feat(flow): 落地 FL2.0 原型——部门选人抽屉 / 授权管理页 / 退回重提入口(基线 origin/main)
build / build (push) Successful in 1m31s

在 c93313c 之上增量落地原型与前端规范,保留既有流程图入口/失败提示/指派预览/线级停发:

- 审批/抄送抽屉按原型重构(422→1026 / 239→742 行):科室树+成员多选、SYSTEM/CUSTOM 分支、
  CUSTOM 分类导航自动定位、跨节点导航重置、保存后重开回显
- 新增授权管理页 /admin/usage-grants(UsageGrants.vue,417 行):校域清单、按流程线筛选、
  授予/撤销后回读,失败不宣称成功
- 路由与 API:新增 usage-grant(schoolList/summary/grant/revoke)、department(groups/options/resolve/members)、
  instance/resubmit;TaskHandle 增加「退回待修改」入口(仅 status=50 显示)
- 错误处理沿用 flowError 统一出口,不透出后端原文
- 组件/契约测试:flow-error、flow2-contract、department-roundtrip、task-result

不含构建产物(dist-verify)与依赖目录
This commit is contained in:
chenxing
2026-10-30 10:15:47 +08:00
parent c93313cdb7
commit bed57846f4
28 changed files with 2829 additions and 253 deletions
+209
View File
@@ -0,0 +1,209 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fromContentJson, toContentJson } from "../src/views/flow/admin/def-editor/convert";
import { createEmptyModel } from "../src/views/flow/admin/def-editor/model";
import { isValidAssignMessage, buildMessage } from "../src/utils/assignEmbed";
/**
* FL 2.0 缺口补足的定义契约往返 + 选人载荷校验单测(2026-10-29)。
*
* 覆盖三件"换皮不能破"的事:
* 1. C1/C3:UserBase 类型化部门引用与节点展示方式在 保存→重新加载→再次保存 中逐字段存活;
* 2. D1:变量契约持久化/回显,且编辑态专用字段(enumText)不进 contentJson;
* 3. F03/F07:选人 iframe 宿主对畸形/异源/异流程载荷一律拒绝。
*
* 注意:并行容器派生节点 id 重编号(Codex F08)是基线既有行为,本文件刻意不把它写成"已修复"。
*/
const ORIGINAL = JSON.stringify({
code: "flow-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
name: "缺口补足线",
start: "start",
nodes: [
{ id: "start", type: "START" },
{
id: "n1",
type: "APPROVAL",
name: "一级审批",
assignee: { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] },
countersign: "ANY",
displayMode: "DEPT",
displayLabel: "校长室",
},
{
id: "n2",
type: "APPROVAL",
name: "二级审批",
assignee: { type: "USERS", userIds: [101, 102] },
countersign: "ALL",
assignPolicy: { mode: "SINGLE", required: true },
},
{
id: "n3",
type: "NOTIFY",
name: "抄送",
receivers: {
type: "DEPT",
deptRefs: [
{ departmentType: "CUSTOM", departmentId: 7 },
{ departmentType: "SYSTEM", departmentId: 42 },
],
},
},
{ id: "end", type: "END" },
],
edges: [
{ from: "start", to: "n1" },
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3" },
{ from: "n3", to: "end" },
],
variables: [
{ key: "days", type: "number", required: true },
{ key: "grade", type: "string", enum: ["g1", "g2"] },
],
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
});
test("部门引用与展示方式:载入→保存→再载入逐字段存活,且不写入名称快照", () => {
const parsed = fromContentJson(JSON.parse(ORIGINAL));
const json = toContentJson({
code: parsed.code,
name: parsed.name,
list: parsed.list,
settings: parsed.settings,
variables: parsed.variables,
});
const n1: any = json.nodes.find((n) => n.id === "n1");
assert.deepEqual(n1.assignee, { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] });
assert.equal(n1.displayMode, "DEPT");
assert.equal(n1.displayLabel, "校长室");
assert.equal(n1.countersign, "ANY");
const n3: any = json.nodes.find((n) => n.id === "n3");
assert.deepEqual(n3.receivers.deptRefs, [
{ departmentType: "CUSTOM", departmentId: 7 },
{ departmentType: "SYSTEM", departmentId: 42 },
]);
// 第二次往返不变形(保存-重载-再保存)
const reparsed = fromContentJson(JSON.parse(JSON.stringify(json)));
const again = toContentJson({
code: reparsed.code,
name: reparsed.name,
list: reparsed.list,
settings: reparsed.settings,
variables: reparsed.variables,
});
assert.deepEqual(again.nodes, json.nodes);
});
test("未配置展示方式的历史节点不写出 displayMode(旧定义字节兼容)", () => {
const legacy = JSON.parse(ORIGINAL);
delete legacy.nodes[1].displayMode;
delete legacy.nodes[1].displayLabel;
const parsed = fromContentJson(legacy);
const json = toContentJson({
code: parsed.code, name: parsed.name, list: parsed.list,
settings: parsed.settings, variables: parsed.variables,
});
const n1: any = json.nodes.find((n) => n.id === "n1");
assert.equal("displayMode" in n1, false);
assert.equal("displayLabel" in n1, false);
});
test("D1 变量契约:required/enum 持久化,编辑态 enumText 不进 contentJson", () => {
const parsed = fromContentJson(JSON.parse(ORIGINAL));
assert.deepEqual(parsed.variables?.map((v) => v.key), ["days", "grade"]);
assert.equal(parsed.variables?.[0].required, true);
assert.deepEqual(parsed.variables?.[1].enum, ["g1", "g2"]);
const json = toContentJson({
code: "x", name: "x", list: parsed.list, settings: parsed.settings, variables: parsed.variables,
});
assert.deepEqual((json as any).variables, [
{ key: "days", type: "number", required: true },
{ key: "grade", type: "string", enum: ["g1", "g2"] },
]);
assert.equal("enumText" in ((json as any).variables[1]), false);
});
test("D1 新建流程显式声明空变量集(后端要求新发布版本必须声明)", () => {
const model = createEmptyModel();
const json = toContentJson(model);
assert.deepEqual((json as any).variables, []);
});
test("D1 逗号输入的值域归一为字符串数组,中文逗号同样切开", () => {
const model = createEmptyModel();
model.variables = [{ key: "grade", type: "string", required: false, enumText: "优,良, 中 ," }];
const json = toContentJson(model);
assert.deepEqual((json as any).variables, [{ key: "grade", type: "string", enum: ["优", "良", "中"] }]);
});
// ==================== F03 选人 iframe 载荷校验 ====================
const ORIGIN = "https://notice.example.edu.cn";
const WINDOW = {} as Window;
const CONTEXT = "ctx-1";
const FLOW = "fg-locked";
function frameEvent(data: unknown, origin = ORIGIN, source: Window | null = WINDOW) {
return { origin, source, data } as unknown as MessageEvent;
}
function confirmed(overrides: Record<string, unknown> = {}) {
return {
proto: "flow", v: 1, type: "assign-confirmed", contextId: CONTEXT,
flowGuid: FLOW, defVersion: 3,
assigns: [{ nodeId: "n1", userIds: [101] }],
...overrides,
};
}
function valid(event: MessageEvent) {
return isValidAssignMessage(event, ORIGIN, WINDOW, CONTEXT, FLOW);
}
test("F03:正向消息通过,且逐字段带出", () => {
const msg = valid(frameEvent(confirmed()));
assert.ok(msg);
assert.equal(msg?.flowGuid, FLOW);
assert.equal(msg?.defVersion, 3);
});
test("F03:origin/source/context 任一缺失或不符即丢弃(不再是「缺条件就跳过校验」)", () => {
assert.equal(valid(frameEvent(confirmed(), "https://evil.example")), null);
assert.equal(isValidAssignMessage(frameEvent(confirmed()), ORIGIN, null, CONTEXT, FLOW), null);
assert.equal(isValidAssignMessage(frameEvent(confirmed()), "", WINDOW, CONTEXT, FLOW), null);
assert.equal(valid(frameEvent(confirmed({ contextId: "stale-ctx" }))), null);
});
test("F03:异流程回传丢弃(消息 flowGuid 必须等于打开时锁定的流程)", () => {
assert.equal(valid(frameEvent(confirmed({ flowGuid: "fg-other" }))), null);
});
test("F03:畸形载荷整条拒绝——字符串版本、空节点、非数字 ID、缺 assigns", () => {
assert.equal(valid(frameEvent(confirmed({ defVersion: "3" }))), null);
assert.equal(valid(frameEvent(confirmed({ defVersion: 0 }))), null);
assert.equal(valid(frameEvent(confirmed({ defVersion: 1.5 }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "", userIds: [101] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: ["101"] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: [] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: "n1=101" }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: undefined }))), null);
});
test("F03:协议版本与类型闭集不被放宽", () => {
assert.equal(valid(frameEvent(confirmed({ v: 2 }))), null);
assert.equal(valid(frameEvent(confirmed({ proto: "fc" }))), null);
assert.equal(valid(frameEvent(confirmed({ type: "assign-mystery" }))), null);
assert.equal(valid(frameEvent({ nope: true })), null);
});
test("取消消息只需 context,不携带选择也不被要求带 flowGuid", () => {
const cancelled = { proto: "flow", v: 1, type: "assign-cancelled", contextId: CONTEXT };
const msg = valid(frameEvent(cancelled));
assert.equal(msg?.type, "assign-cancelled");
assert.equal(buildMessage(cancelled as any)?.type, "assign-cancelled");
});