forked from ChaiHao/BPOS-MVP
治理台按 §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
615 lines
16 KiB
TypeScript
615 lines
16 KiB
TypeScript
/**
|
||
* 治理台小件 —— 自 `~/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}</>;
|
||
}
|