M6(2/n): 治理台九功能 + SSE 订阅层 + 读/治分权
治理台按 §9.1 拆成九个 gov-* feature,共用件落 shared/gov:
· 外壳(左轨/顶栏/面包屑/页签/整页下钻)自原型 Governance.tsx 平移,
DOM 与 g-* 类名原样;路由状态即应用状态,区/页签/下钻栈全编码进 hash 可深链
· 编排画布 diagram-js 画布件 1:1 平移(flow-layout/flow-node-paint/FlowDiagram),
新增 pkg-to-graph 把 ProcessPackage 转成画布图模型;统一报价 v1 全图 12 节点 15 边渲染通过
· 后端补四路由:审批单 / 工作流下钻 / 通道实例 / 度量仪表
SSE 订阅层(§9.2):app 层单一连接,按信封目标失效缓存;
组件不再自行轮询(去掉 refetchInterval)。广播只说「哪个目标动了」,不带业务载荷,
且在投递已落库已送达之后才发 —— 不是第二条投递链路。
两处实质修正:
· 治理路由一律要 govern → 读路由改要 read。客体三类 × 动作三种的分法就是为此:
BD 有 task:read 能看在途、动不了控制事件;一律 govern 等于把三种动作压成一种
· 控制事件/恢复/改派原记为 engine-transition(归机器产出)→ 新增 control 条目种类,
归人裁决侧。归侧是按动作类型的纯推导,所以「谁干的」必须体现在 kind 上;
否则人的叫停会被算进自主率的机器分子
判据 ⑩ 治理台一半跑通:在途监控 / 叫停 / 恢复,治理动作留痕可查。
无权查看不再画成空清单,按岗位说明缺哪一项。
gates: tsc / eslint / depcruise(286 模块)/ api-report / 360 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 治理台外壳 —— 自原型 `Governance.tsx` 第 389–514 行平移。
|
||||
*
|
||||
* 左轨(九功能)→ 顶栏 → 面包屑 → 区首页(页签 + 主视图)/ 整页下钻。
|
||||
* 「**不用抽屉、不用弹窗**:每一层内容都有自己的页面。」
|
||||
*
|
||||
* §9.2「路由状态即应用状态」:当前区 / 页签 / 下钻栈全部编码进 URL 的 hash,
|
||||
* 刷新与深链都能完整还原;前端零过程状态。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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 { useGovStaff, useGovTasks } from '@web-shared/gov/gov-api.ts';
|
||||
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, TriggersHome } 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 { MemPage, MemoryHome } from '@features/gov-memory';
|
||||
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
|
||||
|
||||
/* ── URL 编解码:`#/gov/<zone>/<tab>[/<k>:<id>…]` ──────────────────────── */
|
||||
|
||||
type Place = { zone: GovZone; tab: string; stack: Route[] };
|
||||
|
||||
function readPlace(): Place {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
const parts = raw.split('/').filter((s) => s !== '');
|
||||
const zone = (ZONES.find((z) => z.key === parts[1])?.key ?? 'tasks') as GovZone;
|
||||
const tabs = TABS[zone];
|
||||
const tab = tabs.find((t) => t.key === parts[2])?.key ?? tabs[0]!.key;
|
||||
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 = (p: Place): string =>
|
||||
['#', 'gov', p.zone, p.tab, ...p.stack.map((r) => `${r.k}:${encodeURIComponent(r.id)}`)].join('/');
|
||||
|
||||
export function GovShell() {
|
||||
const [place, setPlace] = useState<Place>(readPlace);
|
||||
const staff = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
|
||||
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
|
||||
useEffect(() => {
|
||||
const onHash = () => setPlace(readPlace());
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
|
||||
const go = useCallback((next: Place) => {
|
||||
window.location.hash = writePlace(next);
|
||||
setPlace(next);
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(r: Route) => go({ ...readPlace(), stack: [...readPlace().stack, r] }),
|
||||
[go],
|
||||
);
|
||||
const back = useCallback(() => {
|
||||
const p = readPlace();
|
||||
go({ ...p, stack: p.stack.slice(0, -1) });
|
||||
}, [go]);
|
||||
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 = staff.data?.staff.find((s) => s.account === 'fengbing');
|
||||
|
||||
/** 告警计数:异常工作流 + 非放行终态的任务 */
|
||||
const alerts =
|
||||
(tasks.data?.workflows ?? []).filter((w) => w.state === 'abnormal').length +
|
||||
(tasks.data?.tasks ?? []).filter((t) => t.terminal !== undefined && t.terminal !== 'released').length;
|
||||
|
||||
/* 面包屑:治理台 › 区 › 页签 › 下钻各级 */
|
||||
const crumbs: Array<{ label: string; go?: () => void }> = [{ label: '治理台', go: () => goZone(zone) }];
|
||||
crumbs.push({ label: meta.title, go: () => go({ zone, tab, stack: [] }) });
|
||||
crumbs.push({ label: TABS[zone].find((t) => t.key === tab)?.label ?? '', 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="shield-alert" s={20} />
|
||||
</div>
|
||||
<div className="g-nav-brand">治理台</div>
|
||||
{ZONES.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="跨对象检索:工作流 / 任务 / 活动 / 工具 / 触发器 / 对象…" />
|
||||
<kbd>⌘K</kbd>
|
||||
</label>
|
||||
<div className="g-top-r">
|
||||
<button className={'g-tbtn' + (alerts ? ' alert' : '')} onClick={() => jump('tasks', 'todo')}>
|
||||
<Ico name="alert-triangle" s={13} />
|
||||
告警<b>{alerts}</b>
|
||||
</button>
|
||||
<a className="g-tbtn" href="/">
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
</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 ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) {
|
||||
const P = {
|
||||
tasks: TasksHome,
|
||||
people: PeopleHome,
|
||||
workflows: WorkflowsHome,
|
||||
triggers: TriggersHome,
|
||||
library: LibraryHome,
|
||||
tools: ToolsHome,
|
||||
data: DataHome,
|
||||
memory: MemoryHome,
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user