/** * 管理面板的**安全入口**路由(路径式,裁决 1.6 / 16.4.7,design/07 §0.2)。 * * 面板挂在 `.env` 的 `ADMIN_PATH` 前缀下(默认 `/admin`,可自定义成 * `/xxx/admin`),深链是 `/<前缀>/
`。这个前缀**绝不硬编进用户端 * bundle**:后端只在把**面板入口页**送达时注入 `window.__CYRENE_ADMIN_ENTRY__` * (见 internal/services/gateway/routes.go 的 adminEntryHatch)。用户端页面 * 没有这个全局量,因此用户端既没有管理入口链接、也解析不出面板路由。 * * 面板内部的模块切换走 history(pushState + popstate);hash 只留给渠道页 * 自己的子页(admin/modules/channels/useAdapterNav.ts)。 */ export type AdminSectionId = | "dashboard" | "personas" | "sessions" | "memories" | "plugins" | "channels" | "channel-events" | "iot" | "rag" | "llm" | "automations" | "agents" | "flow" | "backthink" | "notifications" | "observability" | "system" | "users"; export interface AdminRoute { admin: boolean; section: AdminSectionId; } /** 后端注入的面板入口信息:只有面板入口那一份 index.html 带它。 */ export interface AdminEntry { prefix: string; } declare global { interface Window { __CYRENE_ADMIN_ENTRY__?: AdminEntry; } } const VALID_SECTIONS: ReadonlySet = new Set([ "dashboard", "personas", "sessions", "memories", "plugins", "channels", "channel-events", "iot", "rag", "llm", "automations", "agents", "flow", "backthink", "notifications", "observability", "system", "users", ]); /** 面板入口的路径前缀(去掉尾斜杠);无入口信息时返回空串。 */ function entryBase(entry: AdminEntry | null): string { return (entry?.prefix ?? "").replace(/\/+$/, ""); } /** 读取后端注入的入口信息;用户端(无注入)返回 null。 */ export function readAdminEntry(): AdminEntry | null { if (typeof window === "undefined") return null; const entry = window.__CYRENE_ADMIN_ENTRY__; return entry && typeof entry.prefix === "string" ? entry : null; } /** * 把 pathname 解析成面板路由:前缀不匹配(或压根没有入口信息,即用户端) * 一律 `admin: false`,因此用户端永远不会被路径名带进面板;未知的 section * 段回落到 dashboard。 */ export function parseAdminRoute(pathname: string, entry: AdminEntry | null): AdminRoute { const base = entryBase(entry); if (!base) return { admin: false, section: "dashboard" }; if (pathname !== base && !pathname.startsWith(`${base}/`)) { return { admin: false, section: "dashboard" }; } const rest = pathname.slice(base.length).replace(/^\/+/, "").split("/")[0] ?? ""; return { admin: true, section: VALID_SECTIONS.has(rest) ? (rest as AdminSectionId) : "dashboard", }; } /** 某个 section 的绝对路径(面板根 = 前缀本身)。 */ export function adminSectionPath(section: AdminSectionId, entry: AdminEntry | null): string { const base = entryBase(entry); if (!base) return "/"; return section === "dashboard" ? base : `${base}/${section}`; } const listeners = new Set<() => void>(); /** 订阅面板路由变化(浏览器前进/后退 + 本模块的 pushState)。 */ export function subscribeAdminRoute(onChange: () => void): () => void { listeners.add(onChange); window.addEventListener("popstate", onChange); return () => { listeners.delete(onChange); window.removeEventListener("popstate", onChange); }; } /** 用 history 跳转并通知订阅者(不刷新页面、不靠 hash)。 */ function navigateTo(path: string): void { if (window.location.pathname !== path) window.history.pushState({}, "", path); for (const fn of listeners) fn(); } /** 跳转到面板某模块(面板侧栏/仪表盘下钻用)。 */ export function navigateAdmin(section: AdminSectionId): void { navigateTo(adminSectionPath(section, readAdminEntry())); } /** 离开面板回到用户端根路径(面板外壳的「返回用户端」用它)。 */ export function navigateUserShell(): void { navigateTo("/"); }