Files
Cyrene_v2/frontend/web/src/admin/router.ts
T
AskaEth c8053b1f36
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
- 两个只读端点: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
2026-10-05 14:38:37 +08:00

135 lines
4.1 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.
/**
* 管理面板的**安全入口**路由(路径式,裁决 1.6 / 16.4.7,design/07 §0.2)。
*
* 面板挂在 `.env` 的 `ADMIN_PATH` 前缀下(默认 `/admin`,可自定义成
* `/xxx/admin`),深链是 `/<前缀>/<section>`。这个前缀**绝不硬编进用户端
* 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<string> = 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("/");
}