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

378 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 { 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 { Note } from '@web-shared/gov/primitives.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 { queryKeys } from './query.ts';
import { BlueprintHome } from '@features/gov-blueprint';
import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task';
import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff';
import { EdgesPage, PkgPage, WorkflowsHome } from '@features/gov-process';
import { TriggerPage } from '@features/gov-trigger';
import { ActPage, LibraryHome } from '@features/gov-activity';
import { ToolPage, ToolsHome } from '@features/gov-tool';
import { DataHome, ObjPage } from '@features/gov-data';
import { CockpitHome, KnowledgeHome, MemPage } from '@features/gov-memory';
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
/* ── 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 session = useQuery({ queryKey: queryKeys.session, queryFn: () => call<Session>(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 = [...new Set((objects.data?.objects ?? []).map((o) => o.domain))].sort();
/* 面包屑:台面 › 区 › 页签 › 下钻各级 */
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 ?? 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) =>
// 台账不是一个固定位,是一段**随数据域生长**的动态位 —— 每域一枚
z.key === 'ledgers' ? (
<LedgerRail
key={z.key}
domains={domains}
active={zone === 'ledgers' ? tab : undefined}
onPick={(domain) => go({ zone: 'ledgers', tab: domain, stack: [] })}
/>
) : (
<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>
)}
{/* 三面互跳:另一面按授权亮灯,工作台人人可回 */}
{canSeeSurface(session.data, other) && (
<a className="g-tbtn" href={SURFACE_META[other].path}>
<Ico name={other === 'design' ? 'route' : 'gauge'} s={13} />
{SURFACE_META[other].brand}
</a>
)}
<a className="g-tbtn" href="/">
<Ico name="list-checks" s={13} />
工作台
</a>
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}>
<Ico name="log-out" s={13} />
登出
</button>
</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">
{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={tab} nav={nav} />
</>
) : (
<div className="g-detail" key={`${stack.length}:${top.k}:${top.id}`}>
<Detail route={top} nav={nav} />
</div>
)}
</div>
</div>
</div>
);
}
/**
* 台账段 —— 左轨里唯一的**动态位**:一域一枚,随数据域发布而生长。
*
* 这是「设计产生运营的形状」最直观的一处:导航本身就是被设计出来的。
*/
function LedgerRail({
domains,
active,
onPick,
}: {
domains: readonly string[];
active: string | undefined;
onPick: (domain: string) => void;
}) {
return (
<>
<div className="g-nav-brand" style={{ marginTop: 8, marginBottom: 4 }}>
台账
</div>
{domains.length === 0 ? (
<div className="g-nav-brand" style={{ fontWeight: 600, letterSpacing: 0, lineHeight: 1.5 }}>
随数据域
<br />
发布生长
</div>
) : (
domains.map((d) => (
<button
key={d}
className={'g-nav-b' + (active === d ? ' on' : '')}
onClick={() => onPick(d)}
title={`台账 · ${d}`}
>
<span className="g-nav-ico">
<Ico name="database" s={18} />
</span>
{/* 域名是 `活动_环节` 这种复合键,60px 的轨放不下一行 —— 按下划线断行,窄字号铺开 */}
<span style={{ fontSize: 10, lineHeight: 1.25, wordBreak: 'break-word' }}>
{d.split('_').map((seg, i) => (
<span key={seg} style={{ display: 'block' }}>
{i === 0 && d.includes('_') ? `${seg}_` : seg}
</span>
))}
</span>
</button>
))
)}
</>
);
}
/** 台账 —— Task 10 接真;导航项已随数据域长出来了,内容随后 */
function LedgersPlaceholder() {
return <Note>这里将按域列出全部台账对象、行数据与版本。域的结构在设计中心 · 数据域维护。</Note>;
}
function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) {
const P = {
// 设计中心
blueprint: BlueprintHome,
workflows: WorkflowsHome,
library: LibraryHome,
tools: ToolsHome,
data: DataHome,
// 运营中心
cockpit: CockpitHome,
tasks: TasksHome,
people: PeopleHome,
ledgers: LedgersPlaceholder,
knowledge: KnowledgeHome,
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 'edges':
return <EdgesPage id={route.id} />;
case 'trigger':
return <TriggerPage id={route.id} />;
case 'act':
return <ActPage id={route.id} nav={nav} />;
case 'tool':
return <ToolPage id={route.id} />;
case 'obj':
return <ObjPage id={route.id} />;
case 'mem':
return <MemPage id={route.id} />;
case 'llm':
return <LlmPage id={route.id} />;
case 'chan':
return <ChanPage id={route.id} />;
}
}