forked from boos/BoosAPI
add site announcements
This commit is contained in:
@@ -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