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:
2026-07-27 03:28:40 +08:00
co-authored by Claude Opus 5
parent 594a0a9ca0
commit a9cd6d10c7
47 changed files with 7272 additions and 11 deletions
+614
View File
@@ -0,0 +1,614 @@
/**
* 治理台小件 —— 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 第 138–387 行平移。
*
* **DOM 结构与类名原样保留**(`g-*` + 色调字典 `t-*`),只做三处必要改写(README 平移规则 1/2/4):
* · 去掉 `"use client"`(我们不是 Next);
* · 显式导出(原型是同文件私有函数);
* · 显式返回类型(编码规范 §11.3 `explicit-module-boundary-types` 不覆盖 apps/web,但同风格保留)。
*
* 这些件是**纯呈现**:不取数、不调 API,数据一律由调用方传入。
*/
import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react';
import { Ico } from '@web-shared/Ico.tsx';
export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) {
return (
<span className={`g-chip t-${tone}`}>
{ico && <Ico name={ico} s={10} />}
{children}
</span>
);
}
export function St({ tone, ico, children }: { tone: string; ico: string; children: ReactNode }) {
return (
<span className={`g-st t-${tone}`}>
<Ico name={ico} s={13} />
{children}
</span>
);
}
export function Ava({ name }: { name: string }) {
return <span className="g-avatar">{name.slice(0, 1)}</span>;
}
export function Note({ tone, children }: { tone?: 'warn' | 'ok'; children: ReactNode }) {
return (
<div className={'g-note' + (tone ? ' ' + tone : '')}>
<Ico name={tone === 'warn' ? 'alert-triangle' : tone === 'ok' ? 'circle-check' : 'info'} s={14} />
<span>{children}</span>
</div>
);
}
export function Band({ ico, children, extra }: { ico: string; children: ReactNode; extra?: string }) {
return (
<div className="g-band">
<Ico name={ico} s={13} />
{children}
{extra && <span className="x">{extra}</span>}
</div>
);
}
export function Sec({ ico, title, extra, children }: { ico: string; title: string; extra?: string; children: ReactNode }) {
return (
<div className="g-sec">
<Band ico={ico} {...(extra === undefined ? {} : { extra })}>
{title}
</Band>
{children}
</div>
);
}
export function Kpi({
tone,
ico,
label,
value,
unit,
sub,
dial,
}: {
tone?: string;
ico: string;
label: string;
value: ReactNode;
unit?: string;
sub?: string;
dial?: number;
}) {
return (
<div className={'g-kpi' + (tone ? ' t-' + tone : '')}>
<span className="g-kpi-l">
<Ico name={ico} s={12} />
{label}
</span>
{dial !== undefined ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Dial v={dial} tone={tone ?? 'brand'} />
<span className="g-kpi-v">
{value}
{unit && <small>{unit}</small>}
</span>
</div>
) : (
<span className="g-kpi-v">
{value}
{unit && <small>{unit}</small>}
</span>
)}
{sub && <span className="g-kpi-s">{sub}</span>}
</div>
);
}
export function Dial({ v, tone = 'brand', size = 42 }: { v: number; tone?: string; size?: number }) {
const r = (size - 6) / 2;
const c = 2 * Math.PI * r;
return (
<span className={`g-dial t-${tone}`}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle className="trk" cx={size / 2} cy={size / 2} r={r} strokeWidth={5} />
<circle
className="val"
cx={size / 2}
cy={size / 2}
r={r}
strokeWidth={5}
stroke="var(--tone)"
strokeDasharray={`${(c * v) / 100} ${c}`}
/>
</svg>
</span>
);
}
export function Card({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) {
return (
<div className="g-card">
<div className="g-card-h">
<Ico name={ico} s={14} />
{title}
{extra && <span className="x">{extra}</span>}
</div>
{children}
</div>
);
}
export function Rows({ items }: { items: ReadonlyArray<{ k: string; v: ReactNode; mono?: boolean }> }) {
return (
<div className="g-rows">
{items.map((r, i) => (
<div key={i}>
<em>{r.k}</em>
<b className={r.mono ? 'mono' : ''}>{r.v}</b>
</div>
))}
</div>
);
}
export function Dist({ rows }: { rows: ReadonlyArray<{ k: string; v: number; tone: string; label?: string }> }) {
return (
<div className="g-dist">
{rows.map((r) => (
<div key={r.k} className={`g-dist-row t-${r.tone}`}>
<span className="g-dist-l">{r.k}</span>
<span className="g-dist-t">
<i style={{ width: `${r.v}%` }} />
</span>
<b className="g-dist-v">{r.label ?? `${r.v}%`}</b>
</div>
))}
</div>
);
}
export function Bars({ data, tone = 'brand' }: { data: readonly number[]; tone?: string }) {
const max = Math.max(1, ...data);
return (
<span className={`g-bars t-${tone}`}>
{data.map((d, i) => (
<i key={i} className={i === data.length - 1 ? 'hot' : ''} style={{ height: `${Math.max(8, (d / max) * 100)}%` }} />
))}
</span>
);
}
export function Load({ n, max, tone = 'brand' }: { n: number; max: number; tone?: string }) {
return (
<span className={`g-load t-${tone}`}>
<span className="g-load-t">
<i style={{ width: `${Math.min(100, (n / max) * 100)}%` }} />
</span>
<b>{n}</b>
</span>
);
}
/** 小事实块(预算 / 要素统计等定量并列项) */
export function Facts({ items }: { items: ReadonlyArray<{ k: string; v: string; tone?: string; ratio?: number }> }) {
return (
<div className="g-facts">
{items.map((f, i) => (
<div key={i} className={'g-fact' + (f.tone ? ' t-' + f.tone : '')}>
<em>{f.k}</em>
<b>{f.v}</b>
{f.ratio !== undefined && (
<span className="t">
<i style={{ width: `${Math.min(100, f.ratio * 100)}%` }} />
</span>
)}
</div>
))}
</div>
);
}
/* ---- 可下钻的清单行:清单只负责导航,动作留给详情页 ---- */
export function NList({ children }: { children: ReactNode }) {
return <div className="g-nlist">{children}</div>;
}
export function NRow({
tone = 'mute',
ico,
title,
sub,
cols,
right,
onClick,
}: {
tone?: string;
ico: string;
title: ReactNode;
sub?: ReactNode;
cols?: ReadonlyArray<{ k: string; v: ReactNode }>;
right?: ReactNode;
onClick: () => void;
}) {
return (
<button className={`g-nrow t-${tone}`} onClick={onClick}>
<span className="g-nrow-i">
<Ico name={ico} s={15} />
</span>
<span className="g-nrow-t">
<b>{title}</b>
{sub && <span>{sub}</span>}
</span>
{cols && (
<span className="g-nrow-m">
{cols.map((c, i) => (
<span key={i} className="g-nrow-c">
<em>{c.k}</em>
<b>{c.v}</b>
</span>
))}
</span>
)}
{right && <span className="g-nrow-r">{right}</span>}
<span className="g-nrow-x">
<Ico name="chevron-right" s={16} />
</span>
</button>
);
}
/** 下一级入口(详情页里指向子维度页) */
export function Entry({
ico,
tone = 'brand',
title,
desc,
meta,
onClick,
}: {
ico: string;
tone?: string;
title: string;
desc: string;
meta?: ReactNode;
onClick: () => void;
}) {
return (
<button className={`g-entry t-${tone}`} onClick={onClick}>
<span className="g-entry-i">
<Ico name={ico} s={17} />
</span>
<span className="g-entry-t">
<b>{title}</b>
<em>{desc}</em>
</span>
{meta && <span className="g-entry-m">{meta}</span>}
<span className="g-entry-x">
<Ico name="chevron-right" s={16} />
</span>
</button>
);
}
/* ---- 属性卡:就地填写,不下钻 ---- */
export function Prop({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) {
return (
<div className="g-prop">
<div className="g-prop-h">
<Ico name={ico} s={13} />
{title}
{extra && <span className="x">{extra}</span>}
</div>
<div className="g-prop-b">{children}</div>
</div>
);
}
export function Field({ k, hint, children }: { k: string; hint?: string; children: ReactNode }) {
return (
<div className="g-field">
<em>
{k}
{hint && <i>{hint}</i>}
</em>
{children}
</div>
);
}
export function Seg({
value,
options,
onPick,
}: {
value: string;
options: ReadonlyArray<{ k: string; label: string }>;
onPick: (k: string) => void;
}) {
return (
<div className="g-seg">
{options.map((o) => (
<button key={o.k} className={'g-seg-b' + (value === o.k ? ' on' : '')} onClick={() => onPick(o.k)}>
{o.label}
</button>
))}
</div>
);
}
export function ListEdit({
items,
placeholder,
onChange,
}: {
items: readonly string[];
placeholder: string;
onChange: (v: string[]) => void;
}) {
return (
<div className="g-listedit">
{items.map((it, i) => (
<div key={i} className="g-le-row">
<input
className="g-input"
value={it}
placeholder={placeholder}
onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))}
/>
<button className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
<Ico name="trash-2" s={13} />
</button>
</div>
))}
{items.length === 0 && <span className="g-le-e">未声明</span>}
<button className="g-le-add" onClick={() => onChange([...items, ''])}>
<Ico name="plus" s={12} />
添加
</button>
</div>
);
}
/* ---- 就地确认带:影响预览三必答,不弹窗、不遮挡 ---- */
export type AskSpec = {
title: string;
note?: ReactNode;
/** 三必答 —— 治理动作在按下之前就把后果讲清楚 */
impact: { who: string; undo: string; when: string };
okLabel: string;
onOk: () => void;
};
export type ActSpec = {
label: string;
ico?: string;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
ask?: AskSpec;
onClick?: () => void;
};
export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: boolean; onCancel: () => void }) {
return (
<div className={'g-ask t-' + (danger ? 'danger' : 'warn')}>
<div className="g-ask-h">
<Ico name={danger ? 'alert-triangle' : 'shield-check'} s={15} />
{spec.title}
</div>
{spec.note}
<div className="g-impact">
<div>
<em>影响谁</em>
<b>{spec.impact.who}</b>
</div>
<div>
<em>可否撤销</em>
<b>{spec.impact.undo}</b>
</div>
<div>
<em>何时生效</em>
<b>{spec.impact.when}</b>
</div>
</div>
<div className="g-ask-a">
<button className="g-btn" onClick={onCancel}>
取消
</button>
<button
className={'g-btn ' + (danger ? 'danger' : 'primary')}
onClick={() => {
spec.onOk();
onCancel();
}}
>
{spec.okLabel}
</button>
</div>
</div>
);
}
export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; align?: 'start' | 'end' }) {
const [armed, setArmed] = useState<number | null>(null);
const a = armed !== null ? items[armed] : null;
return (
<div className="g-acts">
<div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}>
{items.map((it, i) => (
<button
key={i}
disabled={it.disabled === true}
className={
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
}
onClick={() => (it.ask ? setArmed(armed === i ? null : i) : it.onClick?.())}
>
{it.ico && <Ico name={it.ico} s={13} />}
{it.label}
</button>
))}
</div>
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
</div>
);
}
/* ---- 详情页头 ---- */
export function PageHead({
ico,
tone = 'brand',
kicker,
title,
chips,
desc,
acts,
}: {
ico: string;
tone?: string;
kicker: string;
title: string;
chips?: ReactNode;
desc?: ReactNode;
acts?: readonly ActSpec[];
}) {
const [armed, setArmed] = useState<number | null>(null);
const a = acts && armed !== null ? acts[armed] : null;
return (
<div className="g-dhwrap">
<div className={`g-dh t-${tone}`}>
<span className="g-dh-i">
<Ico name={ico} s={20} />
</span>
<div className="g-dh-t">
<em>{kicker}</em>
<h2>{title}</h2>
{chips && <div className="chips">{chips}</div>}
{desc && <p>{desc}</p>}
</div>
{acts && (
<div className="g-dh-a">
{acts.map((it, i) => (
<button
key={i}
className={
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
}
onClick={() => (it.ask ? setArmed(armed === i ? null : i) : it.onClick?.())}
>
{it.ico && <Ico name={it.ico} s={13} />}
{it.label}
</button>
))}
</div>
)}
</div>
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
</div>
);
}
/** 需处置卡:一条偏差 + 就地处置 */
export function Todo({
tone = 'danger',
ico,
title,
desc,
acts,
}: {
tone?: string;
ico: string;
title: string;
desc: string;
acts: readonly ActSpec[];
}) {
return (
<div className={`g-todo-card t-${tone}`}>
<div className="g-todo-row">
<span className="g-todo-i">
<Ico name={ico} s={15} />
</span>
<span className="g-todo-t">
<b>{title}</b>
<span>{desc}</span>
</span>
</div>
<Acts items={acts} align="end" />
</div>
);
}
export function Clear({ children }: { children: ReactNode }) {
return (
<div className="g-clear">
<Ico name="circle-check" s={16} />
{children}
</div>
);
}
/** 区首页/详情页共用的板栅 */
export function Board({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return (
<div className="g-board" {...(style === undefined ? {} : { style })}>
{children}
</div>
);
}
export function Kpis({ children }: { children: ReactNode }) {
return <div className="g-kpis">{children}</div>;
}
export function Todos({ children }: { children: ReactNode }) {
return <div className="g-todo">{children}</div>;
}
export type Armed = [number | null, Dispatch<SetStateAction<number | null>>];
/**
* 取数态:载入 / 无权 / 出错 —— 三种都得**说出来**。
*
* 治理台尤其不能把「无权查看」画成空清单:那会让人误以为系统里什么都没有。
* 权限不足按岗位说明缺哪一项,而不是笼统「失败」。
*/
export function QueryGate({
query,
children,
}: {
query: { isPending: boolean; isError: boolean; error: unknown };
children: ReactNode;
}) {
if (query.isPending) {
return (
<div className="g-clear">
<Ico name="clock" s={16} />
载入中…
</div>
);
}
if (query.isError) {
const error = query.error as { status?: number; message?: string } | undefined;
const forbidden = error?.status === 403;
return (
<Note tone="warn">
{forbidden ? (
<>
<b>无权查看</b> —— {error?.message ?? ''}。权限按岗位授予:客体三类 × 动作三种;
换岗即换权,需要请找有该治理功能授权的岗位开通。
</>
) : (
<>
<b>取数失败</b> —— {error?.message ?? '未知错误'}
</>
)}
</Note>
);
}
return <>{children}</>;
}