- 两个只读端点: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
135 lines
4.1 KiB
TypeScript
135 lines
4.1 KiB
TypeScript
/**
|
||
* 管理面板的**安全入口**路由(路径式,裁决 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("/");
|
||
}
|