Files
JX-BPOS/apps/web/src/shared/gov/primitives.tsx
T
ChaiHaoandClaude Opus 5 a9cd6d10c7 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
2026-07-27 03:28:40 +08:00

615 lines
16 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.
/**
* 治理台小件 —— 自 `~/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}</>;
}