feat(admin): A6 通知页前端 — 统一通知/事件中心(管理面板#16)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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)
This commit is contained in:
AskaEth committed 2026-09-30 08:57:15 +08:00
1 parent 1a85de555d
commit 34c8cc4dca
6 files changed
+302 -1

No files matched your search

+2
View File
@@ -18,6 +18,7 @@ import { AutomationsModule } from "./modules/Automations";
import { AgentsModule } from "./modules/Agents";
import { FlowModule } from "./modules/Flow";
import { BackthinkModule } from "./modules/Backthink";
import { NotificationsModule } from "./modules/Notifications";
import { ObservabilityModule } from "./modules/Observability";
import { UsersModule } from "./modules/Users";
import { SystemModule } from "./modules/System";
@@ -37,6 +38,7 @@ function renderSection(section: AdminSectionId): React.ReactNode {
case "agents": return <AgentsModule />;
case "flow": return <FlowModule />;
case "backthink": return <BackthinkModule />;
case "notifications": return <NotificationsModule />;
case "observability": return <ObservabilityModule />;
case "users": return <UsersModule />;
case "system": return <SystemModule />;
@@ -0,0 +1,253 @@
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>
);
}
+2
View File
@@ -20,6 +20,7 @@ export type AdminSectionId =
| "agents"
| "flow"
| "backthink"
| "notifications"
| "observability"
| "system"
| "users";
@@ -43,6 +44,7 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
"agents",
"flow",
"backthink",
"notifications",
"observability",
"system",
"users",
+2 -1
View File
@@ -1,7 +1,7 @@
import type { AdminSectionId } from "./router";
import type { IconName } from "../components/Icon";
/** Sidebar grouping for the 15 admin modules. Groups are visual only. */
/** Sidebar grouping for the 16 admin modules. Groups are visual only. */
export interface AdminSectionMeta {
id: AdminSectionId;
label: string;
@@ -27,6 +27,7 @@ export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
{ id: "agents", label: "Agent 监控", icon: "agent", group: "运行时" },
{ id: "flow", label: "全链路流程", icon: "chart", group: "运行时" },
{ id: "backthink", label: "后台思考", icon: "night", group: "运行时" },
{ id: "notifications", label: "通知", icon: "light", group: "运行时" },
{ id: "observability", label: "可观测性", icon: "trend", group: "运行时" },
{ id: "users", label: "用户与权限", icon: "users", group: "治理" },
{ id: "system", label: "系统设置", icon: "settings", group: "治理" },
+17
View File
@@ -5,6 +5,8 @@ import type {
AdminIoTDevice,
AdminLlmConfig,
AdminLlmProvider,
AdminNotification,
AdminNotificationQuery,
AdminObservabilityMetrics,
AdminPluginRecord,
AdminRagHit,
@@ -738,6 +740,21 @@ export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
}
/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification
* feed (design/10 §3 #16: audit + channel + backthink + system aggregated).
* `type` omitted = all types, `before` = createdAt(ms) cursor for older pages,
* `limit` defaults to 50 (backend caps at 200). Returned newest first. */
export function adminListNotifications(
params: AdminNotificationQuery = {}
): Promise<AdminNotification[]> {
const qs = new URLSearchParams();
if (params.type) qs.set("type", params.type);
if (params.before !== undefined) qs.set("before", String(params.before));
if (params.limit !== undefined) qs.set("limit", String(params.limit));
const q = qs.toString();
return request<AdminNotification[]>(`/admin/notifications${q ? `?${q}` : ""}`);
}
/* ----------------------------------------------------------------------------
* System management admin writes (owner-only, audited). Reference:
* internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约.
+26
View File
@@ -774,3 +774,29 @@ export interface KookViewResponse {
members?: KookGuildUser[];
errors?: Record<string, string>;
}
/** Notification families of the unified feed (design/10 §3 #16). The endpoint
* aggregates pre-existing sources instead of a new model: audit = 系统/安全审计,
* channel = 渠道连接/断开/重连, backthink = 主动推送历史, system = 其余系统事件. */
export type AdminNotificationType = "system" | "channel" | "backthink" | "audit";
/** One item of GET /api/v1/admin/notifications (newest first). `createdAt` is
* epoch milliseconds; `source` records which source produced it (audit /
* backthink / a channel name), so the page can show provenance honestly. */
export interface AdminNotification {
id: string;
type: AdminNotificationType;
title: string;
body?: string;
source: string;
createdAt: number;
}
/** Query for GET /api/v1/admin/notifications. An absent `type` means “all”;
* `before` is a `createdAt` (ms) cursor walking toward older items; `limit`
* defaults to 50 and is clamped to 200 by the backend. */
export interface AdminNotificationQuery {
type?: AdminNotificationType;
before?: number;
limit?: number;
}