Files
flow-center-front/src/views/flow/admin/def-editor/convert.ts
T
chenxing acbf601e7a feat(condition): 编辑器支持表达式判断——判断方式切换 + 出口条件就地编辑 + 表达式调试
- 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 占位
2026-10-10 18:52:54 +08:00

497 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 编辑树 ⇄ 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()]],
};
}