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 { AgentsModule } from "./modules/Agents";
|
||||||
import { FlowModule } from "./modules/Flow";
|
import { FlowModule } from "./modules/Flow";
|
||||||
import { BackthinkModule } from "./modules/Backthink";
|
import { BackthinkModule } from "./modules/Backthink";
|
||||||
|
import { NotificationsModule } from "./modules/Notifications";
|
||||||
import { ObservabilityModule } from "./modules/Observability";
|
import { ObservabilityModule } from "./modules/Observability";
|
||||||
import { UsersModule } from "./modules/Users";
|
import { UsersModule } from "./modules/Users";
|
||||||
import { SystemModule } from "./modules/System";
|
import { SystemModule } from "./modules/System";
|
||||||
@@ -37,6 +38,7 @@ function renderSection(section: AdminSectionId): React.ReactNode {
|
|||||||
case "agents": return <AgentsModule />;
|
case "agents": return <AgentsModule />;
|
||||||
case "flow": return <FlowModule />;
|
case "flow": return <FlowModule />;
|
||||||
case "backthink": return <BackthinkModule />;
|
case "backthink": return <BackthinkModule />;
|
||||||
|
case "notifications": return <NotificationsModule />;
|
||||||
case "observability": return <ObservabilityModule />;
|
case "observability": return <ObservabilityModule />;
|
||||||
case "users": return <UsersModule />;
|
case "users": return <UsersModule />;
|
||||||
case "system": return <SystemModule />;
|
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"
|
| "agents"
|
||||||
| "flow"
|
| "flow"
|
||||||
| "backthink"
|
| "backthink"
|
||||||
|
| "notifications"
|
||||||
| "observability"
|
| "observability"
|
||||||
| "system"
|
| "system"
|
||||||
| "users";
|
| "users";
|
||||||
@@ -43,6 +44,7 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
|
|||||||
"agents",
|
"agents",
|
||||||
"flow",
|
"flow",
|
||||||
"backthink",
|
"backthink",
|
||||||
|
"notifications",
|
||||||
"observability",
|
"observability",
|
||||||
"system",
|
"system",
|
||||||
"users",
|
"users",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AdminSectionId } from "./router";
|
import type { AdminSectionId } from "./router";
|
||||||
import type { IconName } from "../components/Icon";
|
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 {
|
export interface AdminSectionMeta {
|
||||||
id: AdminSectionId;
|
id: AdminSectionId;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -27,6 +27,7 @@ export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
|
|||||||
{ id: "agents", label: "Agent 监控", icon: "agent", group: "运行时" },
|
{ id: "agents", label: "Agent 监控", icon: "agent", group: "运行时" },
|
||||||
{ id: "flow", label: "全链路流程", icon: "chart", group: "运行时" },
|
{ id: "flow", label: "全链路流程", icon: "chart", group: "运行时" },
|
||||||
{ id: "backthink", label: "后台思考", icon: "night", group: "运行时" },
|
{ id: "backthink", label: "后台思考", icon: "night", group: "运行时" },
|
||||||
|
{ id: "notifications", label: "通知", icon: "light", group: "运行时" },
|
||||||
{ id: "observability", label: "可观测性", icon: "trend", group: "运行时" },
|
{ id: "observability", label: "可观测性", icon: "trend", group: "运行时" },
|
||||||
{ id: "users", label: "用户与权限", icon: "users", group: "治理" },
|
{ id: "users", label: "用户与权限", icon: "users", group: "治理" },
|
||||||
{ id: "system", label: "系统设置", icon: "settings", group: "治理" },
|
{ id: "system", label: "系统设置", icon: "settings", group: "治理" },
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
AdminIoTDevice,
|
AdminIoTDevice,
|
||||||
AdminLlmConfig,
|
AdminLlmConfig,
|
||||||
AdminLlmProvider,
|
AdminLlmProvider,
|
||||||
|
AdminNotification,
|
||||||
|
AdminNotificationQuery,
|
||||||
AdminObservabilityMetrics,
|
AdminObservabilityMetrics,
|
||||||
AdminPluginRecord,
|
AdminPluginRecord,
|
||||||
AdminRagHit,
|
AdminRagHit,
|
||||||
@@ -738,6 +740,21 @@ export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
|
|||||||
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
|
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:
|
* System management admin writes (owner-only, audited). Reference:
|
||||||
* internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约.
|
* internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约.
|
||||||
|
|||||||
@@ -774,3 +774,29 @@ export interface KookViewResponse {
|
|||||||
members?: KookGuildUser[];
|
members?: KookGuildUser[];
|
||||||
errors?: Record<string, string>;
|
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