- Maps.vue:一键拉取教师组按钮 + 预览弹窗(科室/成员明细)+ 确认导入 - flow.ts:mapGroupPreview/mapImportGroups API - 含流程编辑器(def-editor)、路由 catch-all、示意图入口存量
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。
|
||||
*
|
||||
* toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL),
|
||||
* edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
|
||||
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN),还原为编辑树。
|
||||
* 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。
|
||||
*
|
||||
* 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。
|
||||
*/
|
||||
import {
|
||||
ApprovalNode,
|
||||
Assignee,
|
||||
Countersign,
|
||||
DEFAULT_URGE,
|
||||
DefEdgeJson,
|
||||
DefJson,
|
||||
DefNodeJson,
|
||||
EditorItem,
|
||||
EditorModel,
|
||||
EditorSettings,
|
||||
ParallelNode,
|
||||
createApproval,
|
||||
newUid,
|
||||
} from "./model";
|
||||
|
||||
/** 编辑树 → 后端 contentJson(code/name 恒取表单值,规避 contentJson 覆盖行字段的既有坑) */
|
||||
export function toContentJson(model: EditorModel): DefJson {
|
||||
const nodes: DefNodeJson[] = [];
|
||||
const edges: DefEdgeJson[] = [];
|
||||
let seq = 1;
|
||||
const nid = (p: string) => `${p}${seq++}`;
|
||||
|
||||
// 端点 name 仅在既有定义带值时回写(新建流程不生成,保持最小 JSON)
|
||||
const startNode: DefNodeJson = { id: "start", type: "START" };
|
||||
if (model.startName) startNode.name = model.startName;
|
||||
nodes.push(startNode);
|
||||
|
||||
const walk = (items: EditorItem[], prev: string): string => {
|
||||
let cursor = prev;
|
||||
items.forEach((item) => {
|
||||
if (item.kind === "approval") {
|
||||
const id = nid("n");
|
||||
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);
|
||||
edges.push({ from: cursor, to: id });
|
||||
cursor = id;
|
||||
return;
|
||||
}
|
||||
const split = nid("split");
|
||||
const join = nid("join");
|
||||
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
|
||||
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
||||
edges.push({ from: cursor, to: split });
|
||||
item.branches.forEach((branch) => {
|
||||
const tail = walk(branch, split);
|
||||
edges.push({ from: tail, to: join });
|
||||
});
|
||||
cursor = join;
|
||||
});
|
||||
return cursor;
|
||||
};
|
||||
|
||||
const tail = walk(model.list, "start");
|
||||
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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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") {
|
||||
if ((out[cur] || []).length !== 1) {
|
||||
throw new Error(`审批节点「${n.name || cur}」的出边必须恰好 1 条`);
|
||||
}
|
||||
list.push(toApprovalNode(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 === "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";
|
||||
return {
|
||||
uid: newUid(),
|
||||
kind: "approval",
|
||||
name: n.name || "审批",
|
||||
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
||||
countersign,
|
||||
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */
|
||||
function makeParallel(branches: EditorItem[][]): ParallelNode {
|
||||
return {
|
||||
uid: newUid(),
|
||||
kind: "parallel",
|
||||
branches: branches.length ? branches : [[createApproval()], [createApproval()]],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user