import { useMemo, useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; import { adminListNotifications } from "../../lib/api"; import type { AdminNotification, AdminNotificationType } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { Notice, PageHeader } from "../components"; import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives"; import { Icon } from "../../components/Icon"; /** Page size = the backend default; `limit` is capped at 200 server-side. */ const PAGE_SIZE = 50; /** Page 1 refresh cadence (silent — the loaded list stays on screen). */ const RELOAD_MS = 30000; type TypeFilter = AdminNotificationType | ""; interface TypeMeta { label: string; tone: TagTone; } const TYPE_META: Record = { system: { label: "系统", tone: "warn" }, channel: { label: "渠道", tone: "ok" }, backthink: { label: "主动", tone: "accent" }, audit: { label: "审计", tone: "neutral" }, }; /** Toolbar chips, fixed order so the row never reflows. "" = all types. */ const FILTERS: ReadonlyArray<{ value: TypeFilter; label: string }> = [ { value: "", label: "全部" }, { value: "system", label: "系统" }, { value: "channel", label: "渠道" }, { value: "backthink", label: "主动推送" }, { value: "audit", label: "审计" }, ]; /** A type the client does not know yet still renders (never drop a row). */ function typeMeta(type: string): TypeMeta { return TYPE_META[type as AdminNotificationType] ?? { label: type || "未知", tone: "neutral" }; } function pad2(n: number): string { return String(n).padStart(2, "0"); } /** Local MM-DD HH:mm — the feed spans days, so the year would only add noise. */ function formatTime(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return "—"; const d = new Date(ms); return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } const NO_ITEMS: AdminNotification[] = []; /** Newest first, deduped by id: `before` is a timestamp cursor, so items sharing * a millisecond can legitimately appear in two pages. */ function mergeItems(a: readonly AdminNotification[], b: readonly AdminNotification[]): AdminNotification[] { const byId = new Map(); for (const n of [...a, ...b]) byId.set(n.id, n); return Array.from(byId.values()).sort((x, y) => (y.createdAt ?? 0) - (x.createdAt ?? 0)); } function errText(e: unknown): string { return e instanceof Error ? e.message : "加载失败"; } interface Feed { items: AdminNotification[]; loading: boolean; error: string | null; moreLoading: boolean; moreError: string | null; done: boolean; reload: () => void; loadMore: () => void; } /** Page 1 tracks `filter` through useAsyncData (so it also auto-refreshes); older * pages are appended by cursor and survive that silent refresh. Pages fetched * under another filter are ignored by comparing their tag, never reset in an * effect — so switching a filter can't flash stale rows for a frame. */ function useNotificationFeed(filter: TypeFilter): Feed { const head = useAsyncData( () => adminListNotifications({ type: filter || undefined, limit: PAGE_SIZE }), [filter] ); const [tail, setTail] = useState<{ filter: TypeFilter; items: AdminNotification[]; done: boolean }>({ filter, items: [], done: false, }); const [moreLoading, setMoreLoading] = useState(false); const [moreError, setMoreError] = useState(null); useAutoReload(head.reload, RELOAD_MS); const older = tail.filter === filter ? tail.items : NO_ITEMS; const done = tail.filter === filter && tail.done; const items = useMemo(() => mergeItems(head.data ?? [], older), [head.data, older]); const loadMore = (): void => { if (moreLoading || items.length === 0) return; const cursor = items[items.length - 1].createdAt; setMoreLoading(true); setMoreError(null); adminListNotifications({ type: filter || undefined, before: cursor, limit: PAGE_SIZE }) .then((next) => { const known = new Set(items.map((n) => n.id)); const fresh = next.filter((n) => !known.has(n.id)); // A short page — or one holding only duplicates of the cursor timestamp — // must end the walk, otherwise "加载更多" would loop on the same cursor. setTail({ filter, items: [...older, ...fresh], done: next.length < PAGE_SIZE || fresh.length === 0 }); }) .catch((e: unknown) => setMoreError(errText(e))) .finally(() => setMoreLoading(false)); }; return { items, loading: head.loading, error: head.error, moreLoading, moreError, done, reload: head.reload, loadMore, }; } function FilterBar({ filter, count, onChange, }: { filter: TypeFilter; count: number; onChange: (next: TypeFilter) => void; }): React.ReactNode { return (
{FILTERS.map((f) => ( ))} 已加载 {count} 条
); } function NotificationRow({ item }: { item: AdminNotification }): React.ReactNode { const meta = typeMeta(item.type); return (
{meta.label} {item.title || "(无标题)"} {formatTime(item.createdAt)}
{item.body ? (
{item.body}
) : null}
来源 {item.source || "—"}
); } function LoadMoreFooter({ done, loading, onLoadMore, }: { done: boolean; loading: boolean; onLoadMore: () => void; }): React.ReactNode { return (
{done ? ( 已到底部 · 没有更早的通知了 ) : ( )}
); } export function NotificationsModule(): React.ReactNode { const [filter, setFilter] = useState(""); const feed = useNotificationFeed(filter); return (
feed.reload()}> 刷新 } /> {feed.error ? {feed.error} : null} {feed.moreError ? 加载更多失败:{feed.moreError} : null} {feed.loading ? (
加载中…
) : feed.items.length === 0 ? ( } title="暂无通知" hint={ filter === "" ? "审计、渠道事件与主动推送产生后会出现在这里。" : "该类型下没有通知——切到「全部」或等待新事件。" } /> ) : (
{feed.items.map((n) => ( ))}
)} {feed.items.length > 0 ? ( ) : null}
通知由既有数据源聚合而成(审计日志 / 渠道连接事件 / 后台思考主动推送),本页只读——不改写任何源数据。 按类型筛选与「加载更多」走服务端 type / before 游标; 未读标记与清空需要后端已读游标(POST /api/v1/admin/notifications/read)落地后再接入,此处不做前端假实现。
); }