feat(flow): 流程图入口/失败提示/指派预览/线级停发闭环/配置分层/选人宿主
build / build (push) Successful in 1m28s

- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
This commit is contained in:
chenxing
2026-10-28 03:18:54 +08:00
parent f2841e2740
commit c93313cdb7
26 changed files with 1602 additions and 36 deletions
+145
View File
@@ -0,0 +1,145 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { toContentJson, fromContentJson } from "../src/views/flow/admin/def-editor/convert";
import { createApproval, createCondition, createNotify, createParallel } from "../src/views/flow/admin/def-editor/model";
import type { EditorItem, EditorModel } from "../src/views/flow/admin/def-editor/model";
/**
* 流程定义序列化回归闸门(任务书 §6.8「检查流程定义序列化」)。
* 本轮只动编辑器面板的**展示分层**,这里钉住:结构语义不因这些改动漂移。
* 对比口径按语义集合比(节点字段集 + 边三元组集),不比数组顺序——空泳道会产生纯顺序差。
*/
type Json = ReturnType<typeof toContentJson>;
function model(items: EditorItem[]): EditorModel {
return {
code: "rt-demo",
name: "往返回归",
list: items,
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
};
}
const clone = (o: unknown) => JSON.parse(JSON.stringify(o));
function fullItems(): EditorItem[] {
const approval = createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" });
approval.countersign = "ALL";
approval.deadlineHours = 8;
approval.assignPolicy = { mode: "SINGLE", required: true };
const outletNode = createApproval("多出口", { type: "USERS", userIds: [101, 102] });
outletNode.outlets = ["同意", "退回", "加签"];
outletNode.branches = [[createApproval("分支一")], [], [createNotify("分支三抄送")]];
const cond = createCondition();
cond.judgeMode = "EXPRESSION";
cond.outlets = ["大额", "小额"];
cond.conditions = ["amount > 1000", ""];
cond.branches = [[createApproval("大额复核")], [createApproval("小额直过")]];
const par = createParallel();
par.branches = [[createNotify("并行抄送A")], [createApproval("并行复核B", { type: "DEPT_KEY", deptKey: "42" })]];
return [approval, cond, par, outletNode];
}
/** 已知缺陷用:非首位并行容器重解析后 split/join 会换成新 id(见最后一条用例) */
function parallelDerivedIds(json: Json): string[] {
return json.nodes.filter((n) => n.type === "PARALLEL_SPLIT" || n.type === "PARALLEL_JOIN").map((n) => n.id);
}
function roundTrip(times: number, json: Json): Json {
let cur = json;
for (let i = 0; i < times; i++) {
cur = toContentJson({ ...model([]), ...fromContentJson(clone(cur)) } as EditorModel);
}
return cur;
}
test("结构往返两轮:每个节点的配置语义逐项一致(并行派生 id 另用例专门记录)", () => {
const first = toContentJson(model(fullItems()));
const second = roundTrip(1, first);
const third = roundTrip(2, first);
const byId = (json: Json) => {
const m = new Map<string, string>();
json.nodes.forEach((n) => m.set(n.id, JSON.stringify(n)));
return m;
};
const a = byId(first);
const b = byId(second);
const c = byId(third);
// 配置类节点(审批/抄送/条件)的 id 与内容必须逐字不变
["u1", "u2", "u3", "u4", "u5c", "u8", "u9", "start", "end"].forEach((id) => {
assert.equal(b.get(id), a.get(id), `节点 ${id} 内容在第二轮发生变化`);
assert.equal(c.get(id), a.get(id), `节点 ${id} 内容在第三轮发生变化`);
});
assert.deepEqual(second.settings, first.settings);
assert.deepEqual(third.settings, first.settings);
// 并行分支内的两名办理人同样不变
const branchIds = ["u13", "u14"];
branchIds.forEach((id) => assert.equal(b.get(id), a.get(id), `并行分支节点 ${id} 变化`));
// 出口边(含条件表达式)在两轮都存在
const edgeSet = (json: Json) => new Set(json.edges.map((e) => `${e.from}->${e.to}${e.name ? `(${e.name})` : ""}${e.condition ? `[${e.condition}]` : ""}`));
const ea = edgeSet(first);
const eb = edgeSet(second);
["u2->u3(同意)", "u2->u4(加签)", "u2->end(退回)", "u5c->u8(大额)[amount > 1000]", "u5c->u9(小额)"].forEach((e) => {
assert.ok(ea.has(e), `第一轮缺少边 ${e}`);
assert.ok(eb.has(e), `第二轮缺少边 ${e}`);
});
});
test("关键语义字段全部活过往返,不被展示层分层影响", () => {
const first = toContentJson(model(fullItems()));
const parsed = fromContentJson(clone(first));
const second = toContentJson({ ...model([]), ...parsed } as EditorModel);
// 会签 ALL 是后端缺省值,生成 JSON 时按设计省略(model.ts 注释);口径以解析回编辑态为准
const approvalItem = parsed.list.find((it) => it.kind === "approval" && it.assignee?.type === "DEPT_KEY");
assert.ok(approvalItem && approvalItem.kind === "approval");
assert.equal(approvalItem.countersign, "ALL", "会签语义在解析端丢失");
assert.equal(approvalItem.deadlineHours, 8, "办理时限丢失");
assert.deepEqual(approvalItem.assignPolicy, { mode: "SINGLE", required: true }, "发起时指派策略丢失");
assert.ok(second.nodes.some((n) => n.type === "NOTIFY" && n.receivers?.type === "USERS"), "抄送对象丢失");
assert.ok(second.nodes.some((n) => n.judgeMode === "EXPRESSION"), "条件判断方式丢失");
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_SPLIT"), "并行拆分丢失");
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_JOIN" && n.waitFor === "ALL"), "并行汇聚语义丢失");
assert.ok(second.nodes.some((n) => n.id === "start") && second.nodes.some((n) => n.id === "end"), "端点丢失");
assert.ok(second.edges.filter((e) => e.name).length >= 3, "多出口/条件分支的出口名丢失");
});
test("设置与端点名不被丢:rejectPolicy/urge/start-end name 原样回写", () => {
const m = model([createApproval("单节点")]);
m.startName = "提交申请";
m.endName = "办结";
const json = toContentJson(m);
assert.equal(json.settings.rejectPolicy, "END_REJECTED");
assert.equal(json.settings.urge.intervalHours, 24);
assert.equal((json.nodes.find((n) => n.type === "START") || {}).name, "提交申请");
assert.equal((json.nodes.find((n) => n.type === "END") || {}).name, "办结");
});
test("已知缺陷(本轮不修,修它等于改序列化契约):并行容器重解析后 split/join 换成新 id", () => {
const first = toContentJson(model([createApproval("a"), createParallel()]));
const before = parallelDerivedIds(first);
const rt = roundTrip(1, first);
const after = parallelDerivedIds(rt);
assert.equal(after.length, before.length, "并行拆/合节点数量发生变化");
assert.notDeepEqual(after, before, "并行 id 改名行为已变化——若已被修好,请把本用例改成断言 id 稳定并同步终审");
// 改名只涉及 id 字面,汇聚语义不变
const join = rt.nodes.find((n) => n.type === "PARALLEL_JOIN");
assert.equal(rt.nodes.filter((n) => n.type === "PARALLEL_SPLIT").length, 1);
assert.equal(join?.waitFor, "ALL", "并行汇聚 waitFor 语义丢失");
// 拆分/汇聚与分支节点的连接关系仍然闭合(每个分支节点都连到某条 split/join)
const anchors = new Set(after);
const linked = rt.edges.filter((e) => anchors.has(e.from) || anchors.has(e.to));
assert.ok(linked.length >= 4, `并行连线丢失,只剩 ${linked.length} 条`);
});