Files
Cyrene_v2/frontend/web/src/admin/AdminApp.tsx
T
AskaEth 8d70f6d331
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 双档 UI 模式(性能档 = 现状 / Smooth Corner 华丽档)+ 开关挂载(裁决 16.4.4)
- design/uiMode.ts:档位纯逻辑(localStorage `cyrene.ui-mode` 持久化、跨标签页同步、未知值回落默认档),
  只改 <html> 类名与 data 属性 ⇒ 不碰 React 树、不重挂载、几何尺寸不变
- 性能档=**现状**:tokens.css / styles/index.css 的改动**全为新增(0 删除)**,且全部限定在
  `:root.ui-mode-fancy` 下;75 元素签名比对证明性能档渲染与改动前逐元素一致
- 华丽档对齐六大核心:连续曲率 corner-shape / 多层 Surface 环境光 / 弹簧动效 / 深色优先(浅色单列补偿)/
  毛玻璃升级 / Liquid Glass(4 折射 + RGB 色差 + 鼠标跟踪 + 双层动态边框)
- 开关挂载:main.tsx 在 createRoot 之前 initUiMode(零闪烁);TopBar/Nav/AdminTopBar 三处挂载,
  窄屏(<640px)按既有约定收起(实测否则 375px 角色块被压到 27px、面板溢出 73px),移动端由侧栏抽屉覆盖
- 复核:tsc 0 错 + vite build 成功;uiMode 38 项 node 测试 + headless Chrome 真渲染 30 项(真点击/真持久化/
  真指针事件);变异反证(去掉读回 → 持久化往返必红)
2026-10-04 13:41:09 +08:00

320 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 { 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 "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={() => { window.location.hash = ""; }}>
返回用户端
</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={() => { window.location.hash = ""; }}>前往用户端登录</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={() => { window.location.hash = ""; }}>返回用户端</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>
);
}