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 ; case "personas": return ; case "sessions": return ; case "memories": return ; case "plugins": return ; case "channels": return ; case "channel-events": return ; case "iot": return ; case "rag": return ; case "llm": return ; case "automations": return ; case "agents": return ; case "flow": return ; case "backthink": return ; case "notifications": return ; case "observability": return ; case "users": return ; case "system": return ; default: return ; } } /** * 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) => (
{group}
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => ( ))}
))} ); } /** Desktop left rail — fixed, `hidden md:flex`. */ function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode { return ( ); } /** 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 (
); } /** 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 = { 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 = { 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("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 ( <> {meta.label} 实时通道 · {ws.label} ); } function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode { const role = getStoredRole(); const [theme, setTheme] = useState(readTheme); const meta = ADMIN_SECTIONS.find((s) => s.id === section); const toggleTheme = () => { const next: ThemeMode = theme === "dark" ? "light" : "dark"; applyTheme(next); setTheme(next); }; return (
{onMenu ? ( ) : null}
Cyrene
Cyrene · 管理面板
{meta?.label ?? "仪表盘"}
{role ? ( {role} ) : null} {/* 双档 UI 档位(裁决 16.4.4):面板顶栏也吃档位,胶囊形态对齐其余 h-8 按钮。 与用户端顶栏同理:窄屏收起(
); } function LoginGate(): React.ReactNode { return (
需要登录

管理面板需持有 JWT。请先在用户端 登录页 完成登录后,再返回本页。

); } function Forbidden403(): React.ReactNode { const role = getStoredRole(); return (
403 · 权限不足

当前角色 {role ?? "?"}(viewer 只读)。管理面板需 owner 或 admin 角色;管理端写操作仅 owner。

); } 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 (
); } if (getStoredRole() === "viewer") { return (
); } return (
setAdminMenuOpen(true)} />
{renderSection(section)}
setAdminMenuOpen(false)} />
); }