This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/app/gov-shell.tsx
T

348 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.
/**
* 治理台外壳 —— 一份壳,两个台面(设计中心 / 运营中心)。
*
* 左轨(本台面的区)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
*
* 两台面差的只是**区表与首页**,皮肤与骨架同一套 —— 故不复制一个壳,而是把台面当参数。
* 区归属、入口亮灯一律读 `surfaces.ts`,壳自己不作主张。
*
* §9.2「路由状态即应用状态」:台面 / 区 / 页签 / 下钻栈全部编码进 URL 的 hash,
* 刷新与深链都能完整还原;前端零过程状态。
*/
import { useCallback, useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Principal } from '@contracts';
import { meRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { logout as kcLogout } from '@web-shared/auth.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, ActiPage, ActvPage, 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, RowPage } 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 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */
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<Place>(() => readPlace(surface));
const me$ = useQuery({ queryKey: queryKeys.me, queryFn: () => call<Principal>(meRoute) });
// 运行态的两份数据只在运营中心取:设计中心不看运行数字(纪律),也不必替它要
const tasks = useGovTasks({ enabled: surface === 'ops' });
const objects = useGovObjects({ enabled: surface === 'ops' });
const queryClient = useQueryClient();
/*
* 登出(ADR-012):先整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人;
* 再走 Keycloak end-session 终结 SSO 会话(`/api/logout` 已退役 410,不再调)。
*/
const logout = () => {
queryClient.clear();
void kcLogout();
};
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
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 = me$.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: [] }) });
/* 页签级只在页签栏真出现时摆 —— 单页签的区页签栏收起(下文 g-tabs 同口径),
面包屑再摆一级同义页签名就是冗余(2026-08-25 用户定,活动区起) */
if (zone === 'ledgers' || TABS[zone].length > 1) {
// 台账区的页签名就是域键 —— 静态表里查不到,呈现走人读短名
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 (
<div className="g-shell gov-root">
{/* ===== 深色左轨:本台面的区 ===== */}
<nav className="g-nav">
<div className="g-nav-mark">
<Ico name={surface === 'design' ? 'route' : 'gauge'} s={20} />
</div>
<div className="g-nav-brand">{SURFACE_META[surface].brand}</div>
{ZONES.filter((z) => SURFACE_ZONES[surface].includes(z.key)).map((z) => (
<button key={z.key} className={'g-nav-b' + (zone === z.key ? ' on' : '')} onClick={() => goZone(z.key)}>
<span className="g-nav-ico">
<Ico name={z.icon} s={18} />
</span>
<span>{z.label}</span>
</button>
))}
<div className="g-nav-foot">
<span className="g-nav-ava">{(me?.name ?? '—').slice(0, 1)}</span>
<span>{me?.name ?? '未登录'}</span>
</div>
</nav>
<div className="g-body">
{/* ===== 深色顶栏 ===== */}
<header className="g-top">
<span className="g-top-brand">
JCAI 操作系统<em>单组织 · ≤50 人 · 生产环境</em>
</span>
<label className="g-search">
<Ico name="search" s={14} />
<input placeholder={SURFACE_META[surface].searchHint} />
<kbd>⌘K</kbd>
</label>
<div className="g-top-r">
{surface === 'ops' && (
<button className={'g-tbtn' + (alerts ? ' alert' : '')} onClick={() => jump('tasks', 'todo')}>
<Ico name="alert-triangle" s={13} />
告警<b>{alerts}</b>
</button>
)}
{/* 三面互跳:另一面与工作台都按 ends 亮灯(ADR-012,权限只到端) */}
{canSeeSurface(me$.data, other) && (
<a className="g-tbtn" href={SURFACE_META[other].path}>
<Ico name={other === 'design' ? 'route' : 'gauge'} s={13} />
{SURFACE_META[other].brand}
</a>
)}
{(me$.data?.ends ?? []).includes('workbench') && (
<a className="g-tbtn" href="/">
<Ico name="list-checks" s={13} />
工作台
</a>
)}
<Btn className="g-tbtn" onClick={logout}>
<Ico name="log-out" s={13} />
登出
</Btn>
</div>
</header>
{/* ===== 工作面 ===== */}
<div className="g-page">
<div className="g-crumb">
{stack.length > 0 && (
<button className="g-back" onClick={back} title="返回上一级(Esc)">
<Ico name="arrow-left" s={15} />
</button>
)}
{crumbs.map((c, i) => (
<span key={i} className="g-crumb-i">
{i > 0 && <i className="sep">›</i>}
{c.go ? <button onClick={c.go}>{c.label}</button> : <b>{c.label}</b>}
</span>
))}
</div>
{top === undefined ? (
<>
<div className="g-head">
<span className="g-head-i" style={{ background: meta.accent }}>
<Ico name={meta.icon} s={19} />
</span>
<div className="g-head-t">
<h1>{meta.title}</h1>
<p>{meta.desc}</p>
</div>
</div>
{/*
* 页签栏只在**真有得选**时出现:一区一内容的区(拆分后大多如此)
* 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。
* 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。
*/}
<div className={'g-tabs' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
{zone === 'ledgers'
? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上
domains.map((d) => (
<button
key={d}
className={'g-tab' + (effectiveTab === d ? ' on' : '')}
title={`数据域 · ${d}`}
onClick={() => go({ zone, tab: d, stack: [] })}
>
<Ico name="table" s={13} />
{domainShort(d)}
</button>
))
: TABS[zone].map((t) => (
<button
key={t.key}
className={'g-tab' + (tab === t.key ? ' on' : '')}
onClick={() => go({ zone, tab: t.key, stack: [] })}
>
<Ico name={t.ico} s={13} />
{t.label}
</button>
))}
</div>
<ZoneHome zone={zone} tab={effectiveTab} nav={nav} />
</>
) : (
<div className="g-detail" key={`${stack.length}:${top.k}:${top.id}`}>
<Detail route={top} nav={nav} />
</div>
)}
</div>
</div>
</div>
);
}
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 <P tab={tab} nav={nav} />;
}
function Detail({ route, nav }: { route: Route; nav: Nav }) {
switch (route.k) {
case 'wf':
case 'apr':
return <WfPage id={route.id} nav={nav} />;
case 'task':
return <TaskPage id={route.id} nav={nav} />;
case 'tasklog':
return <TaskLogPage id={route.id} />;
case 'emp':
return <EmpPage id={route.id} nav={nav} />;
case 'role':
return <RolePage id={route.id} />;
case 'pkg':
return <PkgPage id={route.id} nav={nav} />;
case 'trigger':
return <TriggerPage id={route.id} />;
case 'act':
return <ActPage id={route.id} />;
case 'actd':
return <ActvPage id={route.id} nav={nav} />;
case 'acti':
return <ActiPage id={route.id} />;
case 'tool':
return <ToolPage id={route.id} />;
case 'obj':
return <ObjPage id={route.id} nav={nav} />;
case 'row':
return <RowPage id={route.id} />;
case 'llm':
return <LlmPage id={route.id} />;
case 'chan':
return <ChanPage id={route.id} />;
}
}