Merge pull request #185 from cita-777/codex/site-announcements
[codex] add site announcements
This commit is contained in:
+5
-2
@@ -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 />} />
|
||||
|
||||
@@ -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)}`),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'token', label: '令牌' },
|
||||
{ value: 'proxy', label: '代理' },
|
||||
{ value: 'status', label: '状态' },
|
||||
{ value: 'site_notice', label: '站点公告' },
|
||||
];
|
||||
|
||||
function levelLabel(level: string) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user