Files
JX-BPOS/apps/web/src/shared/gov/flow/flow-node-paint.ts
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

205 lines
7.6 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.
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 },
};