This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/web/src/shared/gov/flow/flow-node-paint.ts
T
ChaiHaoandClaude Opus 5 5414e99778 清掉「节点决定数据契约」的三处遗留 —— 编排面从此一个字段都不碰
讨论定的界线:节点只决定四件(身份 · 类型 · 链接哪个活动 · 出边条件),
数据契约全归活动自己(它的 manifest,随活动升版),流程包的 callInterface 是
「这条流被调用时的签名」另算。照此清三处化石:

  ① `ProcessNode.in/out` —— schema 上还留着「这个节点**声明**吃什么、吐什么」,
     注释还写着「闭合校验以**边映射**为准」;边映射上一轮已退役,这两行是上个模型的化石。
     连同图↔包的两处映射一并撤。
  ② 手绘编辑器属性卡的 **进 / 出 / 查 / 执行** 四行 —— 其中「查」(use)与「执行」(owner)
     连 ProcessNode 都没有,填了保存就丢:写了不生效的输入框。
     同卡里**网关**与**卡点**两支也一并删(两种要素早已从模型里去掉,分支不可达,
     而且正是网关那支在写 owner)。
  ③ 只读图双击卡的 **执行** 行、`onEditNode` 的 owner 形参、画笔里的**责任人角标** ——
     编排不带岗位,图上不该画它。上一轮图例注释说「图上早已不画责任人角标」其实说错了:
     画笔一直画着,只是没人写得进 owner;这次真的撤了,注释也改对。

连带:`FlowNode` 的 owner/in/out/use 四个字段退役;岗位选择器 support.tsx 成了孤儿
(depcruise no-orphans 当场逮住)—— 编排不带岗位,它没有归宿,删。

四闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 16:44:30 +08:00

187 lines
7.2 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),
);
}
/* 责任人角标已退役(2026-07-30):编排不带岗位 —— 谁来干是运营时分配的,不画在图上。 */
// 分工角标已随词签退役(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 },
};