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
205 lines
7.6 KiB
TypeScript
205 lines
7.6 KiB
TypeScript
import { fitLine, type FlowNode, type FlowTone, type FlowVariant } from "./flow-layout";
|
||
|
||
/**
|
||
* 流程图节点/连线的共用画笔:FlowDiagram(diagram-js 渲染)与 FlowEditor(手绘编辑)
|
||
* 都从这里取形——同一套配色/字级/几何,编辑态与展示态不许有视觉漂移。
|
||
*/
|
||
|
||
const svgNS = "http://www.w3.org/2000/svg";
|
||
|
||
export function svgEl<K extends keyof SVGElementTagNameMap>(
|
||
tag: K,
|
||
attrs: Record<string, string | number>,
|
||
text?: string,
|
||
): SVGElementTagNameMap[K] {
|
||
const el = document.createElementNS(svgNS, tag);
|
||
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value));
|
||
if (text !== undefined) el.textContent = text;
|
||
return el;
|
||
}
|
||
|
||
export const FLOW_SANS = '"PingFang SC","Noto Sans SC","Hiragino Sans GB","Microsoft YaHei",sans-serif';
|
||
export const FLOW_MONO = '"SF Mono","JetBrains Mono",Menlo,ui-monospace,monospace';
|
||
|
||
export const TONE_COLOR: Record<FlowTone, string> = { main: "#46586A", ok: "#3F7A5C", bad: "#B0413E" };
|
||
|
||
/** 节点画形(往 group 里追加图元;调用方自己定位 group)。 */
|
||
export function paintNode(group: SVGElement, node: FlowNode, w: number, h: number, variant: FlowVariant): void {
|
||
if (node.kind === "start" || node.kind === "end" || node.kind === "end-bad") {
|
||
const bad = node.kind === "end-bad";
|
||
group.append(
|
||
svgEl("circle", {
|
||
cx: w / 2,
|
||
cy: h / 2,
|
||
r: w / 2,
|
||
fill: bad ? "#F6E7E6" : "#E6F1EA",
|
||
stroke: bad ? "#B0413E" : "#3F7A5C",
|
||
"stroke-width": node.kind === "start" ? 1.8 : bad ? 2.4 : 2.6,
|
||
}),
|
||
);
|
||
} else if (node.kind === "gateway") {
|
||
const hd = w / 2;
|
||
// 2026-07 精简:网关无弹窗、无铜点——判据存于节点数据,导出派生表与 L3 upstreamGates 承接。
|
||
group.append(
|
||
svgEl("polygon", {
|
||
points: `${hd},0 ${w},${hd} ${hd},${h} 0,${hd}`,
|
||
fill: "#EEF1F5",
|
||
stroke: "#46586A",
|
||
"stroke-width": 1.3,
|
||
}),
|
||
);
|
||
group.append(
|
||
svgEl(
|
||
"text",
|
||
{
|
||
x: hd,
|
||
y: hd + 4,
|
||
"text-anchor": "middle",
|
||
"font-family": FLOW_MONO,
|
||
"font-size": variant === "business" ? 13 : 12,
|
||
"font-weight": 700,
|
||
fill: "#46586A",
|
||
},
|
||
node.mode === "and" ? "+" : "×",
|
||
),
|
||
);
|
||
} else if (node.kind === "callout") {
|
||
// 卡点统一红(绿色让位给 Case 活动节点);去向以框内 →id 表达。
|
||
group.append(
|
||
svgEl("rect", {
|
||
x: 0,
|
||
y: 0,
|
||
width: w,
|
||
height: h,
|
||
rx: variant === "business" ? 5 : 6,
|
||
fill: "#F6E7E6",
|
||
stroke: "#B0413E",
|
||
"stroke-width": variant === "business" ? 1.2 : 1.3,
|
||
}),
|
||
);
|
||
const fitted = fitLine(
|
||
node.to ? `${node.label}→${node.to}` : node.label,
|
||
w - 12,
|
||
variant === "business" ? 10 : 11.5,
|
||
9,
|
||
);
|
||
group.append(
|
||
svgEl(
|
||
"text",
|
||
{
|
||
x: w / 2,
|
||
y: h / 2 + 3.5,
|
||
"text-anchor": "middle",
|
||
"font-family": FLOW_SANS,
|
||
"font-size": fitted.size,
|
||
"font-weight": 600,
|
||
fill: "#8F3330",
|
||
},
|
||
fitted.text,
|
||
),
|
||
);
|
||
} else {
|
||
// 方框三色:标准活动 浅蓝(缺省)/ 案例活动 绿(一事一议)/ 审批点 琥珀(人裁节点)。
|
||
// 审批点非活动,只是共用方框几何——模型上它是系统内置的编排要素(见 flow-layout 的 flavor 注释)。
|
||
// 活动调用已整体退役(2026-07 终拍:所有节点都是内置节点;schema 容忍遗留 wait 但不再有专属画法)。
|
||
const ghost = node.ghost === true;
|
||
const isCase = node.flavor === "case";
|
||
const isApproval = node.flavor === "approval";
|
||
const skin = isApproval
|
||
? { fill: "#FBF0DC", stroke: "#B98432", ink: "#6E4B12" }
|
||
: isCase
|
||
? { fill: "#E6F1EA", stroke: "#3F7A5C", ink: "#2F5C45" }
|
||
: { fill: "#E8F1FB", stroke: "#4E7DAF", ink: "#161B22" };
|
||
group.append(
|
||
svgEl("rect", {
|
||
x: 0,
|
||
y: 0,
|
||
width: w,
|
||
height: h,
|
||
rx: 6,
|
||
fill: ghost ? "#F4F6F9" : skin.fill,
|
||
stroke: ghost ? "#9AA6B4" : skin.stroke,
|
||
"stroke-width": ghost ? 1.2 : 1.3,
|
||
...(ghost ? { "stroke-dasharray": "4 3" } : {}),
|
||
}),
|
||
);
|
||
const titleSize = variant === "business" ? 12 : 11.5;
|
||
const inkColor = ghost ? "#6A7686" : skin.ink;
|
||
const title = fitLine(node.label, w - 12, titleSize, 10);
|
||
// L2 主图只留 标号/活动名/责任角标(2026-07 精简)——sub 小字仅步骤图(steps)渲染。
|
||
if (node.sub && variant !== "business") {
|
||
const sub = fitLine(node.sub, w - 12, 8.6, 8);
|
||
group.append(
|
||
svgEl(
|
||
"text",
|
||
{ x: w / 2, y: 18, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor },
|
||
title.text,
|
||
),
|
||
);
|
||
group.append(
|
||
svgEl(
|
||
"text",
|
||
{ x: w / 2, y: 32, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": sub.size, fill: ghost ? "#9AA6B4" : "#6A7686" },
|
||
sub.text,
|
||
),
|
||
);
|
||
} else {
|
||
group.append(
|
||
svgEl(
|
||
"text",
|
||
{ x: w / 2, y: h / 2 + 4, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor },
|
||
title.text,
|
||
),
|
||
);
|
||
}
|
||
// 标号角标:business 一贯有;步骤图(steps)的真实节点也要 S#/B# 编号与指导书条目对齐
|
||
// (2026-07 用户纠正)——ghost 骨架节点不标。
|
||
if (variant === "business" || !ghost) {
|
||
group.append(
|
||
svgEl("text", { x: 6, y: 11, "font-family": FLOW_MONO, "font-size": 8.5, fill: "#8A93A0" }, node.code ?? node.id),
|
||
);
|
||
}
|
||
// 责任下沉:执行人角标在右上——图即责任表。
|
||
if (node.owner && !ghost) {
|
||
const ownerFit = fitLine(node.owner, w * 0.55, 8.5, 7);
|
||
group.append(
|
||
svgEl(
|
||
"text",
|
||
{
|
||
x: w - 6,
|
||
y: 11,
|
||
"text-anchor": "end",
|
||
"font-family": FLOW_SANS,
|
||
"font-size": ownerFit.size,
|
||
"font-weight": 600,
|
||
fill: skin.stroke,
|
||
},
|
||
ownerFit.text,
|
||
),
|
||
);
|
||
}
|
||
// 分工角标已随词签退役(2026-07 user「删除 AI/系统/人 选项配置」):步骤图不再画 assign 角标。
|
||
}
|
||
}
|
||
|
||
/** 箭头三角(按折线最后一段方向)。 */
|
||
export function arrowHeadPoints(waypoints: Array<{ x: number; y: number }>): string {
|
||
const tip = waypoints[waypoints.length - 1];
|
||
const prev = waypoints[waypoints.length - 2];
|
||
// 严格索引:两点以下画不出箭头,直接退空(布局引擎不会产出这种连线,此处只为类型闭合)
|
||
if (tip === undefined || prev === undefined) return "";
|
||
const dx = Math.sign(tip.x - prev.x);
|
||
const dy = Math.sign(tip.y - prev.y);
|
||
return dx !== 0
|
||
? `${tip.x},${tip.y} ${tip.x - dx * 9},${tip.y - 4.5} ${tip.x - dx * 9},${tip.y + 4.5}`
|
||
: `${tip.x},${tip.y} ${tip.x - 4.5},${tip.y - dy * 9} ${tip.x + 4.5},${tip.y - dy * 9}`;
|
||
}
|
||
|
||
export const CAPTION_STYLE: Record<string, { fill: string; size: (v: FlowVariant) => number; font: string; weight?: number }> = {
|
||
gateway: { fill: "#3A4452", size: (v) => (v === "business" ? 10 : 9), font: FLOW_SANS, weight: 600 },
|
||
start: { fill: "#3F7A5C", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||
end: { fill: "#2F5C45", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||
"end-bad": { fill: "#8F3330", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||
note: { fill: "#8A5F1C", size: (v) => (v === "business" ? 9.8 : 8.6), font: FLOW_MONO },
|
||
};
|