- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」) - 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替 - 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0 - 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500) - 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义) - 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4) - 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500 - 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||
import { getAuthToken, getStoredRole, health } from "../lib/api";
|
||
import { useAppStore } from "../store/useAppStore";
|
||
import type { WsStatus } from "../lib/types";
|
||
import { UiModeSwitch } from "../design/UiModeSwitch";
|
||
import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
|
||
import { navigateAdmin, navigateUserShell, type AdminSectionId } from "./router";
|
||
import { Button, Tag, type TagTone } from "../components/primitives";
|
||
import { Icon } from "../components/Icon";
|
||
import { DashboardModule } from "./modules/Dashboard";
|
||
import { PersonasModule } from "./modules/Personas";
|
||
import { SessionsModule } from "./modules/Sessions";
|
||
import { MemoriesModule } from "./modules/Memories";
|
||
import { PluginsModule } from "./modules/Plugins";
|
||
import { ChannelsModule } from "./modules/Channels";
|
||
import { ChannelEventsModule } from "./modules/ChannelEvents";
|
||
import { IoTModule } from "./modules/IoT";
|
||
import { RagModule } from "./modules/Rag";
|
||
import { LlmModule } from "./modules/Llm";
|
||
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";
|
||
|
||
function renderSection(section: AdminSectionId): React.ReactNode {
|
||
switch (section) {
|
||
case "dashboard": return <DashboardModule />;
|
||
case "personas": return <PersonasModule />;
|
||
case "sessions": return <SessionsModule />;
|
||
case "memories": return <MemoriesModule />;
|
||
case "plugins": return <PluginsModule />;
|
||
case "channels": return <ChannelsModule />;
|
||
case "channel-events": return <ChannelEventsModule />;
|
||
case "iot": return <IoTModule />;
|
||
case "rag": return <RagModule />;
|
||
case "llm": return <LlmModule />;
|
||
case "automations": return <AutomationsModule />;
|
||
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 />;
|
||
default: return <DashboardModule />;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sidebar content shared by the desktop rail and the mobile drawer (design/10).
|
||
* Both render ADMIN_GROUPS with the same `onNavigate` hook — the drawer passes
|
||
* its close callback (tap an item → close), the desktop rail passes a no-op — so
|
||
* desktop and mobile never diverge.
|
||
*/
|
||
function adminNavContent({ active, onNavigate }: { active: AdminSectionId; onNavigate: () => void }): React.ReactNode {
|
||
return (
|
||
<>
|
||
{ADMIN_GROUPS.map((group) => (
|
||
<div key={group} className="mb-1">
|
||
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">{group}</div>
|
||
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => { navigateAdmin(s.id); onNavigate(); }}
|
||
className={`flex w-full items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-left transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
|
||
s.id === active
|
||
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
||
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
|
||
}`}
|
||
>
|
||
<Icon name={s.icon} size={18} />
|
||
<span>{s.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Desktop left rail — fixed, `hidden md:flex`. */
|
||
function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
|
||
return (
|
||
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 md:flex">
|
||
{adminNavContent({ active, onNavigate: () => {} })}
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
/** Mobile drawer — scrim + spring slide-in panel (reuses mu-drawer-in). */
|
||
function AdminMobileNav({ open, active, onClose }: { open: boolean; active: AdminSectionId; onClose: () => void }): React.ReactNode {
|
||
if (!open) return null;
|
||
return (
|
||
<div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true">
|
||
<div
|
||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
|
||
onClick={onClose}
|
||
aria-label="关闭侧边栏"
|
||
/>
|
||
<nav
|
||
className="glass absolute inset-y-0 left-0 flex w-[var(--mu-sidebar-w)] max-w-[80vw] flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3"
|
||
style={{ animation: "mu-drawer-in 0.3s var(--mu-spring) both" }}
|
||
>
|
||
<div className="flex items-center justify-between px-2 pb-1">
|
||
<span className="text-xs font-medium text-[var(--mu-text-lo)]">菜单</span>
|
||
<button
|
||
onClick={onClose}
|
||
aria-label="关闭"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 hover:bg-[var(--mu-surface-4)] active:scale-95"
|
||
>
|
||
<Icon name="close" size={18} />
|
||
</button>
|
||
</div>
|
||
{adminNavContent({ active, onNavigate: onClose })}
|
||
</nav>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Poll interval for the backend liveness probe (GET /api/v1/health). */
|
||
const HEALTH_POLL_MS = 15_000;
|
||
|
||
type BackendState = "checking" | "online" | "offline";
|
||
|
||
const BACKEND_META: Record<BackendState, { label: string; tone: TagTone; dot: string }> = {
|
||
checking: { label: "后端检测中", tone: "neutral", dot: "mu-blink" },
|
||
online: { label: "后端在线", tone: "ok", dot: "bg-[var(--mu-ok)]" },
|
||
offline: { label: "后端失联", tone: "err", dot: "bg-[var(--mu-err)]" },
|
||
};
|
||
|
||
const WS_META: Record<WsStatus, { label: string; tone: TagTone; dot: string }> = {
|
||
idle: { label: "未连接", tone: "neutral", dot: "bg-[var(--mu-text-lo)]" },
|
||
connecting: { label: "连接中", tone: "warn", dot: "mu-blink" },
|
||
connected: { label: "实时", tone: "ok", dot: "bg-[var(--mu-ok)]" },
|
||
reconnecting: { label: "重连中", tone: "warn", dot: "mu-blink" },
|
||
disconnected: { label: "已断开", tone: "err", dot: "bg-[var(--mu-err)]" },
|
||
expired: { label: "登录过期", tone: "err", dot: "bg-[var(--mu-err)]" },
|
||
};
|
||
|
||
/** Real liveness probe — the tag must never claim 在线 before /health has actually
|
||
* answered, so it starts as "检测中" (which is a state of its own, not a guess).
|
||
* The realtime channel is a separate signal from the store: it can be down while
|
||
* REST is fine (and vice versa), so it is shown as its own tag. */
|
||
function useBackendHealth(): BackendState {
|
||
const [state, setState] = useState<BackendState>("checking");
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const probe = () => {
|
||
health()
|
||
.then(() => {
|
||
if (!cancelled) setState("online");
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setState("offline");
|
||
});
|
||
};
|
||
probe();
|
||
const id = window.setInterval(probe, HEALTH_POLL_MS);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(id);
|
||
};
|
||
}, []);
|
||
return state;
|
||
}
|
||
|
||
/** 后端在线 / 失联 + 实时通道状态, both from real signals. */
|
||
function BackendStatusTag(): React.ReactNode {
|
||
const backend = useBackendHealth();
|
||
const wsStatus = useAppStore((s) => s.wsStatus);
|
||
const meta = BACKEND_META[backend];
|
||
const ws = WS_META[wsStatus];
|
||
return (
|
||
<>
|
||
<span className="inline-flex shrink-0" title={`GET /api/v1/health · 每 ${HEALTH_POLL_MS / 1000}s 探测一次`}>
|
||
<Tag tone={meta.tone}>
|
||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`} />
|
||
{meta.label}
|
||
</Tag>
|
||
</span>
|
||
<span className="hidden shrink-0 sm:inline-flex" title="实时通道(/ws)状态">
|
||
<Tag tone={ws.tone}>
|
||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${ws.dot}`} />
|
||
实时通道 · {ws.label}
|
||
</Tag>
|
||
</span>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode {
|
||
const role = getStoredRole();
|
||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||
const meta = ADMIN_SECTIONS.find((s) => s.id === section);
|
||
const toggleTheme = () => {
|
||
const next: ThemeMode = theme === "dark" ? "light" : "dark";
|
||
applyTheme(next);
|
||
setTheme(next);
|
||
};
|
||
|
||
return (
|
||
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-2 border-b border-[var(--mu-glass-border)] px-3 sm:px-4">
|
||
{onMenu ? (
|
||
<button
|
||
onClick={onMenu}
|
||
aria-label="打开菜单"
|
||
title="菜单"
|
||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-95 md:hidden"
|
||
>
|
||
<Icon name="menu" size={20} />
|
||
</button>
|
||
) : null}
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 shrink-0 rounded-full object-cover" />
|
||
<div className="min-w-0 leading-tight">
|
||
<div className="truncate whitespace-nowrap text-sm font-semibold">Cyrene · 管理面板</div>
|
||
<div className="truncate whitespace-nowrap text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1" />
|
||
{role ? (
|
||
<Tag tone={role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral"} className="hidden shrink-0 sm:inline-flex">
|
||
{role}
|
||
</Tag>
|
||
) : null}
|
||
<BackendStatusTag />
|
||
{/* 双档 UI 档位(裁决 16.4.4):面板顶栏也吃档位,胶囊形态对齐其余 h-8 按钮。
|
||
与用户端顶栏同理:窄屏收起(<sm),避免与「返回用户端」等既有控件争宽。 */}
|
||
<span className="hidden shrink-0 sm:inline-flex">
|
||
<UiModeSwitch variant="compact" />
|
||
</span>
|
||
<button
|
||
onClick={toggleTheme}
|
||
aria-label="切换主题"
|
||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
|
||
>
|
||
<Icon name={theme === "dark" ? "sun" : "moon"} size={18} />
|
||
</button>
|
||
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => { navigateUserShell(); }}>
|
||
返回用户端
|
||
</Button>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function LoginGate(): React.ReactNode {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-4)] text-[var(--mu-accent)]">
|
||
<Icon name="lock" size={32} />
|
||
</div>
|
||
<div className="text-lg font-semibold">需要登录</div>
|
||
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
|
||
管理面板需持有 JWT。请先在用户端 <span className="text-[var(--mu-text-hi)]">登录页</span> 完成登录后,再返回本页。
|
||
</p>
|
||
<Button variant="primary" onClick={() => { navigateUserShell(); }}>前往用户端登录</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Forbidden403(): React.ReactNode {
|
||
const role = getStoredRole();
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-err)]/15 text-[var(--mu-err)]">
|
||
<Icon name="block" size={32} />
|
||
</div>
|
||
<div className="text-lg font-semibold">403 · 权限不足</div>
|
||
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
|
||
当前角色 <Tag tone="neutral">{role ?? "?"}</Tag>(viewer 只读)。管理面板需 <Tag tone="accent">owner</Tag> 或 <Tag tone="ok">admin</Tag> 角色;管理端写操作仅 owner。
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button variant="primary" onClick={() => { navigateUserShell(); }}>返回用户端</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AdminApp({ section }: { section: AdminSectionId }): React.ReactNode {
|
||
// Mobile nav drawer — only surfaced on the admin shell (md+ keeps the fixed
|
||
// sidebar); the login/403 guards never render it but still hold the hook.
|
||
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
|
||
|
||
// RBAC guard (design/10 §2 + design/11 §7). A logged-in viewer or an
|
||
// unauthenticated user never sees the admin module; the gateway would also
|
||
// reject them with 401/403.
|
||
if (!getAuthToken() || !getStoredRole()) {
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<AdminTopBar section={section} />
|
||
<LoginGate />
|
||
</div>
|
||
);
|
||
}
|
||
if (getStoredRole() === "viewer") {
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<AdminTopBar section={section} />
|
||
<Forbidden403 />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<AdminTopBar section={section} onMenu={() => setAdminMenuOpen(true)} />
|
||
<div className="flex min-h-0 flex-1">
|
||
<AdminSidebar active={section} />
|
||
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
|
||
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
|
||
</main>
|
||
</div>
|
||
<AdminMobileNav open={adminMenuOpen} active={section} onClose={() => setAdminMenuOpen(false)} />
|
||
</div>
|
||
);
|
||
}
|