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)
This commit is contained in:
1 parent
1a85de555d
commit
34c8cc4dca
6 files changed
+302
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "治理" },
|
||||
|
||||
@@ -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 §系统设置契约.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user