Files
Cyrene_v2/frontend/web/src/admin/modules/Notifications.tsx
T
AskaEth 34c8cc4dca
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): A6 通知页前端 — 统一通知/事件中心(管理面板#16)
- Notifications.tsx(254行): 类型筛选(全部/system/channel/backthink/audit,服务端重拉) + before游标加载更多(id去重/到底判定) + 空态/错误(后端文案) + 30s静默刷新; 卡片行(非AdminTable,避其rowKey升序与倒序feed冲突); Miuix
- api.ts adminListNotifications + types.ts AdminNotification(Query) + sections.tsx 侧栏项{id:notifications,group:运行时,icon:light} + AdminApp renderSection + router.ts VALID_SECTIONS
- 契约: GET /admin/notifications?type=&before=&limit= → {data:[{id,type,title,body,source,createdAt}]}; 未读/清空需后端read端点(诚实Notice,不做前端假实现)
- 前端 build 绿(157 modules); 后端端点由并发代理[194]落地
- icon 用现有 light(Icon.tsx 无 bell; 如需铃铛另加 path)
2026-09-30 08:57:15 +08:00

254 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AdminNotificationType, TypeMeta> = {
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<string, AdminNotification>();
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<string | null>(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 (
<div className="flex flex-wrap items-center gap-2">
{FILTERS.map((f) => (
<Button
key={f.value || "all"}
size="sm"
variant={f.value === filter ? "primary" : "soft"}
onClick={() => onChange(f.value)}
>
{f.label}
</Button>
))}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">已加载 {count} 条</span>
</div>
);
}
function NotificationRow({ item }: { item: AdminNotification }): React.ReactNode {
const meta = typeMeta(item.type);
return (
<div className="flex flex-col gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<Tag tone={meta.tone}>{meta.label}</Tag>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title || "(无标题)"}</span>
<span className="shrink-0 font-mono text-xs text-[var(--mu-text-lo)]">{formatTime(item.createdAt)}</span>
</div>
{item.body ? (
<div className="line-clamp-2 whitespace-pre-wrap break-words text-xs text-[var(--mu-text-mid)]">
{item.body}
</div>
) : null}
<div className="text-[11px] text-[var(--mu-text-lo)]">
来源 <span className="font-mono">{item.source || "—"}</span>
</div>
</div>
);
}
function LoadMoreFooter({
done,
loading,
onLoadMore,
}: {
done: boolean;
loading: boolean;
onLoadMore: () => void;
}): React.ReactNode {
return (
<div className="flex items-center justify-center pt-1">
{done ? (
<span className="text-xs text-[var(--mu-text-lo)]">已到底部 · 没有更早的通知了</span>
) : (
<Button variant="outline" size="sm" disabled={loading} onClick={onLoadMore}>
{loading ? "加载中…" : "加载更多(更早)"}
</Button>
)}
</div>
);
}
export function NotificationsModule(): React.ReactNode {
const [filter, setFilter] = useState<TypeFilter>("");
const feed = useNotificationFeed(filter);
return (
<div className="space-y-5">
<PageHeader
title="通知"
subtitle="GET /api/v1/admin/notifications?type=&before=&limit= · 统一通知/事件中心:审计事件 + 渠道连接事件 + 主动推送历史(只读聚合,design/10 §3 #16)"
actions={
<Button variant="soft" disabled={feed.loading} onClick={() => feed.reload()}>
刷新
</Button>
}
/>
<Surface className="space-y-3">
<FilterBar filter={filter} count={feed.items.length} onChange={setFilter} />
{feed.error ? <Notice tone="err">{feed.error}</Notice> : null}
{feed.moreError ? <Notice tone="err">加载更多失败:{feed.moreError}</Notice> : null}
{feed.loading ? (
<div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>
) : feed.items.length === 0 ? (
<EmptyState
icon={<Icon name="light" size={32} />}
title="暂无通知"
hint={
filter === ""
? "审计、渠道事件与主动推送产生后会出现在这里。"
: "该类型下没有通知——切到「全部」或等待新事件。"
}
/>
) : (
<div className="space-y-2">
{feed.items.map((n) => (
<NotificationRow key={n.id} item={n} />
))}
</div>
)}
{feed.items.length > 0 ? (
<LoadMoreFooter done={feed.done} loading={feed.moreLoading} onLoadMore={feed.loadMore} />
) : null}
</Surface>
<Notice>
通知由既有数据源聚合而成(审计日志 / 渠道连接事件 / 后台思考主动推送),本页只读——不改写任何源数据。
按类型筛选与「加载更多」走服务端 <span className="font-mono">type</span> / <span className="font-mono">before</span> 游标;
未读标记与清空需要后端已读游标(<span className="font-mono">POST /api/v1/admin/notifications/read</span>)落地后再接入,此处不做前端假实现。
</Notice>
</div>
);
}