- 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)
254 lines
9.0 KiB
TypeScript
254 lines
9.0 KiB
TypeScript
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>
|
||
);
|
||
}
|