基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -389,7 +389,7 @@ export type ActSpec = {
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
ask?: AskSpec;
|
||||
ask?: AskSpec | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user