feat: 办理人映射页一键拉取教师组(预览确认+批量导入)+ 流程编辑器存量
build / build (push) Successful in 2m6s

- Maps.vue:一键拉取教师组按钮 + 预览弹窗(科室/成员明细)+ 确认导入
- flow.ts:mapGroupPreview/mapImportGroups API
- 含流程编辑器(def-editor)、路由 catch-all、示意图入口存量
This commit is contained in:
chenxing
2026-09-16 01:21:10 +08:00
parent 670988816c
commit 842a4cfb14
15 changed files with 2105 additions and 72 deletions
+260
View File
@@ -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()]],
};
}