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);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
import { reactive, nextTick } from 'vue';
import ViewUIPlus from 'view-ui-plus';
import ApprovalDrawer from '@/views/flow/admin/def-editor/components/ApprovalDrawer.vue';
import NotifyDrawer from '@/views/flow/admin/def-editor/components/NotifyDrawer.vue';
import { EDITOR_CTX, createApproval, createNotify } from '@/views/flow/admin/def-editor/model';
const { api } = vi.hoisted(() => ({ api: { deptGroups: vi.fn(), deptResolve: vi.fn(), deptOptions: vi.fn() } }));
vi.mock('@/api/module/flow', () => ({ flowApi: api }));
enableAutoUnmount(afterEach);
const ok = (data: unknown) => ({ send: async () => data });
describe('CUSTOM 引用真实抽屉编辑往返', () => {
for (const mode of ['approval', 'notify'] as const) {
it(`${mode}:无 categoryId 的历史解析仍可反查分类,保存重开再保存不丢引用`, async () => {
document.body.innerHTML = '';
const ref = { departmentType: 'CUSTOM', departmentId: 9 };
api.deptGroups.mockImplementation(() => ok([
{ departmentType: 'SYSTEM', name: '系统组织' },
{ departmentType: 'CUSTOM', categoryId: 4, name: '教研组' },
]));
api.deptResolve.mockImplementation(() => ok([{ ...ref, name: '科学组', memberCount: 2, selectable: true }]));
api.deptOptions.mockImplementation(() => ok([{ ...ref, categoryId: 4, name: '科学组', memberCount: 2, selectable: true }]));
const node: any = mode === 'approval' ? createApproval('审批') : createNotify('抄送');
const key = mode === 'approval' ? 'assignee' : 'receivers';
node[key] = { type: 'DEPT', deptRefs: [ref] };
const ctx = { model: reactive({ list: [node] }), depts: [], userNames: {}, routers: [], availableVars: [], issueOf: () => [] };
const wrapper = mount(mode === 'approval' ? ApprovalDrawer : NotifyDrawer, {
props: { node, modelValue: true }, attachTo: document.body,
global: { plugins: [ViewUIPlus], provide: { [EDITOR_CTX as unknown as symbol]: ctx } },
});
await flushPromises(); await nextTick();
expect(document.body.textContent).toContain('科学组');
expect(api.deptOptions).toHaveBeenCalledWith({ type: 'CUSTOM' });
for (let round = 0; round < 2; round++) {
if (round) { await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises(); }
const input = document.querySelector('input.ivu-input') as HTMLInputElement;
expect(input).toBeTruthy(); input.value = `修改名称${round}`; input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
const confirm = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '确定');
expect(confirm).toBeTruthy(); confirm!.click(); await nextTick();
expect(node[key]).toEqual({ type: 'DEPT', deptRefs: [ref] });
expect(node.name).toBe(`修改名称${round}`);
}
});
}
});
@@ -0,0 +1,27 @@
import { it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
import ViewUIPlus from 'view-ui-plus';
import TaskHandle from '@/views/flow/TaskHandle.vue';
const { api } = vi.hoisted(() => ({ api: { taskDetail: vi.fn(), approve: vi.fn() } }));
vi.mock('@/api/module/flow', () => ({ flowApi: api, INSTANCE_STATUS: { 10: '审批中' } }));
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { taskId: '1' }, path: '/task/1', query: { embed: '1' } }), useRouter: () => ({ push: vi.fn() }) }));
vi.mock('@/utils/taskEmbed', () => ({ isBareTaskRoute: () => true, postToParent: vi.fn() }));
enableAutoUnmount(afterEach);
const detail = { instanceId: 9, status: 10, myPendingTaskIds: [1], trace: [{ taskId: 1, status: 0 }], outlets: [] };
async function click(text: string) {
const b = Array.from(document.querySelectorAll('button')).find(x => x.textContent?.trim() === text);
expect(b).toBeTruthy(); b!.click(); await flushPromises();
}
for (const lost of [true, false]) it(lost ? 'POST 丢响应不声称未生效' : 'POST 成功但刷新失败保留成功并关闭办理入口', async () => {
document.body.innerHTML = '';
let reads = 0;
api.taskDetail.mockImplementation(() => ({ send: async () => { if (++reads > 1) throw new Error('refresh failed'); return detail; } }));
api.approve.mockImplementation(() => ({ send: async () => { if (lost) throw new TypeError('Failed to fetch'); return {}; } }));
mount(TaskHandle, { global: { plugins: [ViewUIPlus], stubs: { TraceTimeline: true } }, attachTo: document.body });
await flushPromises(); await click('同意'); await click('确定');
const text = document.body.textContent || '';
expect(text).not.toContain('本次办理未生效');
expect(text).not.toContain('任务仍是你的待办');
if (lost) expect(text).toContain('结果待确认');
else { expect(text).toContain('已提交成功'); expect(document.querySelector('.actions')).toBeNull(); }
});