NOTIFY(2026-09-23):编辑器抄送卡片+抽屉+画布 bpmn:ServiceTask 映射、工作台「抄送我的」页签、轨迹 kind=CC 行、示意图青框卡片 CONDITION(2026-09-24):ConditionBox 互斥分支容器(分支列出口名就地编辑、无 JOIN 语义分支尾隐式汇合)、ConditionDrawer 判断服务下拉(flw_router)、convert 双向序列化(CONDITION+命名出边、uid+c 尾缀往返收敛)、validate 镜像后端口径、画布 bpmn:ExclusiveGateway 映射、轨迹「系统判断→出口」行(失败挂起提示)、示意图紫菱形(SVG 与服务端 PNG 同配色)、管理面 flw_router 维护页(/admin/routers)与办件记录「重跑路由」入口 convert 往返 54 项全过、npm run build 通过
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
* 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。
|
||||
*
|
||||
* toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL),
|
||||
* edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
|
||||
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN),还原为编辑树。
|
||||
* 条件分支(2026-09-24)展开为 CONDITION 节点 + 命名出边(无 JOIN,分支尾隐式汇合
|
||||
* 到容器后继),edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
|
||||
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN)与 CONDITION
|
||||
* 汇合点(同法不限节点类型),还原为编辑树。
|
||||
* 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。
|
||||
*
|
||||
* 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。
|
||||
@@ -11,6 +13,7 @@
|
||||
import {
|
||||
ApprovalNode,
|
||||
Assignee,
|
||||
ConditionNode,
|
||||
Countersign,
|
||||
DEFAULT_URGE,
|
||||
DefEdgeJson,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
EditorItem,
|
||||
EditorModel,
|
||||
EditorSettings,
|
||||
NotifyNode,
|
||||
ParallelNode,
|
||||
createApproval,
|
||||
newUid,
|
||||
@@ -35,8 +39,27 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
if (model.startName) startNode.name = model.startName;
|
||||
nodes.push(startNode);
|
||||
|
||||
const walk = (items: EditorItem[], prev: string): string => {
|
||||
let cursor = prev;
|
||||
/**
|
||||
* 条件分支(2026-09-24)无 JOIN 语义:分支尾不互连,隐式汇合到容器后继——cursor 置 null 后
|
||||
* 下一个节点(或 END)只接收各分支尾的连线;firstEdgeName 只作用于分支首边(出口名)。
|
||||
* walk 返回"尾集合"(普通序列=末节点,末尾含条件分支=各分支尾)。
|
||||
*/
|
||||
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string): string[] => {
|
||||
let cursor: string | null = prev;
|
||||
let pending: string[] = [];
|
||||
let first = true;
|
||||
const linkTo = (toId: string): void => {
|
||||
if (cursor !== null) {
|
||||
if (first && firstEdgeName) {
|
||||
edges.push({ from: cursor, to: toId, name: firstEdgeName });
|
||||
} 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;
|
||||
@@ -49,26 +72,57 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
if (item.countersign === "ANY") node.countersign = "ANY";
|
||||
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
||||
nodes.push(node);
|
||||
edges.push({ from: cursor, to: id });
|
||||
linkTo(id);
|
||||
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`;
|
||||
nodes.push({
|
||||
id: condId,
|
||||
type: "CONDITION",
|
||||
name: item.name || "条件判断",
|
||||
routerKey: item.routerKey || "",
|
||||
});
|
||||
linkTo(condId);
|
||||
const tails: string[] = [];
|
||||
item.branches.forEach((branch, bi) => {
|
||||
tails.push(...walk(branch, condId, item.outlets[bi]));
|
||||
});
|
||||
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" });
|
||||
edges.push({ from: cursor, to: split });
|
||||
linkTo(split);
|
||||
item.branches.forEach((branch) => {
|
||||
const tail = walk(branch, split);
|
||||
edges.push({ from: tail, to: join });
|
||||
walk(branch, split).forEach((tail) => edges.push({ from: tail, to: join }));
|
||||
});
|
||||
cursor = join;
|
||||
});
|
||||
return cursor;
|
||||
const tails: string[] = [];
|
||||
if (cursor !== null) tails.push(cursor);
|
||||
tails.push(...pending);
|
||||
return tails;
|
||||
};
|
||||
|
||||
const tail = walk(model.list, "start");
|
||||
edges.push({ from: tail, to: "end" });
|
||||
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);
|
||||
@@ -176,6 +230,89 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
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;
|
||||
return {
|
||||
item: {
|
||||
uid: UsedIds.take(uidRaw),
|
||||
kind: "condition",
|
||||
name: n.name || "条件判断",
|
||||
routerKey: n.routerKey || "",
|
||||
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) {
|
||||
@@ -191,6 +328,12 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
}
|
||||
list.push(toApprovalNode(n));
|
||||
cur = out[cur][0];
|
||||
} 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("并行分发节点没有分支");
|
||||
@@ -206,6 +349,11 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
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") {
|
||||
@@ -258,6 +406,15 @@ function toApprovalNode(n: any): ApprovalNode {
|
||||
};
|
||||
}
|
||||
|
||||
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>(),
|
||||
|
||||
Reference in New Issue
Block a user