From 34c8cc4dcab8f043b78077b9fb9f9017f5f3961f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 30 Sep 2026 08:57:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20A6=20=E9=80=9A=E7=9F=A5=E9=A1=B5?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20=E2=80=94=20=E7=BB=9F=E4=B8=80=E9=80=9A?= =?UTF-8?q?=E7=9F=A5/=E4=BA=8B=E4=BB=B6=E4=B8=AD=E5=BF=83(=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=9D=A2=E6=9D=BF#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/web/src/admin/AdminApp.tsx | 2 + .../web/src/admin/modules/Notifications.tsx | 253 ++++++++++++++++++ frontend/web/src/admin/router.ts | 2 + frontend/web/src/admin/sections.tsx | 3 +- frontend/web/src/lib/api.ts | 17 ++ frontend/web/src/lib/types.ts | 26 ++ 6 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 frontend/web/src/admin/modules/Notifications.tsx diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index 4a617e1..59ad6db 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -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 ; case "flow": return ; case "backthink": return ; + case "notifications": return ; case "observability": return ; case "users": return ; case "system": return ; diff --git a/frontend/web/src/admin/modules/Notifications.tsx b/frontend/web/src/admin/modules/Notifications.tsx new file mode 100644 index 0000000..8ed9879 --- /dev/null +++ b/frontend/web/src/admin/modules/Notifications.tsx @@ -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 = { + 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)落地后再接入,此处不做前端假实现。 + +
+ ); +} diff --git a/frontend/web/src/admin/router.ts b/frontend/web/src/admin/router.ts index a707dd8..78f11c5 100644 --- a/frontend/web/src/admin/router.ts +++ b/frontend/web/src/admin/router.ts @@ -20,6 +20,7 @@ export type AdminSectionId = | "agents" | "flow" | "backthink" + | "notifications" | "observability" | "system" | "users"; @@ -43,6 +44,7 @@ const VALID_SECTIONS: ReadonlySet = new Set([ "agents", "flow", "backthink", + "notifications", "observability", "system", "users", diff --git a/frontend/web/src/admin/sections.tsx b/frontend/web/src/admin/sections.tsx index 703df50..f195200 100644 --- a/frontend/web/src/admin/sections.tsx +++ b/frontend/web/src/admin/sections.tsx @@ -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 = [ { 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: "治理" }, diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index bc2ef47..06c9951 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -5,6 +5,8 @@ import type { AdminIoTDevice, AdminLlmConfig, AdminLlmProvider, + AdminNotification, + AdminNotificationQuery, AdminObservabilityMetrics, AdminPluginRecord, AdminRagHit, @@ -738,6 +740,21 @@ export function adminBackthink(limit?: number): Promise { return request(`/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 { + 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(`/admin/notifications${q ? `?${q}` : ""}`); +} + /* ---------------------------------------------------------------------------- * System management admin writes (owner-only, audited). Reference: * internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约. diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index f39bc66..6632fdb 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -774,3 +774,29 @@ export interface KookViewResponse { members?: KookGuildUser[]; errors?: Record; } + +/** 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; +}