基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
+2 -2
View File
@@ -125,9 +125,9 @@ export interface FlowDiagramProps {
/** 点击活动节点(下钻);不传则节点不可点。 */
onNodeClick?: (nodeId: string) => void;
/** 双击活动节点就地改文字(标题/小字/主办);不传则不可改。 */
onEditNode?: (nodeId: string, patch: { label: string; owner?: string }) => Promise<void>;
onEditNode?: ((nodeId: string, patch: { label: string; owner?: string }) => Promise<void>) | undefined;
/** 执行人候选(L1 业务能力)——传了则执行字段只能下拉选缩写,不开自由填。 */
ownerOptions?: SpPosition[];
ownerOptions?: SpPosition[] | undefined;
ariaLabel: string;
}
+465
View File
@@ -0,0 +1,465 @@
// lucide-react 图标换成 @ui 的内联 Ico —— 本仓不引 lucide 包(1:1 平移规则:视觉同、依赖归一)
import { Ico } from "@ui";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
import type { SpPosition } from "./support.tsx";
import { OwnerSelect } from "./support.tsx";
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
import "diagram-js/assets/diagram-js.css";
/**
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
* (bpmn.js 的绘图层机制,阉割掉 BPMN 数据层)。这里只管:
* 工具条(撤销/重做/适配视图/取消/保存)、双击属性卡(字段范围与画布一致:
* 活动 名称/SOP↔Case/执行人;网关 名称/×|+/判据/判定;卡点 名称/去向;连线 标签/颜色)、
* 删除确认(节点已有下钻内容时)、键盘(Del 删除、⌘Z 撤销、⇧⌘Z 重做)。
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Any = any;
export interface FlowEditorProps {
/** 进入编辑时的图(编译产物/手绘/空);内核转手绘工作副本。 */
initial: FlowGraph | null | undefined;
/** 已存在的下钻(operations)键:铸新号要避开;删这些节点要确认。 */
drilledIds: string[];
/** idMap = 编辑期活动改号映射(自动顺序编号),服务端据此重键 operations。 */
onSave: (graph: FlowGraph, idMap: Record<string, string>) => Promise<void>;
onCancel: () => void;
/** 执行人候选(L1 业务能力)——传了则活动属性卡的执行字段只能下拉选缩写。 */
positions?: SpPosition[] | undefined;
}
interface PropState {
element: Any;
isEdge: boolean;
left: number;
top: number;
}
export function FlowEditor({ initial, drilledIds, onSave, onCancel, positions }: FlowEditorProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<FlowEditorHandles | null>(null);
const [ready, setReady] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [propDirty, setPropDirty] = useState(false);
const [hasShapes, setHasShapes] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prop, setProp] = useState<PropState | null>(null);
const drilled = useMemo(() => new Set(drilledIds), [drilledIds]);
const drilledRef = useRef(drilled);
drilledRef.current = drilled;
// 内核装配(一次):diagram-js modeling 全栈 + 导入种子图。
useEffect(() => {
const container = canvasRef.current;
if (!container) return;
const requestRemove = (raw: Any[]) => {
// 标签映射到宿主(旁注不单独删——文字随元素生死)。
const elements = [...new Set(raw.map((el: Any) => el.labelTarget ?? el))];
const hit = elements.filter((el: Any) => !el.waypoints && drilledRef.current.has(el.businessObject?.id));
if (hit.length > 0) {
const names = hit.map((el: Any) => el.businessObject.id).join("、");
if (!window.confirm(`节点 ${names} 已有下钻内容(任务卡/指导书等),删除后将一并移除。确定删除?`)) return;
}
const modeling = editor.diagram.get("modeling");
modeling.removeElements(elements.slice());
};
// 小盘"编辑"入口 / 网关拉线后自动弹卡:按元素位置(画布坐标→容器坐标)打开属性卡。
const requestProps = (raw: Any) => {
const element = raw?.labelTarget ?? raw;
if (!element || !element.parent) return;
if (!element.businessObject) element.businessObject = {};
const rect = wrapRef.current?.getBoundingClientRect();
if (!rect) return;
const vb = editor.diagram.get("canvas").viewbox();
const px = (element.x + (element.width ?? 0) / 2 - vb.x) * vb.scale;
const py = (element.y + (element.height ?? 0) / 2 - vb.y) * vb.scale;
setProp({
element,
isEdge: Boolean(element.waypoints),
left: Math.max(8, Math.min(px - 140, rect.width - 300)),
top: Math.max(8, Math.min(py + 16, rect.height - 250)),
});
};
const editor = createFlowEditor(initial, { container, reserved: drilledRef.current, requestRemove, requestProps });
editorRef.current = editor;
const eventBus = editor.diagram.get("eventBus");
const commandStack = editor.diagram.get("commandStack");
const elementRegistry = editor.diagram.get("elementRegistry");
const canvas = editor.diagram.get("canvas");
// 从网关拉出的线是分支:连好立刻弹属性卡,当场写分支文字(通过/拒绝…)。
eventBus.on("commandStack.connection.create.postExecuted", (event: Any) => {
const connection = event.context?.connection;
if (connection?.source?.businessObject?.kind === "gateway") {
setTimeout(() => requestProps(connection), 50);
}
});
const refresh = () => {
setCanUndo(commandStack.canUndo());
setCanRedo(commandStack.canRedo());
setHasShapes(elementRegistry.getAll().some((el: Any) => el.parent && !el.waypoints));
};
refresh();
eventBus.on("commandStack.changed", refresh);
// 双击 = 属性卡(节点/连线;双击旁注/线标签落到其宿主;字段范围与展示态一致)。
eventBus.on("element.dblclick", (event: Any) => {
const element = event.element?.labelTarget ?? event.element;
if (!element || !element.parent) return;
if (!element.businessObject) element.businessObject = {};
const rect = wrapRef.current?.getBoundingClientRect();
const oe: MouseEvent | undefined = event.originalEvent;
if (!rect) return;
setProp({
element,
isEdge: Boolean(element.waypoints),
left: Math.max(8, Math.min((oe?.clientX ?? rect.left + 60) - rect.left, rect.width - 300)),
top: Math.max(8, Math.min((oe?.clientY ?? rect.top + 60) - rect.top + 10, rect.height - 250)),
});
});
canvas.zoom("fit-viewport", "auto");
setReady(true);
return () => {
editorRef.current = null;
editor.destroy();
setReady(false);
};
// initial 只作首次种子——编辑期间外部刷新不重建画布(草稿在命令栈里)。
}, []);
// 键盘:Del 删除(过输入框)、⌘/Ctrl+Z 撤销、⇧⌘Z / Ctrl+Y 重做、Esc 关属性卡。
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
const editor = editorRef.current;
if (!editor) return;
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "z") {
e.preventDefault();
const commandStack = editor.diagram.get("commandStack");
if (e.shiftKey) commandStack.redo();
else commandStack.undo();
return;
}
if (mod && e.key.toLowerCase() === "y") {
e.preventDefault();
editor.diagram.get("commandStack").redo();
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
const selection = [...new Set(editor.diagram.get("selection").get().map((el: Any) => el.labelTarget ?? el))];
if (selection.length > 0) {
e.preventDefault();
const hit = selection.filter((el: Any) => !el.waypoints && drilledRef.current.has(el.businessObject?.id));
if (hit.length > 0) {
const names = hit.map((el: Any) => el.businessObject.id).join("、");
if (!window.confirm(`节点 ${names} 已有下钻内容,删除后将一并移除。确定删除?`)) return;
}
editor.diagram.get("modeling").removeElements(selection.slice());
}
return;
}
if (e.key === "Escape") setProp(null);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const dirty = canUndo || propDirty;
const canSave = dirty && hasShapes && !busy && ready;
const run = (fn: (editor: FlowEditorHandles) => void) => {
const editor = editorRef.current;
if (editor) fn(editor);
};
return (
<div className="flow-editor">
<div className="fe-bar">
<button type="button" className="be-btn ghost" disabled={!canUndo || busy} title="撤销(⌘Z)" onClick={() => run((ed) => ed.diagram.get("commandStack").undo())}>
<Ico name="undo-2" s={12} /> 撤销
</button>
<button type="button" className="be-btn ghost" disabled={!canRedo || busy} title="重做(⇧⌘Z)" onClick={() => run((ed) => ed.diagram.get("commandStack").redo())}>
<Ico name="redo-2" s={12} /> 重做
</button>
<button type="button" className="be-btn ghost" title="适配视图(空白处拖拽平移,⌘+滚轮缩放)" onClick={() => run((ed) => ed.diagram.get("canvas").zoom("fit-viewport", "auto"))}>
<Ico name="maximize-2" s={12} /> 适配
</button>
<span className="fe-hint">
左侧元素盘拖放落子 · 点选节点用旁边小盘 追加/连线/删除 · 拖节点自动修线 · 拖线段调走线 · 双击改属性
</span>
<button
type="button"
className="be-btn ghost"
disabled={busy}
onClick={() => {
if (!dirty || window.confirm("放弃本次绘图修改?")) onCancel();
}}
>
<Ico name="x" s={12} /> 取消
</button>
<button
type="button"
className="be-btn solid"
disabled={!canSave}
onClick={() => {
const editor = editorRef.current;
if (!editor) return;
setBusy(true);
setError(null);
const { graph, idMap } = editor.serialize();
onSave(graph, idMap).catch((err) => {
setError(err instanceof Error ? err.message : "保存失败");
setBusy(false);
});
}}
>
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
</button>
</div>
{error && <p className="fe-error">{error}</p>}
<div ref={wrapRef} className="fe-canvas">
<div ref={canvasRef} className="fe-djs" />
{prop && (
<PropCard
key={`${prop.element.id}-${prop.top}`}
prop={prop}
registry={editorRef.current?.diagram.get("elementRegistry")}
positions={positions}
onClose={() => setProp(null)}
onApply={(patch) => {
const editor = editorRef.current;
if (!editor) return;
const bo = prop.element.businessObject as Record<string, unknown>;
for (const [k, v] of Object.entries(patch)) {
if (v === undefined) delete bo[k];
else bo[k] = v;
}
editor.diagram.get("eventBus").fire("elements.changed", { elements: [prop.element] });
// 文字变了 → 同步外部标签(重量尺寸;连线标签按有无文字建/撤)。
syncElementLabel(editor.diagram, prop.element);
setPropDirty(true);
setProp(null);
}}
/>
)}
</div>
<p className="fe-foot">珊格 10px · 只有直线与 90° 折线 · 连线从节点小盘拖出 · Del 删除 · ⌘Z 撤销</p>
</div>
);
}
/* ---- 属性卡(双击节点/连线):gw-pop 纸卡,字段范围与画布展示一致 ---- */
const KIND_NAME: Record<FlowNodeKind, string> = {
activity: "活动",
gateway: "网关",
start: "起始事件",
end: "结束事件",
"end-bad": "异常终止",
callout: "卡点",
};
function PropCard({
prop,
registry,
positions,
onClose,
onApply,
}: {
prop: PropState;
registry: Any;
positions?: SpPosition[] | undefined;
onClose: () => void;
onApply: (patch: Record<string, unknown>) => void;
}) {
const node = prop.isEdge ? undefined : (prop.element.businessObject as FlowNode);
const edge = prop.isEdge ? (prop.element.businessObject as FlowEdge) : undefined;
const [label, setLabel] = useState(node?.label ?? edge?.label ?? "");
const [owner, setOwner] = useState(node?.owner ?? "");
const [flavor, setFlavor] = useState<"sop" | "case" | "approval">(
node?.flavor === "case" ? "case" : node?.flavor === "approval" ? "approval" : "sop",
);
const [mode, setMode] = useState<"x" | "and">(node?.mode === "and" ? "and" : "x");
const [basis, setBasis] = useState(node?.basis ?? "");
const [to, setTo] = useState(node?.to ?? "");
const [tone, setTone] = useState<string>(edge?.tone ?? "");
// 数据线(进/出/查:台账规范名顿号分隔)——L2 只讲流转与数据对象;
// 过程细节(分工/逐条)属 L3 指导书内部,节点级 per/assign 已退役(保存即清遗留值)。
const [dataIn, setDataIn] = useState((node?.in ?? []).join("、"));
const [dataOut, setDataOut] = useState((node?.out ?? []).join("、"));
const [dataUse, setDataUse] = useState((node?.use ?? []).join("、"));
const targets: string[] = useMemo(() => {
if (!registry) return [];
const ids: string[] = [];
for (const el of registry.getAll() as Any[]) {
const bo = el.businessObject as FlowNode | undefined;
if (el.parent && !el.waypoints && bo?.id && bo.id !== node?.id) ids.push(bo.id);
}
return ids.sort();
}, [registry, node?.id]);
const save = () => {
if (node) {
const patch: Record<string, unknown> = {};
const text = label.trim();
if (text) patch.label = text.slice(0, 16);
if (node.kind === "activity") {
patch.owner = owner.trim() ? owner.trim().slice(0, 16) : undefined;
patch.flavor = flavor === "sop" ? undefined : flavor;
const lines = (raw: string) =>
raw.split(/[、,,\n]+/).map((part) => part.trim().slice(0, 20)).filter(Boolean).slice(0, 5);
const din = lines(dataIn);
const dout = lines(dataOut);
const duse = lines(dataUse);
patch.in = din.length ? din : undefined;
patch.out = dout.length ? dout : undefined;
patch.use = duse.length ? duse : undefined;
// 活动调用整体退役(2026-07 终拍,user「所有节点都是内置节点」):保存即清遗留 wait。
patch.wait = undefined;
patch.per = undefined;
patch.assign = undefined;
} else if (node.kind === "gateway") {
patch.owner = owner.trim() ? owner.trim().slice(0, 12) : undefined;
patch.mode = mode === "and" ? "and" : undefined;
patch.basis = mode === "x" && basis.trim() ? basis.trim().slice(0, 120) : undefined;
} else if (node.kind === "callout") {
patch.to = to || undefined;
}
onApply(patch);
} else if (edge) {
onApply({
label: label.trim() ? label.trim().slice(0, 10) : undefined,
tone: tone === "ok" || tone === "bad" || tone === "main" ? tone : undefined,
});
}
};
return (
<div className="gw-pop fe-prop" style={{ left: prop.left, top: prop.top }} role="dialog" aria-label="属性">
<div className="gp-hd">
<span className="gp-dia" aria-hidden>
{node ? (node.kind === "gateway" ? "◇" : node.kind === "callout" || node.kind === "activity" ? "▭" : "●") : "→"}
</span>
<span className="gp-nm">{node ? `${node.id} · ${KIND_NAME[node.kind]}` : "连线"}</span>
</div>
<div className="gp-edit-row">
<span className="lb">{node ? "名称" : "标签"}</span>
<input
value={label}
maxLength={node ? 16 : 10}
placeholder={edge ? "分支名(可空,如 通过/拒绝)" : undefined}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
{node?.kind === "activity" && (
<>
<div className="gp-edit-row">
<span className="lb">类型</span>
<select value={flavor} onChange={(e) => setFlavor(e.target.value as "sop" | "case" | "approval")}>
<option value="sop">标准活动 · flow(蓝 · 照谱执行)</option>
<option value="case">案例活动 · loop(绿 · 临场演绎)</option>
<option value="approval">审批点(琥珀 · 人裁节点)</option>
</select>
</div>
<div className="gp-edit-row">
<span className="lb">执行</span>
{positions ? (
<OwnerSelect value={owner} positions={positions} onChange={setOwner} />
) : (
<input value={owner} maxLength={16} placeholder="执行人岗位缩写(可空)" onChange={(e) => setOwner(e.target.value)} />
)}
</div>
<div className="gp-edit-row">
<span className="lb">进</span>
<input value={dataIn} placeholder="吃进的流转件,顿号分隔 ≤5(规范名)" onChange={(e) => setDataIn(e.target.value)} />
</div>
<div className="gp-edit-row">
<span className="lb">出</span>
<input value={dataOut} placeholder="产出的流转件;可带状态词:单据名(已受理)" onChange={(e) => setDataOut(e.target.value)} />
</div>
<div className="gp-edit-row">
<span className="lb">查</span>
<input value={dataUse} placeholder="照着查/判的底座与规则,顿号分隔" onChange={(e) => setDataUse(e.target.value)} />
</div>
</>
)}
{node?.kind === "gateway" && (
<>
<div className="gp-edit-row">
<span className="lb">类型</span>
<select value={mode} onChange={(e) => setMode(e.target.value as "x" | "and")}>
<option value="x">判断 ×(按判据走一条)</option>
<option value="and">并行 +(全部同时走)</option>
</select>
</div>
{mode === "x" && (
<div className="gp-edit-row">
<span className="lb">判据</span>
<textarea value={basis} maxLength={120} rows={2} placeholder="白话可判的一句;卡点写死后果" onChange={(e) => setBasis(e.target.value)} />
</div>
)}
<div className="gp-edit-row">
<span className="lb">判定</span>
<input value={owner} maxLength={12} placeholder={mode === "and" ? "(并行可空)" : "判定人缩写"} onChange={(e) => setOwner(e.target.value)} />
</div>
</>
)}
{node?.kind === "callout" && (
<div className="gp-edit-row">
<span className="lb">去向</span>
<select value={to} onChange={(e) => setTo(e.target.value)}>
<option value="">(不标去向)</option>
{targets.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</div>
)}
{edge && (
<div className="gp-edit-row">
<span className="lb">颜色</span>
<select value={tone} onChange={(e) => setTone(e.target.value)}>
<option value="">钢灰(主线)</option>
<option value="ok">绿(通过/接受)</option>
<option value="bad">红(退回/拒绝)</option>
</select>
</div>
)}
<div className="gp-btns">
<button type="button" className="be-btn ghost" onClick={onClose}>
取消
</button>
<button type="button" className="be-btn solid" disabled={node ? !label.trim() : false} onClick={save}>
保存
</button>
</div>
</div>
);
}
+112 -3
View File
@@ -1,16 +1,125 @@
/**
* 编排画布 —— diagram-js 画布件的宿主(编码规范 §6.1「PRD 画布件 1:1 平移」)。
*
* 原型的 `HostExample.FlowFigureBlock` 同时带手绘编辑器;此处先接**只读态**:
* 流程包 → `FlowGraph`(`pkg-to-graph`)→ 布局引擎算几何 → `FlowDiagram` 绘制。
* 节点实例落位以角注呈现,几何不因运行态而变——**图是定义,标注是运行**。
* `FlowFigureBlock` 自原型 `HostExample` 平移:区块小标题右侧「编辑」芯片进手绘编辑器
* (FlowEditor,画布内工具栏含 取消/保存绘图);展示态 hero/cap 框 + FlowDiagram
* (单击节点下钻/选中、双击活动就地改文字);图例贴在流程图正下方。
* 必须包在 `.process-desk > .process-doc` 纸面容器内(flow-canvas.css 规则 scoped 到它)。
*
* `FlowFigure` 保留为只读便捷壳(任务页的流程图用它)。
*/
import { useState } from 'react';
import type { ProcessPackage } from '@contracts';
import { Ico } from '@ui';
import { FlowDiagram } from './FlowDiagram.tsx';
import { FlowEditor } from './FlowEditor.tsx';
import type { FlowGraph } from './flow-layout.ts';
import type { SpPosition } from './support.tsx';
import { packageToGraph } from './pkg-to-graph.ts';
import type { InstanceMark } from './pkg-to-graph.ts';
import './flow-canvas.css';
export function FlowFigureBlock({
title,
oneLiner,
graph,
drilledIds,
positions,
canEdit,
onNodeClick,
onEditNodeText,
onSaveFlowGraph,
}: {
title: string;
oneLiner?: string | undefined;
graph: FlowGraph | null;
/** 已建立下钻内容的节点 id(删除这些节点时编辑器会二次确认) */
drilledIds: string[];
positions?: SpPosition[] | undefined;
canEdit: boolean;
onNodeClick: (nodeId: string) => void;
/** 双击流程图活动就地改文字(标题 / 主办) */
onEditNodeText?: ((nodeId: string, patch: { label: string; owner?: string }) => Promise<void>) | undefined;
/** 手工绘图:整图保存(idMap = 本次编辑会话 原id→现id,宿主用它重键下钻内容) */
onSaveFlowGraph?: ((graph: FlowGraph, idMap: Record<string, string>) => Promise<void>) | undefined;
}) {
const [editing, setEditing] = useState(false);
return (
<>
<div className="view-lbl">
流程图 · 顺序
{canEdit && onSaveFlowGraph !== undefined && !editing && (
<button
type="button"
className="dc2-addbtn"
title="手工绘图(珊格正交:活动/网关/起止/卡点,拖拽连线);画布内工具栏有 取消/保存绘图"
onClick={() => setEditing(true)}
>
<Ico name="pen-line" s={11} /> 编辑
</button>
)}
</div>
{editing && onSaveFlowGraph !== undefined ? (
<div className="hero">
<div className="cap">
<span>FIG — {title}流程 · 手工绘图</span>
<span>珊格 10px · 直线与直角折线</span>
</div>
<FlowEditor
initial={graph}
drilledIds={drilledIds}
positions={positions}
onSave={async (next, idMap) => {
await onSaveFlowGraph(next, idMap);
setEditing(false);
}}
onCancel={() => setEditing(false)}
/>
</div>
) : (
<div className="hero">
<div className="cap">
<span>FIG — {title}流程</span>
<span>{oneLiner ?? '活动 · 网关 · 顺序流'}</span>
</div>
<div className="fig-body">
<FlowDiagram
graph={graph}
variant="business"
onNodeClick={onNodeClick}
onEditNode={canEdit ? onEditNodeText : undefined}
ownerOptions={positions}
ariaLabel={`${title}业务流程图`}
/>
</div>
</div>
)}
{/* 图例贴在流程图正下方,图标与绘图工具(元素盘)同款 SVG */}
<div className="legend">
<span>
<b>图例</b>
</span>
<span><i className="lg-i fe-pi-start" aria-hidden /> 起始</span>
<span><i className="lg-i fe-pi-end" aria-hidden /> 结束</span>
<span><i className="lg-i fe-pi-sop" aria-hidden /> 标准活动</span>
<span><i className="lg-i fe-pi-case" aria-hidden /> 案例活动</span>
<span><i className="lg-i fe-pi-approval" aria-hidden /> 审批点</span>
<span><i className="lg-i fe-pi-gwx" aria-hidden /> 判断网关</span>
<span><i className="lg-i fe-pi-gwand" aria-hidden /> 并行网关</span>
<span><i className="lg-i fe-pi-callout" aria-hidden /> 卡点</span>
<span><i className="lg-i lg-flow" aria-hidden /> 顺序流</span>
<span className="sep">|</span>
<span>
<b>责任</b>
</span>
<span>右上角标 责任人</span>
</div>
</>
);
}
/** 只读壳:流程包 → 图 → 画(任务页 / 工作流实例页用) */
export function FlowFigure({
pkg,
instances,
@@ -0,0 +1,909 @@
import Diagram from "diagram-js";
import BaseRenderer from "diagram-js/lib/draw/BaseRenderer";
import BaseLayouter from "diagram-js/lib/layout/BaseLayouter";
import CroppingConnectionDocking from "diagram-js/lib/layout/CroppingConnectionDocking";
import { repairConnection, withoutRedundantPoints } from "diagram-js/lib/layout/ManhattanLayout";
import { getMid, getOrientation } from "diagram-js/lib/layout/LayoutUtil";
import CommandInterceptor from "diagram-js/lib/command/CommandInterceptor";
import RuleProvider from "diagram-js/lib/features/rules/RuleProvider";
import OrderingProvider from "diagram-js/lib/features/ordering/OrderingProvider";
import ModelingModule from "diagram-js/lib/features/modeling";
import RulesModule from "diagram-js/lib/features/rules";
import LabelSupportModule from "diagram-js/lib/features/label-support";
import MoveModule from "diagram-js/lib/features/move";
import OutlineModule from "diagram-js/lib/features/outline";
import SelectionModule from "diagram-js/lib/features/selection";
import InteractionEventsModule from "diagram-js/lib/features/interaction-events";
import HoverFixModule from "diagram-js/lib/features/hover-fix";
import ConnectModule from "diagram-js/lib/features/connect";
import ConnectionPreviewModule from "diagram-js/lib/features/connection-preview";
import BendpointsModule from "diagram-js/lib/features/bendpoints";
import CreateModule from "diagram-js/lib/features/create";
import PaletteModule from "diagram-js/lib/features/palette";
import ContextPadModule from "diagram-js/lib/features/context-pad";
import GridSnappingModule from "diagram-js/lib/features/grid-snapping";
import SnappingModule from "diagram-js/lib/features/snapping";
import AutoPlaceModule from "diagram-js/lib/features/auto-place";
import MoveCanvasModule from "diagram-js/lib/navigation/movecanvas";
import ZoomScrollModule from "diagram-js/lib/navigation/zoomscroll";
import {
estTextWidth,
MANUAL_GRID,
MANUAL_SIZES,
type FlowEdge,
type FlowGraph,
type FlowNode,
type FlowNodeKind,
type FlowTone,
} from "./flow-layout.ts";
import { cleanPoints, mintNodeId, orderActivityIds, seedManualGraph, snap, swapIdTokens, type OrderNode } from "./manual-flow.ts";
import { arrowHeadPoints, CAPTION_STYLE, FLOW_MONO, paintNode, svgEl, TONE_COLOR } from "./flow-node-paint.ts";
/**
* 手工绘图编辑器内核:照 bpmn-js 的绘图层架构组装 diagram-js——
* modeling+命令栈(撤销/重做)、move 拖拽预览、connect 拖线、bendpoints 折点/线段拖拽、
* palette 拖放落子、context-pad 元素旁快捷盘(追加/连线/删除)、grid-snapping 珊格吸附、
* 曼哈顿正交布线 + 裁剪停靠(线永远直角、端点扣框边)。
* 阉割的数据层:没有 moddle/XML——businessObject 就是我们的 FlowNode/FlowEdge,
* 保存时从 elementRegistry 序列化回 manual FlowGraph(吸附珊格),走原 flow 路由校验落库。
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Any = any;
export interface NewNodeSpec {
kind: FlowNodeKind;
flavor?: "case" | "approval";
mode?: "x" | "and";
}
const DEFAULT_LABEL: Record<FlowNodeKind, string> = {
activity: "新活动",
gateway: "判断",
start: "开始",
end: "结束",
"end-bad": "终止",
callout: "卡点",
};
function specNode(spec: NewNodeSpec): FlowNode {
return {
id: "",
kind: spec.kind,
label: spec.kind === "gateway" && spec.mode === "and" ? "并行" : DEFAULT_LABEL[spec.kind],
...(spec.flavor ? { flavor: spec.flavor } : {}),
...(spec.mode === "and" ? { mode: "and" as const } : {}),
} as FlowNode;
}
/** 注册表当前的 BO 集合(铸新号避开已用编号;调用方再叠加下钻 operations 键)。 */
function usedIds(elementRegistry: Any): FlowGraph {
const nodes: FlowNode[] = [];
for (const el of elementRegistry.getAll()) {
const bo = el.businessObject as FlowNode | undefined;
if (bo?.id) nodes.push(bo);
}
return { nodes, rows: [], anchors: [], edges: [], manual: true };
}
/* ---------- 渲染器:与展示态共用 flow-node-paint 画笔 ---------- */
class FlowEditorRenderer extends BaseRenderer {
static $inject = ["eventBus"];
constructor(eventBus: Any) {
super(eventBus, 2000);
}
override canRender(): boolean {
return true;
}
override drawShape(parent: SVGElement, element: Any): SVGElement {
// 外部 label 元素(起止/网关旁注、连线标签):可选中可拖动,文字随宿主 BO。
if (element.labelTarget) {
const group = svgEl("g", {});
const host = element.labelTarget;
const bo = (host.businessObject ?? {}) as Partial<FlowNode & FlowEdge>;
const text = bo.label ?? "";
let fill = "#3A4452";
let font = CAPTION_STYLE.gateway!.font;
let size = 10;
let weight: number | undefined = 600;
if (host.waypoints) {
fill = TONE_COLOR[(bo.tone as FlowTone) ?? "main"];
font = FLOW_MONO;
size = 9.8;
weight = undefined;
} else {
const kind = (bo as FlowNode).kind;
const role = kind === "gateway" ? "gateway" : kind;
const style = CAPTION_STYLE[role] ?? CAPTION_STYLE.gateway!;
fill = style.fill;
font = style.font;
size = style.size("business");
weight = style.weight;
}
// 透明命中垫(文字太细,给一块可点可拖的区域)。
group.append(svgEl("rect", { x: 0, y: 0, width: element.width, height: element.height, fill: "transparent" }));
group.append(
svgEl(
"text",
{
x: element.width / 2,
y: element.height / 2 + 3.5,
"text-anchor": "middle",
"font-family": font,
"font-size": size,
...(weight ? { "font-weight": weight } : {}),
fill,
"pointer-events": "none",
},
text,
),
);
parent.appendChild(group);
return group;
}
const bo = (element.businessObject ?? { id: "", kind: "activity", label: "" }) as FlowNode;
const group = svgEl("g", {});
paintNode(group, bo, element.width, element.height, "business");
parent.appendChild(group);
return group;
}
override drawConnection(parent: SVGElement, connection: Any): SVGElement {
const bo = (connection.businessObject ?? {}) as Partial<FlowEdge>;
const waypoints = (connection.waypoints ?? []) as Array<{ x: number; y: number }>;
const group = svgEl("g", {});
if (waypoints.length < 2) {
parent.appendChild(group);
return group;
}
const color = TONE_COLOR[bo.tone ?? "main"];
const d = `M${waypoints[0]!.x},${waypoints[0]!.y} ${waypoints
.slice(1)
.map((p) => `L${p.x},${p.y}`)
.join(" ")}`;
// 加宽透明命中层(线段拖拽/选中手感);标签文字是独立 label 元素,不在此画。
group.append(svgEl("path", { d, fill: "none", stroke: "transparent", "stroke-width": 12 }));
group.append(svgEl("path", { d, fill: "none", stroke: color, "stroke-width": 1.4 }));
group.append(svgEl("polygon", { points: arrowHeadPoints(waypoints), fill: color, "pointer-events": "none" }));
parent.appendChild(group);
return group;
}
/** 裁剪停靠(CroppingConnectionDocking)按此路径求交点——菱形/圆按真形状,线才扣在框边。 */
override getShapePath(shape: Any): string {
const bo = (shape.businessObject ?? {}) as Partial<FlowNode>;
const { x, y, width: w, height: h } = shape;
if (bo.kind === "gateway") {
return `M${x + w / 2},${y}L${x + w},${y + h / 2}L${x + w / 2},${y + h}L${x},${y + h / 2}Z`;
}
if (bo.kind === "start" || bo.kind === "end" || bo.kind === "end-bad") {
const r = w / 2;
const cx = x + r;
const cy = y + r;
return `M${cx},${cy}m0,${-r}a${r},${r},0,1,1,0,${2 * r}a${r},${r},0,1,1,0,${-2 * r}z`;
}
return `M${x},${y}l${w},0l0,${h}l${-w},0z`;
}
override getConnectionPath(connection: Any): string {
const waypoints = (connection.waypoints ?? []) as Array<{ x: number; y: number }>;
if (waypoints.length < 2) return "";
return `M${waypoints[0]!.x},${waypoints[0]!.y}${waypoints
.slice(1)
.map((p) => `L${p.x},${p.y}`)
.join("")}`;
}
}
const rendererModule = {
__init__: ["flowEditorRenderer"],
flowEditorRenderer: ["type", FlowEditorRenderer],
};
/* ---------- 外部 label:起止/网关旁注、连线标签(可拖动换位、随宿主移动) ---------- */
const CAPTIONED_KINDS = new Set<FlowNodeKind>(["start", "end", "end-bad", "gateway"]);
function labelDims(text: string): { width: number; height: number } {
return { width: Math.max(24, Math.ceil(estTextWidth(text || " ", 10)) + 10), height: 16 };
}
function labelOf(host: Any): Any | null {
return host.label ?? host.labels?.[0] ?? null;
}
/** 缺省标签中心:节点=正下方;连线=最长段中点上方一点。 */
function defaultLabelMid(host: Any): { x: number; y: number } {
if (host.waypoints) {
const pts = host.waypoints as Array<{ x: number; y: number }>;
let best = 1;
let bl = -1;
for (let i = 1; i < pts.length; i++) {
const L = Math.abs(pts[i]!.x - pts[i - 1]!.x) + Math.abs(pts[i]!.y - pts[i - 1]!.y);
if (L > bl) {
bl = L;
best = i;
}
}
return { x: (pts[best - 1]!.x + pts[best]!.x) / 2, y: (pts[best - 1]!.y + pts[best]!.y) / 2 - 10 };
}
return { x: host.x + host.width / 2, y: host.y + host.height + 10 };
}
/** 新建带旁注的节点时自动挂标签(palette/追加/撤销重做都经命令栈,一并生效)。 */
class FlowLabelBehavior extends CommandInterceptor {
static override $inject = ["eventBus", "modeling"];
constructor(eventBus: Any, modeling: Any) {
super(eventBus);
// 拖拽连出的线没有 businessObject——统一在创建时种一个空 BO,
// 否则属性卡打不开(分支文字/颜色没入口),序列化也要处处兜底。
this.preExecute(
"connection.create",
(context: Any) => {
const connection = context.connection;
if (connection && !connection.businessObject) connection.businessObject = {};
},
true,
);
this.postExecuted(
"shape.create",
(context: Any) => {
const shape = context.shape;
const bo = shape?.businessObject as FlowNode | undefined;
if (!bo || shape.labelTarget || !CAPTIONED_KINDS.has(bo.kind)) return;
if (labelOf(shape)) return;
modeling.createLabel(shape, defaultLabelMid(shape), {
...labelDims(bo.label ?? ""),
businessObject: bo,
});
},
true,
);
}
}
/**
* 属性卡改完文字后同步标签:重量尺寸、建/删连线标签(文字清空=撤签)。
* 节点旁注常驻(起止/网关必有名);连线标签跟随 label 字段有无。
*/
export function syncElementLabel(diagram: Any, host: Any): void {
const modeling = diagram.get("modeling") as Any;
const eventBus = diagram.get("eventBus") as Any;
const bo = host.businessObject as Partial<FlowNode & FlowEdge>;
const existing = labelOf(host);
const text = bo.label ?? "";
if (host.waypoints) {
if (text && !existing) {
modeling.createLabel(host, defaultLabelMid(host), { ...labelDims(text), businessObject: bo });
return;
}
if (!text && existing) {
modeling.removeShape(existing);
return;
}
}
if (existing) {
const dims = labelDims(text);
const mid = { x: existing.x + existing.width / 2, y: existing.y + existing.height / 2 };
existing.width = dims.width;
existing.height = dims.height;
existing.x = Math.round(mid.x - dims.width / 2);
existing.y = Math.round(mid.y - dims.height / 2);
eventBus.fire("elements.changed", { elements: [existing] });
}
}
/* ---------- 规则:能连什么、能放哪(阉割版——自由绘,只拦真错误) ---------- */
function isRoot(element: Any): boolean {
return !element || !element.parent;
}
function duplicateEdge(source: Any, target: Any, except?: Any): boolean {
return (source?.outgoing ?? []).some((c: Any) => c !== except && c.target === target);
}
class FlowRules extends RuleProvider {
static override $inject = ["eventBus"];
override init(): void {
this.addRule("shape.resize", () => false);
this.addRule(["shape.create", "elements.create"], (context: Any) => {
const target = context.target;
const elements: Any[] = context.elements ?? (context.shape ? [context.shape] : []);
if (elements.some((el) => el.waypoints)) return false;
return isRoot(target) || target === undefined ? true : false;
});
this.addRule("elements.move", (context: Any) => {
const shapes: Any[] = context.shapes ?? [];
// 标签是注记:自由摆放,允许落在任何元素上方(不算嵌套)。
if (shapes.length > 0 && shapes.every((s) => s.labelTarget)) return true;
const target = context.target;
if (target === undefined || isRoot(target)) return true;
return false;
});
this.addRule("connection.start", (context: Any) => {
const source = context.source;
return Boolean(source && !source.waypoints && !isRoot(source));
});
this.addRule("connection.create", (context: Any) => {
const { source, target } = context;
if (!source || !target || source === target) return false;
if (source.waypoints || target.waypoints) return false;
if (isRoot(source) || isRoot(target)) return false;
if (duplicateEdge(source, target)) return false;
return true;
});
this.addRule("connection.reconnect", (context: Any) => {
const { source, target, connection } = context;
if (!source || !target || source === target) return false;
if (source.waypoints || target.waypoints) return false;
if (duplicateEdge(source, target, connection)) return false;
return true;
});
this.addRule("connection.updateWaypoints", () => true);
}
}
const rulesModule = {
__depends__: [RulesModule],
__init__: ["flowRules"],
flowRules: ["type", FlowRules],
};
/* ---------- 布线:曼哈顿正交(bpmn 同款 repairConnection),移动节点自动修线 ---------- */
class FlowLayouter extends BaseLayouter {
override layoutConnection(connection: Any, hints: Any = {}): Array<{ x: number; y: number }> {
const source = hints.source ?? connection.source;
const target = hints.target ?? connection.target;
const start = hints.connectionStart ?? getMid(source);
const end = hints.connectionEnd ?? getMid(target);
const waypoints = hints.waypoints ?? connection.waypoints ?? [];
// 上下关系为主走竖向端口,否则横向(近似 bpmn 的 preferredLayouts 选择)。
const orientation = getOrientation(source, target, 0) as string;
const vertical = orientation === "top" || orientation === "bottom";
const preferred = hints.preferredLayouts ?? [vertical ? "v:v" : "h:h"];
const repaired =
repairConnection(source, target, start, end, waypoints, { ...hints, preferredLayouts: preferred }) ??
[start, end];
return withoutRedundantPoints(repaired);
}
}
/** 连线建/修后按真实形状裁剪端点(bpmn 的 crop 行为)。 */
class FlowCropper extends CommandInterceptor {
static override $inject = ["eventBus", "connectionDocking"];
constructor(eventBus: Any, connectionDocking: Any) {
super(eventBus);
// 注意:第三参 unwrap=true 时回调直接收 context(不是 event)。
const crop = (context: Any) => {
const connection = context.connection;
if (!connection?.source || !connection?.target) return;
try {
connection.waypoints = connectionDocking.getCroppedWaypoints(connection);
} catch {
// 裁剪失败(极端交点)保持原折线——渲染仍正交。
}
};
this.executed(["connection.create", "connection.layout", "connection.reconnect"], crop, true);
}
}
/** 连线垫底、节点在上(免得线盖住框)。 */
class FlowOrdering extends OrderingProvider {
static override $inject = ["eventBus"];
override getOrdering(element: Any, newParent: Any): Any {
if (element.waypoints) return { parent: newParent, index: 0 };
return null;
}
}
const modelingModule = {
__depends__: [ModelingModule, LabelSupportModule],
__init__: ["flowCropper", "flowOrdering", "flowLabelBehavior"],
layouter: ["type", FlowLayouter],
connectionDocking: ["type", CroppingConnectionDocking],
flowCropper: ["type", FlowCropper],
flowOrdering: ["type", FlowOrdering],
flowLabelBehavior: ["type", FlowLabelBehavior],
};
/* ---------- 元素盘(palette):拖放/点击落子 ---------- */
export const PALETTE_SPECS: Array<{ key: string; label: string; className: string; spec: NewNodeSpec }> = [
{ key: "create.sop", label: "标准活动 · flow(蓝 · 照谱执行)", className: "fe-pi fe-pi-sop", spec: { kind: "activity" } },
{ key: "create.case", label: "案例活动 · loop(绿 · 临场演绎)", className: "fe-pi fe-pi-case", spec: { kind: "activity", flavor: "case" } },
{ key: "create.approval", label: "审批点(琥珀 · 人裁节点)", className: "fe-pi fe-pi-approval", spec: { kind: "activity", flavor: "approval" } },
{ key: "create.gw-x", label: "判断网关 ×(按判据走一条)", className: "fe-pi fe-pi-gwx", spec: { kind: "gateway", mode: "x" } },
{ key: "create.gw-and", label: "并行网关 +(全部同时走)", className: "fe-pi fe-pi-gwand", spec: { kind: "gateway", mode: "and" } },
{ key: "create.start", label: "起始事件", className: "fe-pi fe-pi-start", spec: { kind: "start" } },
{ key: "create.end", label: "结束事件", className: "fe-pi fe-pi-end", spec: { kind: "end" } },
{ key: "create.callout", label: "卡点(红 · 标去向)", className: "fe-pi fe-pi-callout", spec: { kind: "callout" } },
];
class FlowPalette {
static $inject = ["palette", "create", "elementFactory", "elementRegistry", "flowEditorConfig"];
private _create: Any;
private _elementFactory: Any;
private _elementRegistry: Any;
private _config: Any;
constructor(palette: Any, create: Any, elementFactory: Any, elementRegistry: Any, flowEditorConfig: Any) {
this._create = create;
this._elementFactory = elementFactory;
this._elementRegistry = elementRegistry;
this._config = flowEditorConfig;
palette.registerProvider(this);
}
getPaletteEntries(): Record<string, Any> {
const entries: Record<string, Any> = {};
for (const item of PALETTE_SPECS) {
const action = (event: Any) => {
const bo = specNode(item.spec);
bo.id = mintNodeId(item.spec.kind, usedIds(this._elementRegistry), this._config.reserved);
const size = MANUAL_SIZES[item.spec.kind];
const shape = this._elementFactory.createShape({ width: size.w, height: size.h, businessObject: bo });
this._create.start(event, shape);
};
entries[item.key] = {
group: "flow",
className: item.className,
title: `${item.label} — 拖到画布或点击后点画布落子`,
action: { dragstart: action, click: action },
};
}
return entries;
}
}
const paletteModule = {
__depends__: [PaletteModule, CreateModule],
__init__: ["flowPalette"],
flowPalette: ["type", FlowPalette],
};
/* ---------- 上下文小盘(context-pad):选中元素旁的 追加/连线/删除 ---------- */
class FlowContextPad {
static $inject = ["contextPad", "connect", "elementFactory", "elementRegistry", "autoPlace", "flowEditorConfig"];
private _connect: Any;
private _elementFactory: Any;
private _elementRegistry: Any;
private _autoPlace: Any;
private _config: Any;
constructor(contextPad: Any, connect: Any, elementFactory: Any, elementRegistry: Any, autoPlace: Any, flowEditorConfig: Any) {
this._connect = connect;
this._elementFactory = elementFactory;
this._elementRegistry = elementRegistry;
this._autoPlace = autoPlace;
this._config = flowEditorConfig;
contextPad.registerProvider(this);
}
getContextPadEntries(element: Any): Record<string, Any> {
const entries: Record<string, Any> = {};
// 标签元素不出小盘(拖动换位、双击改字就够了;删除随宿主)。
if (element.labelTarget) return entries;
const removeElements = () => this._config.requestRemove([element]);
const openProps = () => this._config.requestProps(element);
if (element.waypoints) {
entries["edit"] = {
group: "edit",
className: "fe-ci fe-ci-edit",
title: "编辑分支文字/颜色(双击线也可以)",
action: { click: openProps },
};
entries["delete"] = {
group: "edit",
className: "fe-ci fe-ci-del",
title: "删除连线",
action: { click: removeElements },
};
return entries;
}
entries["edit"] = {
group: "edit",
className: "fe-ci fe-ci-edit",
title: "编辑属性(双击节点也可以)",
action: { click: openProps },
};
const appendEntry = (key: string, title: string, className: string, spec: NewNodeSpec) => {
entries[key] = {
group: "append",
className,
title,
action: {
click: () => {
const bo = specNode(spec);
bo.id = mintNodeId(spec.kind, usedIds(this._elementRegistry), this._config.reserved);
const size = MANUAL_SIZES[spec.kind];
const shape = this._elementFactory.createShape({ width: size.w, height: size.h, businessObject: bo });
this._autoPlace.append(element, shape);
},
},
};
};
appendEntry("append.sop", "后接标准活动(自动连线)", "fe-ci fe-pi-sop", { kind: "activity" });
appendEntry("append.case", "后接案例活动(自动连线)", "fe-ci fe-pi-case", { kind: "activity", flavor: "case" });
appendEntry("append.approval", "后接审批点(自动连线)", "fe-ci fe-pi-approval", { kind: "activity", flavor: "approval" });
appendEntry("append.gw", "后接判断网关 ×(自动连线)", "fe-ci fe-pi-gwx", { kind: "gateway", mode: "x" });
appendEntry("append.end", "后接结束事件(自动连线)", "fe-ci fe-pi-end", { kind: "end" });
entries["connect"] = {
group: "edit",
className: "fe-ci fe-ci-connect",
title: "连线(拖到目标节点)",
action: {
click: (event: Any) => this._connect.start(event, element),
dragstart: (event: Any) => this._connect.start(event, element),
},
};
entries["delete"] = {
group: "edit",
className: "fe-ci fe-ci-del",
title: "删除节点(连带其连线)",
action: { click: removeElements },
};
return entries;
}
}
const contextPadModule = {
__depends__: [ContextPadModule, ConnectModule, AutoPlaceModule],
__init__: ["flowContextPad"],
flowContextPad: ["type", FlowContextPad],
};
/* ---------- 珊格底纹图层 ---------- */
class FlowGridVisual {
static $inject = ["canvas"];
constructor(canvas: Any) {
const layer = canvas.getLayer("flow-grid", -3) as SVGElement;
const defs = svgEl("defs", {});
const pattern = svgEl("pattern", {
id: "fe-grid-dots",
width: MANUAL_GRID * 2,
height: MANUAL_GRID * 2,
patternUnits: "userSpaceOnUse",
});
pattern.append(svgEl("circle", { cx: 1, cy: 1, r: 1, fill: "#DCE3EC" }));
defs.append(pattern);
layer.append(defs);
layer.append(
svgEl("rect", {
x: -4000,
y: -4000,
width: 12000,
height: 12000,
fill: "url(#fe-grid-dots)",
"pointer-events": "none",
}),
);
}
}
const gridVisualModule = {
__init__: ["flowGridVisual"],
flowGridVisual: ["type", FlowGridVisual],
};
/* ---------- 组装 / 导入 / 序列化 ---------- */
export interface FlowEditorHandles {
diagram: Any;
/** 从 elementRegistry 序列化回 manual FlowGraph(坐标/折点吸附珊格,去重去悬空);
* idMap = 本次编辑期活动的 旧id→新id(自动顺序编号所致),服务端据此重键 operations。 */
serialize: () => { graph: FlowGraph; idMap: Record<string, string> };
destroy: () => void;
}
export interface FlowEditorOptions {
container: HTMLElement;
/** 已存在的下钻(operations)键:铸号避开。 */
reserved: ReadonlySet<string>;
/** 删除请求(带下钻确认)由 React 壳接管:确认后自己调 modeling.removeElements。 */
requestRemove: (elements: Any[]) => void;
/** 打开某元素的属性卡(小盘"编辑"入口 / 网关拉线后自动弹卡)。 */
requestProps: (element: Any) => void;
}
export function createFlowEditor(initial: FlowGraph | null | undefined, options: FlowEditorOptions): FlowEditorHandles {
// React 壳的回调/上下文作为 DI value 供给(palette/context-pad 注入 flowEditorConfig)。
const configModule = {
flowEditorConfig: [
"value",
{
reserved: options.reserved,
requestRemove: options.requestRemove,
requestProps: options.requestProps,
},
],
};
const diagram = new Diagram({
canvas: { container: options.container },
modules: [
configModule,
rendererModule,
rulesModule,
modelingModule,
gridVisualModule,
InteractionEventsModule,
HoverFixModule,
SelectionModule,
OutlineModule,
MoveModule,
ConnectModule,
ConnectionPreviewModule,
BendpointsModule,
CreateModule,
paletteModule,
contextPadModule,
GridSnappingModule,
SnappingModule,
AutoPlaceModule,
MoveCanvasModule,
ZoomScrollModule,
],
} as Any);
const canvas = diagram.get("canvas") as Any;
const elementFactory = diagram.get("elementFactory") as Any;
const elementRegistry = diagram.get("elementRegistry") as Any;
const eventBus = diagram.get("eventBus") as Any;
const root = elementFactory.createRoot({ id: "flow-edit-root" });
canvas.setRootElement(root);
// 追加(autoPlace)落点:源右侧 90px,撞车往下让(简版 bpmn AutoPlaceUtil)。
eventBus.on("autoPlace", (event: Any) => {
const { source, shape } = event;
const x = snap(source.x + source.width + 90 + shape.width / 2);
let y = snap(source.y + source.height / 2);
const overlaps = (px: number, py: number) =>
elementRegistry
.getAll()
.some(
(el: Any) =>
!el.waypoints &&
el.parent &&
Math.abs(el.x + el.width / 2 - px) < (el.width + shape.width) / 2 + 20 &&
Math.abs(el.y + el.height / 2 - py) < (el.height + shape.height) / 2 + 20,
);
let guard = 0;
while (overlaps(x, y) && guard++ < 20) y = snap(y + 70);
return { x, y };
});
// 导入既有图(种子):不进命令栈(不可撤销的基线)。
const seeded = seedManualGraph(initial);
const shapeById = new Map<string, Any>();
const addLabelFor = (host: Any, bo: { label?: string; lx?: number; ly?: number }) => {
const dims = labelDims(bo.label ?? "");
const mid = bo.lx !== undefined && bo.ly !== undefined ? { x: bo.lx, y: bo.ly } : defaultLabelMid(host);
const label = elementFactory.createLabel({
labelTarget: host,
businessObject: bo,
x: Math.round(mid.x - dims.width / 2),
y: Math.round(mid.y - dims.height / 2),
width: dims.width,
height: dims.height,
});
canvas.addShape(label, root);
};
for (const node of seeded.nodes) {
const size = MANUAL_SIZES[node.kind];
const shape = elementFactory.createShape({
x: node.x ?? 0,
y: node.y ?? 0,
width: size.w,
height: size.h,
businessObject: node,
});
canvas.addShape(shape, root);
shapeById.set(node.id, shape);
if (CAPTIONED_KINDS.has(node.kind)) addLabelFor(shape, node);
}
for (const edge of seeded.edges) {
const source = shapeById.get(edge.from);
const target = shapeById.get(edge.to);
if (!source || !target || !edge.points || edge.points.length < 2) continue;
const connection = elementFactory.createConnection({
source,
target,
waypoints: edge.points.map((p) => ({ x: p.x, y: p.y })),
businessObject: edge,
});
canvas.addConnection(connection, root);
if (edge.label) addLabelFor(connection, edge);
}
/* ---- 活动自动顺序编号:结构一变(命令栈)就按流向重排 A1..An ----
* 编号是真 id:卡点去向/判据文字同步换号;进场时记住每个节点的原始 id,
* 保存时产出 旧id→新id 映射,服务端据此把 operations(两件套)跟着重键。 */
const origIds = new WeakMap<Any, string>();
for (const el of elementRegistry.getAll()) {
if (el.parent && !el.waypoints && !el.labelTarget && el.businessObject?.id) {
origIds.set(el, el.businessObject.id as string);
}
}
const renumberAll = () => {
const shapes = elementRegistry
.getAll()
.filter((el: Any) => el.parent && !el.waypoints && !el.labelTarget && el.businessObject?.id);
const nodes: OrderNode[] = shapes.map((el: Any) => ({
id: el.businessObject.id,
kind: el.businessObject.kind,
x: el.x + el.width / 2,
y: el.y + el.height / 2,
}));
const edges = elementRegistry
.getAll()
.filter((el: Any) => el.waypoints && el.parent && el.source?.businessObject?.id && el.target?.businessObject?.id)
.map((el: Any) => ({ from: el.source.businessObject.id, to: el.target.businessObject.id }));
const mapping = orderActivityIds(nodes, edges);
if (mapping.size === 0) return;
const changed: Any[] = [];
for (const el of shapes) {
const bo = el.businessObject as FlowNode;
let touched = false;
if (mapping.has(bo.id)) {
bo.id = mapping.get(bo.id)!;
touched = true;
}
if (bo.to && mapping.has(bo.to)) {
bo.to = mapping.get(bo.to)!;
touched = true;
}
if (bo.basis) {
const swapped = swapIdTokens(bo.basis, mapping);
if (swapped !== bo.basis) {
bo.basis = swapped;
touched = true;
}
}
if (touched) {
changed.push(el);
const label = labelOf(el);
if (label) changed.push(label);
}
}
if (changed.length) eventBus.fire("elements.changed", { elements: changed });
};
renumberAll();
eventBus.on("commandStack.changed", renumberAll);
const serialize = (): { graph: FlowGraph; idMap: Record<string, string> } => {
// 画布是无限域(可有负坐标)——先求整体包围,统一平移到非负(保相对几何/正交),再吸附珊格。
let minX = Infinity;
let minY = Infinity;
for (const el of elementRegistry.getAll()) {
if (!el.parent) continue;
if (el.waypoints) {
for (const p of el.waypoints as Array<{ x: number; y: number }>) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
}
} else {
minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y);
}
}
const pad = MANUAL_GRID * 2;
const dx = Number.isFinite(minX) && minX < pad ? pad - minX : 0;
const dy = Number.isFinite(minY) && minY < pad ? pad - minY : 0;
// 标签位(注记中心,随整体平移;不吸附网格):从 label 元素读回宿主 BO 的 lx/ly。
const labelMidOf = (host: Any): { lx: number; ly: number } | null => {
const label = labelOf(host);
if (!label) return null;
return {
lx: Math.max(0, Math.round(label.x + label.width / 2 + dx)),
ly: Math.max(0, Math.round(label.y + label.height / 2 + dy)),
};
};
const nodes: FlowNode[] = [];
const edges: FlowEdge[] = [];
const seen = new Set<string>();
for (const el of elementRegistry.getAll()) {
if (!el.parent) continue; // root
if (el.waypoints || el.labelTarget) continue;
const bo = el.businessObject as FlowNode;
if (!bo?.id || seen.has(bo.id)) continue;
seen.add(bo.id);
const node: FlowNode = { ...bo, x: Math.max(0, snap(el.x + dx)), y: Math.max(0, snap(el.y + dy)) };
const mid = labelMidOf(el);
if (mid) {
node.lx = mid.lx;
node.ly = mid.ly;
} else {
delete node.lx;
delete node.ly;
}
nodes.push(node);
}
const ids = new Set(nodes.map((n) => n.id));
const pairs = new Set<string>();
for (const el of elementRegistry.getAll()) {
if (!el.waypoints || !el.parent) continue;
const from = el.source?.businessObject?.id as string | undefined;
const to = el.target?.businessObject?.id as string | undefined;
if (!from || !to || from === to || !ids.has(from) || !ids.has(to)) continue;
const key = `${from}→${to}`;
if (pairs.has(key)) continue;
pairs.add(key);
const bo = (el.businessObject ?? {}) as FlowEdge;
const points = cleanPoints(
(el.waypoints as Array<{ x: number; y: number }>).map((p) => ({
x: Math.max(0, snap(p.x + dx)),
y: Math.max(0, snap(p.y + dy)),
})),
);
if (points.length < 2) continue;
const edge: FlowEdge = { ...bo, from, to, points };
const mid = labelMidOf(el);
if (mid && edge.label) {
edge.lx = mid.lx;
edge.ly = mid.ly;
} else {
delete edge.lx;
delete edge.ly;
}
edges.push(edge);
}
// 卡点去向指向已删节点的,摘除。
for (const node of nodes) {
if (node.to && node.to !== "E" && !ids.has(node.to)) delete node.to;
}
// 编辑期改号映射(自动顺序编号):旧id→新id,服务端据此重键 operations。
const idMap: Record<string, string> = {};
for (const el of elementRegistry.getAll()) {
if (!el.parent || el.waypoints || el.labelTarget) continue;
const orig = origIds.get(el);
const cur = el.businessObject?.id as string | undefined;
if (orig && cur && orig !== cur) idMap[orig] = cur;
}
return { graph: { nodes, rows: [], anchors: [], edges, manual: true }, idMap };
};
// 开发/自动化调试句柄(E2E 直查 registry/commandStack;销毁时清理)。
(window as Any).__feDiagram = diagram;
return {
diagram,
serialize,
destroy: () => {
if ((window as Any).__feDiagram === diagram) delete (window as Any).__feDiagram;
diagram.destroy();
},
};
}
+9 -9
View File
@@ -25,28 +25,28 @@ export interface FlowNode {
* 编排层五要素之一,零开发、不进活动库。它挂在 kind:"activity" 下**仅因共用同一套方框几何**
* (蛇形折行与尺寸计算),不表示它在模型上属于活动。
*/
flavor?: "case" | "approval";
flavor?: "case" | "approval" | undefined;
/** Case 节点引用的库案例 id(cases[].id):L2 只是引用,下钻编辑共享库案例。 */
caseRef?: string;
label: string;
sub?: string;
sub?: string | undefined;
code?: string;
ghost?: boolean;
note?: string;
/** (遗留)仅卡点:旧编译器按方向着色的产物——卡点现统一红,渲染忽略。 */
tone?: "ok" | "bad";
/** 仅卡点:去向节点 id(回跳返工/前跳直达)——渲染时框内标 →id。 */
to?: string;
to?: string | undefined;
/** 责任下沉:活动=主办(A);网关=判定人。活动框内角标,网关随判据进浮层。 */
owner?: string;
owner?: string | undefined;
/** 判据下沉:仅网关——判定标准(节点数据;画布不再弹窗,导出派生表可见)。 */
basis?: string;
basis?: string | undefined;
/** 仅网关:x=判断(×,默认)/ and=并行(+,所有出口同时走)。 */
mode?: "x" | "and";
mode?: "x" | "and" | undefined;
/** 数据线下沉(仅活动):进/出=流转,查=底座——L2 图后「数据 · 台账与加工」段由此派生。 */
in?: string[];
out?: string[];
use?: string[];
in?: string[] | undefined;
out?: string[] | undefined;
use?: string[] | undefined;
/** 执行基数(exec-spec):item=逐条办;缺省=跟单办。 */
per?: "item";
/** 人机分工:auto 自动 / assist 帮我做 / human 我来做(缺省)。 */
@@ -0,0 +1,136 @@
/**
* 画布图 → 流程包:**合并**,不是重建。
*
* 编辑器只动「画得出来的」:节点增删改名、连线、几何、注记。
* 语义里画布装不下的部分 —— 边映射(装配)、失败走向、驳回走向、催办阶梯、
* 负责人策略、调用接口 —— 一律**从旧包按 id 保留**;id 变过号的经 idMap 跟过去。
* 所以保存永远不会把「画布上看不见的声明」抹掉。
*
* 卡点(callout)与行分组/锚定/折线点属于画布事实,语义 schema 不收:
* 整张手绘图原样存进 `pkg.diagram`(手绘几何即呈现真相),流转判断不读它。
*/
import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
import { activityIdSchema, nodeIdSchema } from '@contracts';
import type { FlowGraph } from './flow-layout.ts';
/** 新画的活动还没绑活动库条目:先以标签占位软引用,闭合校验会标「未安装」,发布被拦 */
const placeholderActivityId = (label: string) =>
activityIdSchema.parse(`未安装:${label.trim() === '' ? '未命名' : label.trim()}`);
export function mergeGraphIntoPackage(
pkg: ProcessPackage,
graph: FlowGraph,
idMap: Readonly<Record<string, string>> = {},
): ProcessPackage {
// 旧节点按「现 id」可查:编辑器改过号的,先把旧包的 id 翻译成新号
const renamed = new Map<string, ProcessNode>();
for (const n of pkg.nodes) renamed.set(idMap[n.id] ?? n.id, n);
const priorEdge = new Map<string, Edge>();
for (const e of pkg.edges) {
const from = idMap[e.from] ?? e.from;
const to = idMap[e.to] ?? e.to;
priorEdge.set(`${from}→${to}`, e);
}
// 画布节点里只有五要素入语义;卡点(callout)留在 diagram
const semantic = graph.nodes.filter((n) => n.kind !== 'callout');
const degree = (id: string, dir: 'in' | 'out') =>
graph.edges.filter((e) => (dir === 'in' ? e.to === id : e.from === id)).length;
const nodes: ProcessNode[] = semantic.map((n) => {
const prior = renamed.get(n.id);
const id = nodeIdSchema.parse(n.id);
const layout = {
row: prior?.layout?.row ?? 0,
col: prior?.layout?.col ?? 0,
...(n.x === undefined ? {} : { x: n.x }),
...(n.y === undefined ? {} : { y: n.y }),
};
const base = {
id,
label: n.label,
...(n.sub === undefined ? {} : { sub: n.sub }),
layout,
};
if (n.kind === 'start') return { ...base, kind: 'start' as const };
if (n.kind === 'end' || n.kind === 'end-bad') {
return {
...base,
kind: 'end' as const,
...(prior?.kind === 'end' && prior.output !== undefined ? { output: prior.output } : {}),
};
}
if (n.kind === 'gateway') {
const priorGw = prior?.kind === 'gateway' ? prior : undefined;
return {
...base,
kind: 'gateway' as const,
gateway: (n.mode === 'and' ? 'parallel' : 'exclusive') as 'parallel' | 'exclusive',
// 分叉/汇合看度数:出边多为叉,入边多为汇;旧声明优先(度数打平时不猜)
mode: priorGw?.mode ?? (degree(n.id, 'out') > degree(n.id, 'in') ? ('fork' as const) : ('join' as const)),
...(n.basis === undefined ? {} : { basis: n.basis }),
};
}
if (n.flavor === 'approval') {
const priorAp = prior?.kind === 'approval' ? prior : undefined;
return {
...base,
kind: 'approval' as const,
approver: {
position: n.owner ?? priorAp?.approver.position ?? '未声明',
strategy: priorAp?.approver.strategy ?? ('sole' as const),
},
// 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,发布前在属性外补
reject: priorAp?.reject ?? { kind: 'terminate' as const },
...(priorAp?.nudge === undefined ? {} : { nudge: priorAp.nudge }),
...(priorAp?.message === undefined ? {} : { message: priorAp.message }),
};
}
const priorAct = prior?.kind === 'activity' ? prior : undefined;
return {
...base,
kind: 'activity' as const,
activityId: priorAct?.activityId ?? placeholderActivityId(n.label),
owner: {
position: n.owner ?? priorAct?.owner.position ?? '未声明',
strategy: priorAct?.owner.strategy ?? ('sole' as const),
...(priorAct?.owner.staffId === undefined ? {} : { staffId: priorAct.owner.staffId }),
},
failure: priorAct?.failure ?? { kind: 'terminate' as const },
...(priorAct?.message === undefined ? {} : { message: priorAct.message }),
...(n.in === undefined || n.in.length === 0 ? {} : { in: [...n.in] }),
...(n.out === undefined || n.out.length === 0 ? {} : { out: [...n.out] }),
};
});
const semanticIds = new Set(semantic.map((n) => n.id));
const edges: Edge[] = graph.edges
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
.map((e) => {
const prior = priorEdge.get(`${e.from}→${e.to}`);
return {
id: prior?.id ?? `e-${e.from}-${e.to}`,
from: nodeIdSchema.parse(e.from),
to: nodeIdSchema.parse(e.to),
// 装配映射与条件是「画布上看不见的声明」—— 原样保留,画布删不掉它们
mapping: prior?.mapping ?? [],
...(prior?.condition === undefined ? {} : { condition: prior.condition }),
reopen: prior?.reopen ?? false,
...(e.label === undefined ? {} : { label: e.label }),
...(e.tone === 'ok' || e.tone === 'bad' ? { tone: e.tone } : {}),
};
});
return {
...pkg,
nodes,
edges,
// 手绘图原样存证:卡点/行分组/锚定/折线点都在这里,呈现以它为真相
diagram: JSON.parse(JSON.stringify(graph)) as Record<string, unknown>,
};
}
+266
View File
@@ -0,0 +1,266 @@
import {
MANUAL_GRID,
layoutFlow,
manualNodeSize,
type FlowEdge,
type FlowGraph,
type FlowNode,
type FlowNodeKind,
} from "./flow-layout.ts";
/**
* 手绘流程图的纯几何底座(零 DOM,可单测):
* 珊格吸附、端口(节点四边中点;手绘尺寸取网格友好值,端口天然落珊格)、
* 正交路由/端点重接(编译图→手绘种子用)、编号铸造(A#/G#/X#/B# 未用最小号,避开下钻键)。
* 交互(拖拽/连线/折点)不在这里——编辑器内核 flow-editor-core 用 diagram-js modeling 全栈
* (bpmn.js 的绘图层机制);保存序列化时借这里的 snap/cleanPoints 收口。
* 服务端 manual-flow.ts 的校验(网格/正交/引用)是同一契约的落库硬门。
*/
export const snap = (v: number): number => Math.round(v / MANUAL_GRID) * MANUAL_GRID;
export type PortSide = "n" | "e" | "s" | "w";
export interface Pt {
x: number;
y: number;
}
export function nodeBox(node: FlowNode): { x: number; y: number; w: number; h: number } {
const size = manualNodeSize(node);
return { x: node.x ?? 0, y: node.y ?? 0, w: size.w, h: size.h };
}
export function nodeCenter(node: FlowNode): Pt {
const b = nodeBox(node);
return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
}
export function portPoint(node: FlowNode, side: PortSide): Pt {
const b = nodeBox(node);
switch (side) {
case "n":
return { x: b.x + b.w / 2, y: b.y };
case "s":
return { x: b.x + b.w / 2, y: b.y + b.h };
case "e":
return { x: b.x + b.w, y: b.y + b.h / 2 };
case "w":
return { x: b.x, y: b.y + b.h / 2 };
}
}
/** 按目标方位取水平/垂直向端口。 */
function portToward(node: FlowNode, toward: Pt, axis: "h" | "v"): Pt {
const c = nodeCenter(node);
if (axis === "h") return portPoint(node, toward.x >= c.x ? "e" : "w");
return portPoint(node, toward.y >= c.y ? "s" : "n");
}
/** 折线清理:去零段、并共线;至少保两点。 */
export function cleanPoints(points: Pt[]): Pt[] {
const pts = points.map((p) => ({ x: p.x, y: p.y }));
let changed = true;
while (changed && pts.length > 2) {
changed = false;
for (let i = pts.length - 2; i >= 0; i--) {
if (pts[i]!.x === pts[i + 1]!.x && pts[i]!.y === pts[i + 1]!.y) {
pts.splice(i + 1, 1);
changed = true;
}
}
for (let i = pts.length - 3; i >= 0; i--) {
const [a, b, c] = [pts[i]!, pts[i + 1]!, pts[i + 2]!];
if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) {
pts.splice(i + 1, 1);
changed = true;
}
}
}
return pts;
}
/** 两节点间的默认正交路由:同轴直线,否则 Z 形(中线拐两折)。种子重接兜底用。 */
export function routeBetween(from: FlowNode, to: FlowNode): Pt[] {
const fc = nodeCenter(from);
const tc = nodeCenter(to);
const dx = tc.x - fc.x;
const dy = tc.y - fc.y;
if (Math.abs(dx) >= Math.abs(dy)) {
const exit = portPoint(from, dx >= 0 ? "e" : "w");
const enter = portPoint(to, dx >= 0 ? "w" : "e");
if (exit.y === enter.y) return [exit, enter];
const midX = snap((exit.x + enter.x) / 2);
return cleanPoints([exit, { x: midX, y: exit.y }, { x: midX, y: enter.y }, enter]);
}
const exit = portPoint(from, dy >= 0 ? "s" : "n");
const enter = portPoint(to, dy >= 0 ? "n" : "s");
if (exit.x === enter.x) return [exit, enter];
const midY = snap((exit.y + enter.y) / 2);
return cleanPoints([exit, { x: exit.x, y: midY }, { x: enter.x, y: midY }, enter]);
}
/**
* 端点重接:节点换尺寸/吸附后,保留中段走线,只把首末段重新扣回端口
* (按首末段原方向选端口,邻点顺轴平移保正交)。两点直线直接整条重路由。
*/
export function attachEndpoints(points: Pt[], from: FlowNode, to: FlowNode): Pt[] {
if (points.length <= 2) return routeBetween(from, to);
const pts = points.map((p) => ({ x: p.x, y: p.y }));
const headHorizontal = pts[0]!.y === pts[1]!.y;
const head = portToward(from, pts[1]!, headHorizontal ? "h" : "v");
pts[0] = { ...head };
if (headHorizontal) pts[1] = { x: pts[1]!.x, y: head.y };
else pts[1] = { x: head.x, y: pts[1]!.y };
const n = pts.length;
const tailHorizontal = pts[n - 1]!.y === pts[n - 2]!.y;
const tail = portToward(to, pts[n - 2]!, tailHorizontal ? "h" : "v");
pts[n - 1] = { ...tail };
if (tailHorizontal) pts[n - 2] = { x: pts[n - 2]!.x, y: tail.y };
else pts[n - 2] = { x: tail.x, y: pts[n - 2]!.y };
const cleaned = cleanPoints(pts);
return cleaned.length >= 2 ? cleaned : routeBetween(from, to);
}
const ID_PREFIX: Partial<Record<FlowNodeKind, string>> = {
activity: "A",
gateway: "G",
callout: "X",
"end-bad": "B",
};
/* ---- 活动自动顺序编号:沿流向拓扑序,几何 (y,x) 决胜 ---- */
export interface OrderNode {
id: string;
kind: FlowNodeKind;
/** 节点中心(决胜用)。 */
x: number;
y: number;
}
/**
* 计算活动的规范编号 A1..An:从入度 0 的节点(起始)沿连线做拓扑遍历,
* 同时就绪者按 (y,x) 取先(上面的先、左边的先)——汇合点等所有来路编号完才轮到,
* 所以网关分支链先占号、汇后主线随后,与剧本世界的编号纪律同构;
* 几何即排序把手:想换号,挪节点。环用 (剩余入度,y,x) 最小者破;
* 不可达的活动排在最后。只返回**有变化**的 旧id→新id 映射。
*/
export function orderActivityIds(nodes: OrderNode[], edges: Array<{ from: string; to: string }>): Map<string, string> {
const byId = new Map(nodes.map((n) => [n.id, n]));
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0]));
const out = new Map<string, string[]>();
for (const e of edges) {
if (!byId.has(e.from) || !byId.has(e.to) || e.from === e.to) continue;
out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
}
const geo = (id: string): [number, number] => {
const n = byId.get(id)!;
return [n.y, n.x];
};
const byGeo = (a: string, b: string) => geo(a)[0] - geo(b)[0] || geo(a)[1] - geo(b)[1];
const visited = new Set<string>();
const ready = new Set<string>(nodes.filter((n) => (indeg.get(n.id) ?? 0) === 0).map((n) => n.id));
const mapping = new Map<string, string>();
let seq = 0;
const visit = (id: string) => {
visited.add(id);
ready.delete(id);
const node = byId.get(id)!;
if (node.kind === "activity") mapping.set(id, `A${++seq}`);
for (const t of out.get(id) ?? []) {
indeg.set(t, (indeg.get(t) ?? 1) - 1);
if ((indeg.get(t) ?? 0) <= 0 && !visited.has(t)) ready.add(t);
}
};
while (visited.size < nodes.length) {
const candidates = [...ready].filter((id) => !visited.has(id));
if (candidates.length > 0) {
visit(candidates.sort(byGeo)[0]!);
} else {
// 环/汇合死锁:挑未访问中 (剩余入度, y, x) 最小者破环。
const rest = nodes.filter((n) => !visited.has(n.id)).map((n) => n.id);
rest.sort((a, b) => (indeg.get(a) ?? 0) - (indeg.get(b) ?? 0) || byGeo(a, b));
visit(rest[0]!);
}
}
for (const [oldId, newId] of [...mapping]) {
if (oldId === newId) mapping.delete(oldId);
}
return mapping;
}
/** 文本里的 id 引用按映射换号:占位符两段式(防级联),长 id 先换(防 A10 被 A1 误伤)。 */
export function swapIdTokens(text: string, mapping: ReadonlyMap<string, string>): string {
const pairs = [...mapping.entries()].filter(([from, to]) => from !== to).sort((a, b) => b[0].length - a[0].length);
let outText = text;
pairs.forEach(([from], i) => {
outText = outText.split(from).join(`⟦${i}⟧`);
});
pairs.forEach(([, to], i) => {
outText = outText.split(`⟦${i}⟧`).join(to);
});
return outText;
}
/** 铸新编号:前缀取未用最小号;起/止先用 S/E,再 S2/E2…;避开 reserved(下钻 operations 键)。 */
export function mintNodeId(kind: FlowNodeKind, graph: FlowGraph, reserved: ReadonlySet<string>): string {
const used = new Set([...graph.nodes.map((n) => n.id), ...reserved]);
if (kind === "start" || kind === "end") {
const base = kind === "start" ? "S" : "E";
if (!used.has(base)) return base;
for (let n = 2; ; n++) if (!used.has(`${base}${n}`)) return `${base}${n}`;
}
const prefix = ID_PREFIX[kind] ?? "N";
for (let n = 1; ; n++) if (!used.has(`${prefix}${n}`)) return `${prefix}${n}`;
}
/**
* 进入绘图模式的种子:
* - 已是手绘图 → 深拷贝;
* - 自动布局图(编译产物/旧数据)→ 按当前布局几何转手绘:节点取布局位置换手绘尺寸(保中心),
* 连线沿布局折线吸附珊格再把端点扣回新端口——用户从"看到的样子"起步,而不是被重排;
* - 空 → 空白画布。
*/
export function seedManualGraph(graph: FlowGraph | null | undefined): FlowGraph {
if (!graph || graph.nodes.length === 0) {
return { nodes: [], rows: [], anchors: [], edges: [], manual: true };
}
if (graph.manual) return JSON.parse(JSON.stringify(graph)) as FlowGraph;
const layout = layoutFlow(graph, "business");
let minY = Infinity;
for (const shape of layout.shapes) minY = Math.min(minY, shape.y);
for (const c of layout.connections) for (const p of c.waypoints) minY = Math.min(minY, p.y);
const off = minY < MANUAL_GRID * 2 ? Math.ceil((MANUAL_GRID * 2 - minY) / MANUAL_GRID) * MANUAL_GRID : 0;
const nodes: FlowNode[] = layout.shapes.map((shape) => {
const size = manualNodeSize(shape.node);
const cx = shape.x + shape.w / 2;
const cy = shape.y + shape.h / 2 + off;
return {
...shape.node,
x: Math.max(0, snap(cx - size.w / 2)),
y: Math.max(0, snap(cy - size.h / 2)),
};
});
const byId = (id: string) => nodes.find((n) => n.id === id);
const edges: FlowEdge[] = [];
for (const connection of layout.connections) {
const from = byId(connection.edge.from);
const to = byId(connection.edge.to);
if (!from || !to) continue;
const pts = connection.waypoints.map((p) => ({ x: Math.max(0, snap(p.x)), y: Math.max(0, snap(p.y + off)) }));
edges.push({ ...connection.edge, points: attachEndpoints(pts, from, to) });
}
return { nodes, rows: [], anchors: [], edges, manual: true };
}
+58 -17
View File
@@ -20,23 +20,31 @@ export type InstanceMark = {
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
const ROW_WIDTH = 4;
export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
export function packageToGraph(
pkg: ProcessPackage,
marks: readonly InstanceMark[] = [],
/** 活动范式(activityId → loop|flow):案例活动画绿 —— 范式住活动库,包里只有引用 */
paradigms?: ReadonlyMap<string, string>,
): FlowGraph {
const markOf = new Map(marks.map((m) => [m.nodeId, m]));
/*
* 手绘图存证优先:编辑器保存过,**手绘几何即呈现真相**。
* 运行标注仍要每次现算(它是运行态,不入存证),合到存证节点的 note 上。
*/
const stored = readStoredDiagram(pkg.diagram);
if (stored !== undefined) {
return {
...stored,
nodes: stored.nodes.map((n) => {
const state = stateOf(markOf.get(n.id));
return state === undefined ? n : { ...n, note: state };
}),
};
}
const nodes: FlowNode[] = pkg.nodes.map((n) => {
const mark = markOf.get(n.id);
const state =
mark === undefined || !mark.activated
? undefined
: mark.terminal !== undefined
? mark.terminal === 'released'
? '已放行'
: '已终止'
: mark.running
? '运行中'
: mark.waiting
? '等待中'
: '已激活';
const state = stateOf(markOf.get(n.id));
if (n.kind === 'gateway') {
return {
@@ -45,7 +53,8 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
label: n.label,
// 并行网关画 +,异或网关画 ×(判断)
mode: n.gateway === 'parallel' ? 'and' : 'x',
...(n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }),
...(n.sub === undefined ? (n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }) : { sub: n.sub }),
...(n.basis === undefined ? {} : { basis: n.basis }),
...(state === undefined ? {} : { note: state }),
};
}
@@ -58,13 +67,20 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
kind: 'activity',
label: n.label,
code: n.id,
...(n.sub === undefined ? {} : { sub: n.sub }),
...(state === undefined ? {} : { note: state }),
};
if (n.kind === 'approval') {
// approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何
return { ...base, flavor: 'approval', owner: n.approver.position };
}
return { ...base, owner: n.owner.position };
return {
...base,
owner: n.owner.position,
...(paradigms?.get(n.activityId) === 'loop' ? { flavor: 'case' as const } : {}),
...(n.in === undefined ? {} : { in: [...n.in] }),
...(n.out === undefined ? {} : { out: [...n.out] }),
};
});
/** 蛇形折行:偶数行 L→R、奇数行 R→L —— 行的划分交给拓扑序 */
@@ -76,13 +92,38 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
from: e.from,
to: e.to,
// 回边按声明走「回 A4 重开新实例」,视觉上标红以区分正向流转
...(e.reopen ? { tone: 'bad' as const } : {}),
...(e.tone !== undefined ? { tone: e.tone } : e.reopen ? { tone: 'bad' as const } : {}),
...(e.label === undefined ? (e.condition === undefined ? {} : { label: e.condition }) : { label: e.label }),
}));
return { nodes, rows, anchors: [], edges };
}
/**
* 手绘图存证的在场校验:`diagram` 由本仓 `graph-to-pkg` 序列化而来,结构即 FlowGraph;
* 这里只验骨架在不在(nodes/edges 数组、非空),不逐字段复检 —— 写入侧是唯一生产者。
*/
function readStoredDiagram(d: Record<string, unknown> | undefined): FlowGraph | undefined {
if (d === undefined) return undefined;
const nodes = d['nodes'];
const edges = d['edges'];
if (!Array.isArray(nodes) || nodes.length === 0 || !Array.isArray(edges)) return undefined;
return {
nodes,
edges,
rows: Array.isArray(d['rows']) ? d['rows'] : [],
anchors: Array.isArray(d['anchors']) ? d['anchors'] : [],
manual: d['manual'] === true,
} as FlowGraph;
}
/** 运行标注:节点实例态 → 画布角注文字;未激活不标 */
function stateOf(mark: InstanceMark | undefined): string | undefined {
if (mark === undefined || !mark.activated) return undefined;
if (mark.terminal !== undefined) return mark.terminal === 'released' ? '已放行' : '已终止';
return mark.running ? '运行中' : mark.waiting ? '等待中' : '已激活';
}
/** 拓扑序:回边不参与定序(否则成环),同层按声明顺序稳定排列 */
function topologicalOrder(pkg: ProcessPackage): string[] {
const ids = pkg.nodes.map((n) => n.id as string);
+5 -1
View File
@@ -108,7 +108,11 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
{ key: 'emp', label: '员工', ico: 'users' },
{ key: 'roles', label: '岗位授权', ico: 'key' },
],
workflows: [{ key: 'pkgs', label: '流程包', ico: 'boxes' }],
workflows: [
{ key: 'overview', label: '总览', ico: 'gauge' },
{ key: 'pkgs', label: '流程包', ico: 'boxes' },
{ key: 'todo', label: '需处置', ico: 'flag' },
],
triggers: [{ key: 'rules', label: '起单规则', ico: 'zap' }],
library: [{ key: 'shelf', label: '活动货架', ico: 'boxes' }],
tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }],
+1 -1
View File
@@ -389,7 +389,7 @@ export type ActSpec = {
primary?: boolean;
danger?: boolean;
disabled?: boolean;
ask?: AskSpec;
ask?: AskSpec | undefined;
onClick?: () => void;
};