/** * 编辑树 ⇄ 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 = {}; nodes.forEach((n) => { byId[n.id] = n; }); const out: Record = {}; 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([starts[0].id]); /** SPLIT 配对:各出边可达集求交 → 交集内取 BFS 序最早的 PARALLEL_JOIN(嵌套并行天然区分:内层 join 非全分支公共可达) */ function findJoin(splitId: string): string { const order = new Map([[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(); 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(); 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([[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(); 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(); 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(), 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()]], }; }