- ConditionNode 增 judgeMode/conditions(SERVICE 缺省,旧定义天然兼容);DefEdgeJson/DefNodeJson 透传 condition/judgeMode - convert.ts 双向透传:CONDITION 分支首边带 condition,序列化顺序=分支顺序(求值优先级保序) - ConditionDrawer 判断方式 Radio(服务/表达式);ConditionBox 表达式模式分支头部条件输入(序号即优先级) - validate.ts 镜像校验:表达式模式每出口条件必填(语法由后端发布期拦) - Routers 页加「表达式调试」Modal(/admin/router/eval,与线上同一求值核心) - addBranch/removeBranch 同步维护 conditions 占位
497 lines
21 KiB
TypeScript
497 lines
21 KiB
TypeScript
/**
|
||
* 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。
|
||
*
|
||
* toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL),
|
||
* 条件分支(2026-09-24)展开为 CONDITION 节点 + 命名出边(无 JOIN,分支尾隐式汇合
|
||
* 到容器后继),edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
|
||
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN)与 CONDITION
|
||
* 汇合点(同法不限节点类型),还原为编辑树。
|
||
* 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。
|
||
*
|
||
* 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。
|
||
*/
|
||
import {
|
||
ApprovalNode,
|
||
Assignee,
|
||
ConditionNode,
|
||
Countersign,
|
||
DEFAULT_URGE,
|
||
DefEdgeJson,
|
||
DefJson,
|
||
DefNodeJson,
|
||
EditorItem,
|
||
EditorModel,
|
||
EditorSettings,
|
||
NotifyNode,
|
||
ParallelNode,
|
||
createApproval,
|
||
newUid,
|
||
resetUidSeq,
|
||
} from "./model";
|
||
|
||
/** 编辑树 → 后端 contentJson(code/name 恒取表单值,规避 contentJson 覆盖行字段的既有坑) */
|
||
export function toContentJson(model: EditorModel): DefJson {
|
||
const nodes: DefNodeJson[] = [];
|
||
const edges: DefEdgeJson[] = [];
|
||
|
||
// 端点 name 仅在既有定义带值时回写(新建流程不生成,保持最小 JSON)
|
||
const startNode: DefNodeJson = { id: "start", type: "START" };
|
||
if (model.startName) startNode.name = model.startName;
|
||
nodes.push(startNode);
|
||
|
||
/**
|
||
* 条件分支(2026-09-24)无 JOIN 语义:分支尾不互连,隐式汇合到容器后继——cursor 置 null 后
|
||
* 下一个节点(或 END)只接收各分支尾的连线;firstEdgeName 只作用于分支首边(出口名)。
|
||
* walk 返回"尾集合"(普通序列=末节点,末尾含条件分支=各分支尾)。
|
||
* firstEdgeCondition(2026-10-10):EXPRESSION 模式分支首边的条件表达式,与出口名同位写入。
|
||
*/
|
||
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string,
|
||
firstEdgeCondition?: string): string[] => {
|
||
let cursor: string | null = prev;
|
||
let pending: string[] = [];
|
||
let first = true;
|
||
const linkTo = (toId: string): void => {
|
||
if (cursor !== null) {
|
||
if (first && firstEdgeName) {
|
||
const e: DefEdgeJson = { from: cursor, to: toId, name: firstEdgeName };
|
||
if (firstEdgeCondition !== undefined) e.condition = firstEdgeCondition;
|
||
edges.push(e);
|
||
} else {
|
||
edges.push({ from: cursor, to: toId });
|
||
}
|
||
}
|
||
pending.forEach((t) => edges.push({ from: t, to: toId }));
|
||
pending = [];
|
||
first = false;
|
||
};
|
||
items.forEach((item) => {
|
||
if (item.kind === "approval") {
|
||
const id = item.uid;
|
||
const node: DefNodeJson = {
|
||
id,
|
||
type: "APPROVAL",
|
||
name: item.name || "审批",
|
||
assignee: cloneAssignee(item.assignee),
|
||
};
|
||
if (item.countersign === "ANY") node.countersign = "ANY";
|
||
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
||
nodes.push(node);
|
||
linkTo(id);
|
||
if (item.outlets && item.outlets.length >= 2 && item.branches) {
|
||
// 多出口(2026-10-03 P2):APPROVAL 的多条命名出边即按钮选路,
|
||
// 分支尾隐式汇合到后继(与条件分支容器完全同构)
|
||
const tails: string[] = [];
|
||
item.branches.forEach((branch, bi) => {
|
||
tails.push(...walk(branch, id, item.outlets![bi]));
|
||
});
|
||
cursor = null;
|
||
pending = tails;
|
||
} else {
|
||
cursor = id;
|
||
}
|
||
return;
|
||
}
|
||
if (item.kind === "notify") {
|
||
const id = item.uid;
|
||
const node: DefNodeJson = {
|
||
id,
|
||
type: "NOTIFY",
|
||
name: item.name || "抄送",
|
||
receivers: cloneAssignee(item.receivers),
|
||
};
|
||
nodes.push(node);
|
||
linkTo(id);
|
||
cursor = id;
|
||
return;
|
||
}
|
||
if (item.kind === "condition") {
|
||
const condId = `${item.uid}c`;
|
||
const node: DefNodeJson = {
|
||
id: condId,
|
||
type: "CONDITION",
|
||
name: item.name || "条件判断",
|
||
routerKey: item.routerKey || "",
|
||
};
|
||
// SERVICE 是后端缺省,不写字段保持最小 JSON;EXPRESSION 显式携带
|
||
if (item.judgeMode === "EXPRESSION") node.judgeMode = "EXPRESSION";
|
||
nodes.push(node);
|
||
linkTo(condId);
|
||
const tails: string[] = [];
|
||
const expression = item.judgeMode === "EXPRESSION";
|
||
item.branches.forEach((branch, bi) => {
|
||
const cond = expression ? (item.conditions || [])[bi] || "" : undefined;
|
||
tails.push(...walk(branch, condId, item.outlets[bi], cond));
|
||
});
|
||
cursor = null;
|
||
pending = tails;
|
||
return;
|
||
}
|
||
const split = `${item.uid}s`;
|
||
const join = `${item.uid}j`;
|
||
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
|
||
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
||
linkTo(split);
|
||
item.branches.forEach((branch) => {
|
||
walk(branch, split).forEach((tail) => edges.push({ from: tail, to: join }));
|
||
});
|
||
cursor = join;
|
||
});
|
||
const tails: string[] = [];
|
||
if (cursor !== null) tails.push(cursor);
|
||
tails.push(...pending);
|
||
return tails;
|
||
};
|
||
|
||
walk(model.list, "start").forEach((tail) => edges.push({ from: tail, to: "end" }));
|
||
const endNode: DefNodeJson = { id: "end", type: "END" };
|
||
if (model.endName) endNode.name = model.endName;
|
||
nodes.push(endNode);
|
||
|
||
return {
|
||
code: model.code,
|
||
name: model.name,
|
||
start: "start",
|
||
nodes,
|
||
edges,
|
||
settings: {
|
||
rejectPolicy: "END_REJECTED",
|
||
urge: { ...model.settings.urge },
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* toContentJson 的节点 id 直接取编辑态 uid(u 段 / uid 后缀 +s、+j):id 稳定可回溯,
|
||
* bpmn 画布与卡片共用同一套 id——画布上选中元素的 id 即卡片 uid,配置抽屉无需二次映射;
|
||
* id 仅要求"非空且唯一"(DefValidator 口径)。
|
||
*/
|
||
function cloneAssignee(a: Assignee | null): Assignee {
|
||
if (!a) return { type: "DEPT_KEY", deptKey: "" };
|
||
return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey };
|
||
}
|
||
|
||
export interface ParsedDef {
|
||
code: string;
|
||
name: string;
|
||
list: EditorItem[];
|
||
settings: EditorSettings;
|
||
/** 端点 name 原样带出(见 EditorModel.startName 注释) */
|
||
startName?: string;
|
||
endName?: string;
|
||
}
|
||
|
||
/**
|
||
* contentJson → 编辑树。抛错即为"不可还原为卡片视图",调用方展示错误并建议源码模式。
|
||
*/
|
||
export function fromContentJson(def: any): ParsedDef {
|
||
UsedIds.set.clear();
|
||
resetUidSeq();
|
||
if (!def || !Array.isArray(def.nodes) || !Array.isArray(def.edges)) {
|
||
throw new Error("JSON 需包含 nodes 与 edges 数组");
|
||
}
|
||
const nodes: any[] = def.nodes;
|
||
const edges: any[] = def.edges;
|
||
|
||
const byId: Record<string, any> = {};
|
||
nodes.forEach((n) => {
|
||
byId[n.id] = n;
|
||
});
|
||
|
||
const out: Record<string, string[]> = {};
|
||
edges.forEach((e) => {
|
||
if (!byId[e.from] || !byId[e.to]) {
|
||
throw new Error(`边引用了不存在的节点:${e.from} → ${e.to}`);
|
||
}
|
||
(out[e.from] = out[e.from] || []).push(e.to);
|
||
});
|
||
|
||
const starts = nodes.filter((n) => n.type === "START");
|
||
if (starts.length !== 1) throw new Error("必须有且仅有 1 个 START 节点");
|
||
const first = (out[starts[0].id] || [])[0];
|
||
if (!first) throw new Error("START 节点没有后继");
|
||
|
||
const seen = new Set<string>([starts[0].id]);
|
||
|
||
/** SPLIT 配对:各出边可达集求交 → 交集内取 BFS 序最早的 PARALLEL_JOIN(嵌套并行天然区分:内层 join 非全分支公共可达) */
|
||
function findJoin(splitId: string): string {
|
||
const order = new Map<string, number>([[splitId, 0]]);
|
||
const queue: string[] = [splitId];
|
||
while (queue.length) {
|
||
const cur = queue.shift()!;
|
||
(out[cur] || []).forEach((to) => {
|
||
if (!order.has(to)) {
|
||
order.set(to, order.size);
|
||
queue.push(to);
|
||
}
|
||
});
|
||
}
|
||
const branches = out[splitId] || [];
|
||
const reachSets = branches.map((head) => {
|
||
const set = new Set<string>();
|
||
const q: string[] = [head];
|
||
while (q.length) {
|
||
const cur = q.shift()!;
|
||
if (set.has(cur)) continue;
|
||
set.add(cur);
|
||
(out[cur] || []).forEach((to) => q.push(to));
|
||
}
|
||
return set;
|
||
});
|
||
let common = reachSets[0] || new Set<string>();
|
||
reachSets.forEach((set) => {
|
||
common = new Set([...common].filter((x) => set.has(x)));
|
||
});
|
||
const candidates = nodes
|
||
.filter((n) => n.type === "PARALLEL_JOIN" && common.has(n.id))
|
||
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||
if (!candidates.length) {
|
||
throw new Error("并行分支未汇聚到同一个节点,无法还原为卡片视图");
|
||
}
|
||
return candidates[0].id;
|
||
}
|
||
|
||
/**
|
||
* CONDITION 汇合点(2026-09-24):无 JOIN 节点,分支尾隐式连到同一后继——
|
||
* 各出边可达集求交(不限节点类型)取 BFS 序最早者即汇合点(分支内部节点不可能被全分支公共可达)。
|
||
*/
|
||
function findMerge(condId: string): string {
|
||
const order = new Map<string, number>([[condId, 0]]);
|
||
const queue: string[] = [condId];
|
||
while (queue.length) {
|
||
const cur = queue.shift()!;
|
||
(out[cur] || []).forEach((to) => {
|
||
if (!order.has(to)) {
|
||
order.set(to, order.size);
|
||
queue.push(to);
|
||
}
|
||
});
|
||
}
|
||
const reachSets = (out[condId] || []).map((head) => {
|
||
const set = new Set<string>();
|
||
const q: string[] = [head];
|
||
while (q.length) {
|
||
const cur = q.shift()!;
|
||
if (set.has(cur)) continue;
|
||
set.add(cur);
|
||
(out[cur] || []).forEach((to) => q.push(to));
|
||
}
|
||
return set;
|
||
});
|
||
let common = reachSets[0] || new Set<string>();
|
||
reachSets.forEach((set) => {
|
||
common = new Set([...common].filter((x) => set.has(x)));
|
||
});
|
||
const candidates = nodes
|
||
.filter((n) => n.id !== condId && common.has(n.id))
|
||
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||
if (!candidates.length) {
|
||
throw new Error("条件分支未汇聚到同一个节点,无法还原为卡片视图");
|
||
}
|
||
return candidates[0].id;
|
||
}
|
||
|
||
/** CONDITION 反解:出口名取 cond→分支首节点的边 name(判断接口 outlets 契约) */
|
||
function makeCondition(condId: string): { item: ConditionNode; mergeId: string } {
|
||
const n = byId[condId];
|
||
const heads = out[condId] || [];
|
||
if (heads.length < 2) {
|
||
throw new Error(`条件分支节点「${n.name || condId}」至少 2 条出边`);
|
||
}
|
||
const mergeId = findMerge(condId);
|
||
// 不预占 seen:汇合点是普通节点,交回外层循环正常消费(区别于并行 join 的"跳过式"处理)
|
||
const branches = heads.map((head) => {
|
||
const r = walkList(head, mergeId);
|
||
if (r.endedAt !== mergeId) {
|
||
throw new Error("条件分支未汇聚到同一个节点,无法还原为卡片视图");
|
||
}
|
||
return r.list;
|
||
});
|
||
const outlets = heads.map((head) => {
|
||
const e = edges.find((x) => x.from === condId && x.to === head);
|
||
return e && e.name ? String(e.name) : "";
|
||
});
|
||
if (outlets.some((o) => !o || !o.trim())) {
|
||
throw new Error(`条件分支节点「${n.name || condId}」的每个出口必须命名(出口名即判断依据)`);
|
||
}
|
||
const dup = outlets.find((o, i) => outlets.indexOf(o) !== i);
|
||
if (dup) {
|
||
throw new Error(`条件分支出口名重复:「${dup}」(同一节点的出口名必须唯一)`);
|
||
}
|
||
// uid 复用 JSON id 并剥掉生成态的 "c" 尾缀:uid+"c" 是本编辑器的 JSON id 生成约定,
|
||
// 剥尾保证"图→树→图→树→图"的 id 收敛(否则每轮追加一个 c,往返不幂等)
|
||
const uidRaw = condId.endsWith("c") ? condId.slice(0, -1) : condId;
|
||
// 出口条件反解(2026-10-10):EXPRESSION 模式出边 condition 与 outlets 同位
|
||
const conditions = heads.map((head) => {
|
||
const e = edges.find((x) => x.from === condId && x.to === head);
|
||
return e && e.condition ? String(e.condition) : "";
|
||
});
|
||
return {
|
||
item: {
|
||
uid: UsedIds.take(uidRaw),
|
||
kind: "condition",
|
||
name: n.name || "条件判断",
|
||
routerKey: n.routerKey || "",
|
||
judgeMode: n.judgeMode === "EXPRESSION" ? "EXPRESSION" : "SERVICE",
|
||
conditions,
|
||
outlets,
|
||
branches: branches.map((b) => (b.length ? b : [createApproval()])),
|
||
},
|
||
mergeId,
|
||
};
|
||
}
|
||
|
||
function walkList(cur: string, stopAt?: string): { list: EditorItem[]; endedAt: string } {
|
||
const list: EditorItem[] = [];
|
||
while (true) {
|
||
if (stopAt !== undefined && cur === stopAt) return { list, endedAt: cur };
|
||
if (seen.has(cur)) throw new Error(`检测到环或重复连线:${cur}`);
|
||
seen.add(cur);
|
||
const n = byId[cur];
|
||
if (!n) throw new Error(`引用了不存在的节点:${cur}`);
|
||
|
||
if (n.type === "APPROVAL") {
|
||
const heads = out[cur] || [];
|
||
if (heads.length === 1) {
|
||
list.push(toApprovalNode(n));
|
||
cur = heads[0];
|
||
} else if (heads.length >= 2) {
|
||
// 多出口审批:出口名=出边 name(按钮文案),复用 CONDITION 的汇合点算法
|
||
const mergeId = findMerge(cur);
|
||
const branches = heads.map((head) => {
|
||
const r = walkList(head, mergeId);
|
||
if (r.endedAt !== mergeId) {
|
||
throw new Error("多出口审批未汇聚到同一个节点,无法还原为卡片视图");
|
||
}
|
||
return r.list;
|
||
});
|
||
const outlets = heads.map((head) => {
|
||
const e = edges.find((x) => x.from === cur && x.to === head);
|
||
return e && e.name ? String(e.name) : "";
|
||
});
|
||
if (outlets.some((o) => !o || !o.trim())) {
|
||
throw new Error(`多出口审批「${n.name || cur}」的每个出口必须命名(按钮文案)`);
|
||
}
|
||
const dup = outlets.find((o, i) => outlets.indexOf(o) !== i);
|
||
if (dup) {
|
||
throw new Error(`多出口审批出口名重复:「${dup}」(按钮文案必须唯一)`);
|
||
}
|
||
const node = toApprovalNode(n);
|
||
node.outlets = outlets;
|
||
node.branches = branches.map((b) => (b.length ? b : [createApproval()]));
|
||
list.push(node);
|
||
cur = mergeId;
|
||
} else {
|
||
throw new Error(`审批节点「${n.name || cur}」出边数非法`);
|
||
}
|
||
} else if (n.type === "NOTIFY") {
|
||
if ((out[cur] || []).length !== 1) {
|
||
throw new Error(`抄送节点「${n.name || cur}」的出边必须恰好 1 条`);
|
||
}
|
||
list.push(toNotifyNode(n));
|
||
cur = out[cur][0];
|
||
} else if (n.type === "PARALLEL_SPLIT") {
|
||
const heads = out[cur] || [];
|
||
if (!heads.length) throw new Error("并行分发节点没有分支");
|
||
const joinId = findJoin(cur);
|
||
seen.add(joinId);
|
||
const branches = heads.map((head) => {
|
||
const r = walkList(head, joinId);
|
||
if (r.endedAt !== joinId) {
|
||
throw new Error("并行分支未汇聚到同一个节点,无法还原为卡片视图");
|
||
}
|
||
return r.list;
|
||
});
|
||
list.push(makeParallel(branches));
|
||
if ((out[joinId] || []).length !== 1) throw new Error("汇聚节点出边必须恰好 1 条");
|
||
cur = out[joinId][0];
|
||
} else if (n.type === "CONDITION") {
|
||
// 条件分支容器:反解后 cur=汇合点(下一节点/END/外层容器的 join),循环顶部统一续走
|
||
const made = makeCondition(cur);
|
||
list.push(made.item);
|
||
cur = made.mergeId;
|
||
} else if (n.type === "END") {
|
||
return { list, endedAt: cur };
|
||
} else if (n.type === "PARALLEL_JOIN") {
|
||
throw new Error("汇聚节点出现在不受支持的位置(存在跨分支连线)");
|
||
} else {
|
||
throw new Error(`不支持的节点类型:${n.type}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const firstWalk = walkList(first);
|
||
if (byId[firstWalk.endedAt] && byId[firstWalk.endedAt].type !== "END") {
|
||
throw new Error("主流程未正确走到 END 节点");
|
||
}
|
||
const orphans = nodes.filter((n) => !seen.has(n.id));
|
||
if (orphans.length) {
|
||
throw new Error(`存在未能从开始到达的节点:${orphans.map((n) => n.name || n.id).join("、")}`);
|
||
}
|
||
|
||
const st = def.settings || {};
|
||
const urge = st.urge || {};
|
||
return {
|
||
code: def.code || "",
|
||
name: def.name || "",
|
||
list: firstWalk.list,
|
||
startName: starts[0].name || undefined,
|
||
endName: byId[firstWalk.endedAt]?.name || undefined,
|
||
settings: {
|
||
rejectPolicy: "END_REJECTED",
|
||
urge: {
|
||
enabled: urge.enabled !== false,
|
||
intervalHours: Number(urge.intervalHours) || DEFAULT_URGE.intervalHours,
|
||
maxTimes: Number(urge.maxTimes) || DEFAULT_URGE.maxTimes,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function toApprovalNode(n: any): ApprovalNode {
|
||
const countersign: Countersign = n.countersign === "ANY" ? "ANY" : "ALL";
|
||
// uid 复用 JSON 原节点 id(如 n3):载入→卡片→画布→保存全程同一套 id,卡片树与画布
|
||
// 节点一一对应(业务配置按 id 继承的通道依赖这一点);缺 id/撞 id 才退回新生成 uid。
|
||
return {
|
||
uid: UsedIds.take(n.id),
|
||
kind: "approval",
|
||
name: n.name || "审批",
|
||
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
||
countersign,
|
||
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
|
||
};
|
||
}
|
||
|
||
function toNotifyNode(n: any): NotifyNode {
|
||
return {
|
||
uid: UsedIds.take(n.id),
|
||
kind: "notify",
|
||
name: n.name || "抄送",
|
||
receivers: n.receivers ? cloneAssignee(n.receivers as Assignee) : null,
|
||
};
|
||
}
|
||
|
||
/** 载入会话内已占用的 uid 集(防重复/缺 id 撞车) */
|
||
const UsedIds = {
|
||
set: new Set<string>(),
|
||
take(raw: unknown): string {
|
||
const s = raw == null ? "" : String(raw).trim();
|
||
if (s && !this.set.has(s)) {
|
||
this.set.add(s);
|
||
return s;
|
||
}
|
||
let u = newUid();
|
||
while (this.set.has(u)) u = newUid();
|
||
this.set.add(u);
|
||
return u;
|
||
},
|
||
};
|
||
|
||
/** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */
|
||
function makeParallel(branches: EditorItem[][]): ParallelNode {
|
||
return {
|
||
uid: newUid(),
|
||
kind: "parallel",
|
||
branches: branches.length ? branches : [[createApproval()], [createApproval()]],
|
||
};
|
||
}
|