/** * 治理台外壳 —— 一份壳,两个台面(设计中心 / 运营中心)。 * * 左轨(本台面的区)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。 * 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」 * * 两台面差的只是**区表与首页**,皮肤与骨架同一套 —— 故不复制一个壳,而是把台面当参数。 * 区归属、入口亮灯一律读 `surfaces.ts`,壳自己不作主张。 * * §9.2「路由状态即应用状态」:台面 / 区 / 页签 / 下钻栈全部编码进 URL 的 hash, * 刷新与深链都能完整还原;前端零过程状态。 */ import { useCallback, useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { Session } from '@contracts'; import { logoutRoute, sessionRoute } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; import { Ico } from '@web-shared/Ico.tsx'; import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts'; import type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts'; import { SURFACE_META, SURFACE_ZONES, canSeeSurface } from '@web-shared/gov/surfaces.ts'; import type { Surface } from '@web-shared/gov/surfaces.ts'; import { useGovObjects, useGovTasks } from '@web-shared/gov/gov-api.ts'; import { domainShort, sortDomains } from '@web-shared/gov/domains.ts'; import { queryKeys } from './query.ts'; import { BlueprintHome } from '@features/gov-blueprint'; import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task'; import { RunsHome } from '@features/gov-runs'; import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff'; import { PkgPage, WorkflowsHome } from '@features/gov-process'; import { TriggerPage, TriggerRulesHome } from '@features/gov-trigger'; import { ActPage, LibraryHome } from '@features/gov-activity'; import { ToolPage, ToolsHome } from '@features/gov-tool'; import { DataHome, LedgersHome, ObjPage } from '@features/gov-data'; import { ArchiveHome } from '@features/gov-archive'; import { TestbedHome } from '@features/gov-testbed'; import { CockpitHome } from '@features/gov-memory'; import { ChanPage, LlmPage, ModelsHome, SystemHome } from '@features/gov-system'; import { Btn } from '@web-shared/Btn.tsx'; /* ── URL 编解码:`#///[/:…]` ────────────────── */ type Place = { zone: GovZone; tab: string; stack: Route[] }; function readPlace(surface: Surface): Place { const raw = window.location.hash.replace(/^#\/?/, ''); const parts = raw.split('/').filter((s) => s !== ''); // 区限定在**本台面**的区表内:越界(旧链接、手改地址)落回本台首区,不串台 const zones = SURFACE_ZONES[surface]; const zone = zones.find((z) => z === parts[1]) ?? zones[0]!; const tabs = TABS[zone]; // 台账区的页签是数据域(动态生成),静态表里没有 —— 认不出就原样收下,别兜到不存在的首签 const tab = tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]?.key ?? (parts[2] ?? ''); const stack: Route[] = []; for (const seg of parts.slice(3)) { const at = seg.indexOf(':'); if (at <= 0) continue; stack.push({ k: seg.slice(0, at), id: decodeURIComponent(seg.slice(at + 1)) } as Route); } return { zone, tab, stack }; } const writePlace = (surface: Surface, p: Place): string => ['#', surface, p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/'); export function GovShell({ surface }: { surface: Surface }) { const [place, setPlace] = useState(() => readPlace(surface)); const session = useQuery({ queryKey: queryKeys.session, queryFn: () => call(sessionRoute) }); // 运行态的两份数据只在运营中心取:设计中心不看运行数字(纪律),也不必替它要 const tasks = useGovTasks({ enabled: surface === 'ops' }); const objects = useGovObjects({ enabled: surface === 'ops' }); const queryClient = useQueryClient(); /** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */ const logout = useMutation({ mutationFn: () => call<{ ok: boolean }>(logoutRoute), onSuccess: () => { queryClient.clear(); // 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影 window.location.assign('/'); }, }); // 浏览器前进 / 后退即导航 —— 路由状态就是应用状态 useEffect(() => { const onHash = () => setPlace(readPlace(surface)); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, [surface]); const go = useCallback( (next: Place) => { window.location.hash = writePlace(surface, next); setPlace(next); }, [surface], ); const push = useCallback( (r: Route) => { const p = readPlace(surface); go({ ...p, stack: [...p.stack, r] }); }, [go, surface], ); const back = useCallback(() => { const p = readPlace(surface); go({ ...p, stack: p.stack.slice(0, -1) }); }, [go, surface]); const goZone = useCallback((z: GovZone) => go({ zone: z, tab: TABS[z][0]?.key ?? '', stack: [] }), [go]); const jump = useCallback((z: GovZone, tab: string, r?: Route) => go({ zone: z, tab, stack: r ? [r] : [] }), [go]); const nav: Nav = { push, jump }; // Esc 退一级 —— 与面包屑同义 useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') back(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [back]); const { zone, tab, stack } = place; const meta = ZM[zone]; const top = stack[stack.length - 1]; const me = session.data?.staff; const other: Surface = surface === 'design' ? 'ops' : 'design'; /** 告警计数:异常工作流 + 非放行终态的任务 —— 只有运营中心管这个 */ const alerts = (tasks.data?.workflows ?? []).filter((w) => w.state === 'abnormal').length + (tasks.data?.tasks ?? []).filter((t) => t.terminal !== undefined && t.terminal !== 'released').length; /** 台账的域页签按**已有域**长出来 —— 设计侧一发布新域,台账里自动多一个页签 */ const domains = sortDomains([...new Set((objects.data?.objects ?? []).map((o) => o.domain))]); // 进台账没带域(左轨点进来)时落到首域 —— 页签与内容同一判定 const effectiveTab = zone === 'ledgers' && tab === '' ? (domains[0] ?? '') : tab; /* 面包屑:台面 › 区 › 页签 › 下钻各级 */ const crumbs: Array<{ label: string; go?: () => void }> = [ { label: SURFACE_META[surface].brand, go: () => goZone(zone) }, ]; crumbs.push({ label: meta.title, go: () => go({ zone, tab, stack: [] }) }); // 台账区的页签名就是域键 —— 静态表里查不到,呈现走人读短名 crumbs.push({ label: TABS[zone].find((t) => t.key === tab)?.label ?? (zone === 'ledgers' ? domainShort(effectiveTab) : tab), go: () => go({ zone, tab, stack: [] }), }); stack.forEach((r, i) => crumbs.push({ label: routeLabel(r), ...(i === stack.length - 1 ? {} : { go: () => go({ zone, tab, stack: stack.slice(0, i + 1) }) }), }), ); return (
{/* ===== 深色左轨:本台面的区 ===== */}
{/* ===== 深色顶栏 ===== */}
JCAI 操作系统单组织 · ≤50 人 · 生产环境
{surface === 'ops' && ( )} {/* 三面互跳:另一面按授权亮灯,工作台人人可回 */} {canSeeSurface(session.data, other) && ( {SURFACE_META[other].brand} )} 工作台 logout.mutate()} busy={logout.isPending}> 登出
{/* ===== 工作面 ===== */}
{stack.length > 0 && ( )} {crumbs.map((c, i) => ( {i > 0 && ›} {c.go ? : {c.label}} ))}
{top === undefined ? ( <>

{meta.title}

{meta.desc}

{/* * 页签栏只在**真有得选**时出现:一区一内容的区(拆分后大多如此) * 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。 * 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。 */}
{zone === 'ledgers' ? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上 domains.map((d) => ( )) : TABS[zone].map((t) => ( ))}
) : (
)}
); } function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) { const P = { // 设计中心 blueprint: BlueprintHome, triggers: TriggerRulesHome, workflows: WorkflowsHome, library: LibraryHome, tools: ToolsHome, data: DataHome, archive: ArchiveHome, models: ModelsHome, testbed: TestbedHome, // 运营中心 cockpit: CockpitHome, runs: RunsHome, tasks: TasksHome, people: PeopleHome, ledgers: LedgersHome, system: SystemHome, }[zone]; return

; } function Detail({ route, nav }: { route: Route; nav: Nav }) { switch (route.k) { case 'wf': case 'apr': return ; case 'task': return ; case 'tasklog': return ; case 'emp': return ; case 'role': return ; case 'pkg': return ; case 'trigger': return ; case 'act': return ; case 'tool': return ; case 'obj': return ; case 'llm': return ; case 'chan': return ; } }