feat(admin): 抄送(NOTIFY)与条件分支(CONDITION)全栈接入编辑器/轨迹/管理面
build / build (push) Successful in 1m55s

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:
chenxing
2026-09-24 01:19:15 +08:00
parent cdf04f0930
commit e30e7ab665
20 changed files with 1614 additions and 84 deletions
+168 -11
View File
@@ -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>(),