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
+110 -26
View File
@@ -71,12 +71,13 @@ describe("FlowAssignEmbed 地址与会话", () => {
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || "";
const src = frameEl()!.src;
expect(src.split("#").length - 1).toBe(1);
expect(src).toContain("#/assign-preview");
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain("contextId=");
expect((wrapper.vm as any).contextId).toBeTruthy();
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain(`contextId=${(wrapper.vm as any).contextId}`);
});
it("缺少 flowGuid 时不开框、不监听", async () => {
@@ -84,13 +85,22 @@ describe("FlowAssignEmbed 地址与会话", () => {
const wrapper = mountHost({ flowGuid: "" });
(wrapper.vm as any).open();
await nextTick();
await flushPromises();
expect(frameEl()).toBeNull();
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
expect(addSpy.mock.calls.filter((c) => c[0] === "message")).toHaveLength(0);
addSpy.mockRestore();
});
});
describe("FlowAssignEmbed 消息校验", () => {
it("换线立即结束旧会话,旧 iframe 不能借新 props 回传选择", async () => {
const wrapper = mountHost(); await openHost(wrapper);
const contextId = (wrapper.vm as any).contextId;
await wrapper.setProps({ flowGuid: 'different-flow' });
await post(wrapper, baseData({ contextId, flowGuid: 'different-flow' }));
expect(wrapper.emitted('confirmed')).toBeUndefined();
expect(wrapper.emitted('cancelled')).toHaveLength(1);
});
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
const wrapper = mountHost();
await openHost(wrapper);
@@ -98,25 +108,25 @@ describe("FlowAssignEmbed 消息校验", () => {
await post(wrapper, baseData({ contextId: cid }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
expect((events[0][0] as any).defVersion).toBe(3);
expect((events[0][0] as any).flowGuid).toBe("g-embed");
const payload = events![0][0] as any;
expect(payload.flowGuid).toBe("g-embed");
expect(payload.defVersion).toBe(3);
expect(payload.assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
const fakeOtherWindow = { __other: true };
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example.com" });
await post(wrapper, baseData({ contextId: cid }), { source: fakeOtherWindow });
await post(wrapper, baseData({ contextId: "stale-from-prev-session" }));
await post(wrapper, { proto: "fc", v: VERSION, type: "assign-confirmed", contextId: cid });
await post(wrapper, { proto: PROTO, v: 99, type: "assign-confirmed", contextId: cid });
await post(wrapper, { proto: PROTO, v: VERSION, type: "not-ours", contextId: cid });
await post(wrapper, "plain-string-from-page-script");
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example" });
await post(wrapper, baseData({ contextId: cid }), { source: { __other: true } });
await post(wrapper, baseData({ contextId: "stale-ctx" }));
await post(wrapper, { proto: "fc", v: 1, type: "assign-confirmed", contextId: cid });
await post(wrapper, baseData({ contextId: cid, v: 2 }));
await post(wrapper, baseData({ contextId: cid, type: "assign-mystery" }));
await post(wrapper, { nope: true });
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
@@ -126,6 +136,9 @@ describe("FlowAssignEmbed 消息校验", () => {
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
await post(wrapper, baseData({ contextId: cid, defVersion: undefined }));
await post(wrapper, baseData({ contextId: cid, flowGuid: undefined }));
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
});
@@ -145,36 +158,33 @@ describe("FlowAssignEmbed 生命周期与选择语义", () => {
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [777] }] }));
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [999] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
expect((events![0][0] as any).assigns[0].userIds).toEqual([101]);
});
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
(wrapper.vm as any).close();
await nextTick();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
removeSpy.mockRestore();
});
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid1 = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [101] }] }));
await post(wrapper, baseData({ contextId: cid1 }));
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
// 宿主把 A 记在内存里,重开选 B
await openHost(wrapper);
const cid2 = (wrapper.vm as any).contextId;
expect(cid2).not.toEqual(cid1);
// 上一轮残帧补发一条:按 context 失效
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
expect(wrapper.emitted("confirmed")).toHaveLength(1);
@@ -202,3 +212,77 @@ describe("FlowAssignEmbed 生命周期与选择语义", () => {
removeSpy.mockRestore();
});
});
describe("FlowAssignEmbed 载荷逐字段校验与真实关闭(F03/F07)", () => {
/** F03:宿主必须按打开时锁定的流程与字段类型核载荷,畸形载荷不得覆盖节点配置 */
it.each([
["异流程回传", () => ({ flowGuid: "g-other" })],
["字符串 defVersion", () => ({ defVersion: "3" })],
["零 defVersion", () => ({ defVersion: 0 })],
["小数 defVersion", () => ({ defVersion: 1.5 })],
["空 nodeId", () => ({ assigns: [{ nodeId: "", userIds: [101] }] })],
["字符串型 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: ["101"] }] })],
["空 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: [] }] })],
["assigns 不是数组", () => ({ assigns: "u2=101" })],
])("畸形/异源载荷被丢弃:%s", async (_label, mutate) => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, ...(mutate() as object) }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("缺少可核验的窗口引用时不处理消息(不再是「缺条件就跳过校验」)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }), { source: {} as Window });
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
/** F07:点 X 是真实用户路径,必须走同一 settle/close 并通知宿主取消 */
it("点 Modal 的 X:宿主收到一次 cancelled,原有选择未被覆盖,监听器已摘", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const closeBtn = document.querySelector(".ivu-modal-close");
expect(closeBtn).not.toBeNull();
(closeBtn as HTMLElement).click();
await nextTick();
await flushPromises();
const cancelled = wrapper.emitted("cancelled");
expect(cancelled).toHaveLength(1);
expect(wrapper.emitted("confirmed")).toBeUndefined();
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
});
it("程序化 close 与 X 走同一路径:重复关闭只 emit 一次 cancelled", async () => {
const wrapper = mountHost();
await openHost(wrapper);
(wrapper.vm as any).close();
(wrapper.vm as any).close();
const closeBtn = document.querySelector(".ivu-modal-close");
(closeBtn as HTMLElement)?.click();
await nextTick();
await flushPromises();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
});
it("确认后不得再补发 cancelled(iframe 已答复,Modal 收起只是收尾)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toHaveLength(1);
const closeBtn = document.querySelector(".ivu-modal-close");
(closeBtn as HTMLElement)?.click();
await nextTick();
await flushPromises();
expect(wrapper.emitted("cancelled")).toBeUndefined();
expect(wrapper.emitted("confirmed")).toHaveLength(1);
});
});