feat(flow): 落地 FL2.0 原型——部门选人抽屉 / 授权管理页 / 退回重提入口(基线 origin/main)
build / build (push) Successful in 1m31s
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user