add site announcements

This commit is contained in:
cita
2026-03-20 11:24:29 +08:00
parent 5a9f007a75
commit 940bc1d71c
35 changed files with 1831 additions and 15 deletions
+5 -2
View File
@@ -35,6 +35,7 @@ const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
type ThemeMode = 'system' | 'light' | 'dark';
@@ -322,12 +323,13 @@ function UserProfileModal({
);
}
const sidebarGroups = [
export const sidebarGroups = [
{
label: '控制台',
items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/site-announcements', label: '站点公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
@@ -764,6 +766,7 @@ function AppShell() {
<Routes>
<Route path="/" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} />
<Route path="/site-announcements" element={<SiteAnnouncements />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/oauth" element={<OAuthManagement />} />
<Route path="/tokens" element={<Tokens />} />
+8
View File
@@ -478,6 +478,14 @@ export const api = {
markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }),
markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }),
clearEvents: () => request('/api/events', { method: 'DELETE' }),
getSiteAnnouncements: (params?: string) => request(`/api/site-announcements${params ? '?' + params : ''}`),
markSiteAnnouncementRead: (id: number) => request(`/api/site-announcements/${id}/read`, { method: 'POST' }),
markAllSiteAnnouncementsRead: () => request('/api/site-announcements/read-all', { method: 'POST' }),
clearSiteAnnouncements: () => request('/api/site-announcements', { method: 'DELETE' }),
syncSiteAnnouncements: (payload?: { siteId?: number }) => request('/api/site-announcements/sync', {
method: 'POST',
body: JSON.stringify(payload || {}),
}),
getTasks: (limit = 50) => request(`/api/tasks?limit=${Math.max(1, Math.min(200, Math.trunc(limit)))}`),
getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`),
+2 -1
View File
@@ -18,6 +18,7 @@ const typeLabels: Record<string, string> = {
token: '令牌',
proxy: '代理',
status: '状态',
site_notice: '站点公告',
};
export default function NotificationPanel({
@@ -97,7 +98,7 @@ export default function NotificationPanel({
{/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status'].map((filterType) => (
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
<button key={filterType} onClick={() => setFilter(filterType)}
style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
+1
View File
@@ -26,6 +26,7 @@ const TYPE_OPTIONS = [
{ value: 'token', label: '令牌' },
{ value: 'proxy', label: '代理' },
{ value: 'status', label: '状态' },
{ value: 'site_notice', label: '站点公告' },
];
function levelLabel(level: string) {
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusAnnouncementId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js';
type SiteAnnouncementRow = {
id: number;
siteId: number;
platform: string;
sourceKey: string;
title: string;
content: string;
level: 'info' | 'warning' | 'error';
firstSeenAt?: string | null;
lastSeenAt?: string | null;
readAt?: string | null;
};
type SiteRow = {
id: number;
name: string;
platform?: string | null;
};
export default function SiteAnnouncements() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const [rows, setRows] = useState<SiteAnnouncementRow[]>([]);
const [sites, setSites] = useState<SiteRow[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [clearing, setClearing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [highlightAnnouncementId, setHighlightAnnouncementId] = useState<number | null>(null);
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
const siteNameById = useMemo(() => {
const map = new Map<number, string>();
for (const site of sites) {
map.set(site.id, site.name);
}
return map;
}, [sites]);
const load = async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
const [announcementRows, siteRows] = await Promise.all([
api.getSiteAnnouncements(),
api.getSites(),
]);
setRows(Array.isArray(announcementRows) ? announcementRows : []);
setSites(Array.isArray(siteRows) ? siteRows : []);
} catch (error: any) {
toast.error(error?.message || '加载站点公告失败');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
return () => {
const win = globalThis as typeof globalThis & {
clearTimeout?: typeof clearTimeout;
};
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
};
}, []);
useEffect(() => {
const focusAnnouncementId = readFocusAnnouncementId(location.search);
if (!focusAnnouncementId || loading) return;
const row = rowRefs.current.get(focusAnnouncementId);
const nextSearch = clearFocusParams(location.search);
const win = globalThis as typeof globalThis & {
setTimeout?: typeof setTimeout;
clearTimeout?: typeof clearTimeout;
};
if (!row) {
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
return;
}
row.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
setHighlightAnnouncementId(focusAnnouncementId);
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
if (typeof win.setTimeout === 'function') {
highlightTimerRef.current = win.setTimeout(() => {
setHighlightAnnouncementId((current) => (current === focusAnnouncementId ? null : current));
}, 2200);
}
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, rows]);
const clearAll = async () => {
setClearing(true);
try {
await api.clearSiteAnnouncements();
setRows([]);
toast.success('公告已清空');
} catch (error: any) {
toast.error(error?.message || '清空公告失败');
} finally {
setClearing(false);
}
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await api.markAllSiteAnnouncementsRead();
setRows((current) => current.map((row) => ({
...row,
readAt: row.readAt || new Date().toISOString(),
})));
toast.success('已标记全部为已读');
} catch (error: any) {
toast.error(error?.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const triggerSync = async () => {
try {
await api.syncSiteAnnouncements();
toast.success('公告同步任务已启动');
} catch (error: any) {
toast.error(error?.message || '启动同步失败');
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('站点公告')}</h2>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={triggerSync}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
手动同步
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
</button>
</div>
</div>
<div className="card" style={{ padding: 0 }}>
{loading ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<span className="spinner spinner-sm" />
</div>
) : rows.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center' }}>
<div className="empty-state-title">暂无公告</div>
<div className="empty-state-desc">当前没有可显示的站点公告。</div>
</div>
) : (
rows.map((row, index) => (
<div
key={row.id}
ref={(node) => {
if (node) rowRefs.current.set(row.id, node);
else rowRefs.current.delete(row.id);
}}
className={`animate-slide-up stagger-${Math.min(index + 1, 5)} ${highlightAnnouncementId === row.id ? 'row-focus-highlight' : ''}`.trim()}
style={{
padding: '16px 18px',
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 6 }}>
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span className="badge badge-muted">{siteNameById.get(row.siteId) || `站点 #${row.siteId}`}</span>
<span className="badge badge-info">{row.platform}</span>
<span className={`badge ${row.readAt ? 'badge-muted' : 'badge-warning'}`}>{row.readAt ? '已读' : '未读'}</span>
</div>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{row.content}</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
首次发现:{formatDateTimeLocal(row.firstSeenAt || row.lastSeenAt || '')}
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import {
buildSiteFocusPath,
clearFocusParams,
readFocusAccountIntent,
readFocusAnnouncementId,
readFocusSiteId,
} from './navigationFocus.js';
@@ -20,6 +21,8 @@ describe('navigationFocus helpers', () => {
it('parses focus params from query string', () => {
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
expect(readFocusAnnouncementId('?focusAnnouncementId=21')).toBe(21);
expect(readFocusAnnouncementId('?focusAnnouncementId=abc')).toBeNull();
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
accountId: 22,
@@ -37,6 +40,7 @@ describe('navigationFocus helpers', () => {
it('clears focus params but keeps other params', () => {
expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc');
expect(clearFocusParams('?focusAnnouncementId=44&type=site_notice')).toBe('?type=site_notice');
expect(clearFocusParams('?focusAccountId=2&openRebind=1&type=token')).toBe('?type=token');
expect(clearFocusParams('?focusAccountId=2')).toBe('');
});
@@ -60,6 +64,12 @@ describe('navigationFocus helpers', () => {
type: 'status',
})).toBe('/sites?focusSiteId=9');
expect(buildEventNavigationPath({
relatedType: 'site_announcement',
relatedId: 12,
type: 'site_notice',
})).toBe('/site-announcements?focusAnnouncementId=12');
expect(buildEventNavigationPath({
relatedType: 'route',
relatedId: null,
@@ -68,6 +78,7 @@ describe('navigationFocus helpers', () => {
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
expect(buildEventNavigationPath({})).toBe('/events');
});
});
+19
View File
@@ -1,4 +1,5 @@
const FOCUS_SITE_ID_KEY = 'focusSiteId';
const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId';
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
const OPEN_REBIND_KEY = 'openRebind';
@@ -21,6 +22,12 @@ export function buildSiteFocusPath(siteId: number): string {
return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`;
}
export function buildAnnouncementFocusPath(announcementId: number): string {
const normalizedId = normalizePositiveId(announcementId);
if (!normalizedId) return '/site-announcements';
return `/site-announcements?${FOCUS_ANNOUNCEMENT_ID_KEY}=${normalizedId}`;
}
export function buildAccountFocusPath(
accountId: number,
options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
@@ -48,6 +55,11 @@ export function readFocusSiteId(search: string): number | null {
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
}
export function readFocusAnnouncementId(search: string): number | null {
const params = new URLSearchParams(search);
return normalizePositiveId(params.get(FOCUS_ANNOUNCEMENT_ID_KEY));
}
export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } {
const params = new URLSearchParams(search);
return {
@@ -64,6 +76,7 @@ export function readFocusTokenId(search: string): number | null {
export function clearFocusParams(search: string): string {
const params = new URLSearchParams(search);
params.delete(FOCUS_SITE_ID_KEY);
params.delete(FOCUS_ANNOUNCEMENT_ID_KEY);
params.delete(FOCUS_ACCOUNT_ID_KEY);
params.delete(FOCUS_TOKEN_ID_KEY);
params.delete(OPEN_REBIND_KEY);
@@ -86,6 +99,9 @@ export function buildEventNavigationPath(event: {
if (relatedType === 'site' && relatedId) {
return buildSiteFocusPath(relatedId);
}
if (relatedType === 'site_announcement' && relatedId) {
return buildAnnouncementFocusPath(relatedId);
}
if (relatedType === 'route') {
return '/routes';
}
@@ -95,6 +111,9 @@ export function buildEventNavigationPath(event: {
if (eventType === 'checkin') {
return '/checkin';
}
if (eventType === 'site_notice') {
return '/site-announcements';
}
return '/events';
}
+128
View File
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import SiteAnnouncements from './SiteAnnouncements.js';
import { sidebarGroups } from '../App.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSiteAnnouncements: vi.fn(),
getSites: vi.fn(),
markSiteAnnouncementRead: vi.fn(),
markAllSiteAnnouncementsRead: vi.fn(),
clearSiteAnnouncements: vi.fn(),
syncSiteAnnouncements: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: any): string {
const children = node?.children || [];
return children.map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('SiteAnnouncements page', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 9, name: 'Sub Site', platform: 'sub2api' },
]);
apiMock.getSiteAnnouncements.mockResolvedValue([
{
id: 12,
siteId: 9,
platform: 'sub2api',
sourceKey: 'announcement:12',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
readAt: null,
},
]);
apiMock.clearSiteAnnouncements.mockResolvedValue({ success: true });
apiMock.markAllSiteAnnouncementsRead.mockResolvedValue({ success: true });
apiMock.syncSiteAnnouncements.mockResolvedValue({ success: true, queued: true, taskId: 'task-1' });
});
afterEach(() => {
vi.clearAllMocks();
});
it('exposes the renamed sidebar item and the site announcements entry', () => {
const consoleGroup = sidebarGroups.find((group) => group.label === '控制台');
const labels = (consoleGroup?.items || []).map((item) => item.label);
expect(labels).toContain('站点管理');
expect(labels).toContain('站点公告');
});
it('loads announcements, highlights the focused row, and clears local rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/site-announcements?focusAnnouncementId=12']}>
<Routes>
<Route path="/site-announcements" element={<SiteAnnouncements />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
{
createNodeMock: (element) => {
if (element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
});
await flushMicrotasks();
expect(apiMock.getSiteAnnouncements).toHaveBeenCalled();
expect(apiMock.getSites).toHaveBeenCalled();
expect(JSON.stringify(root!.toJSON())).toContain('站点公告');
expect(JSON.stringify(root!.toJSON())).toContain('Maintenance');
const highlightedRows = root!.root.findAll((node) => {
const className = typeof node.props.className === 'string' ? node.props.className : '';
return className.includes('row-focus-highlight') && collectText(node).includes('Maintenance');
});
expect(highlightedRows).toHaveLength(1);
const clearButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('清空公告')
));
await act(async () => {
await clearButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.clearSiteAnnouncements).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
});