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
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
import ViewUIPlus from "view-ui-plus";
import { nextTick, reactive } from "vue";
const { flowApiMock } = vi.hoisted(() => ({
flowApiMock: {
adminTeacherTree: vi.fn(),
deptMembers: vi.fn(),
mapLookupUser: vi.fn(),
applyPreview: vi.fn(),
},
}));
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
import ApprovalDrawer from "@/views/flow/admin/def-editor/components/ApprovalDrawer.vue";
import AssigneePreviewModal from "@/views/flow/admin/def-editor/components/AssigneePreviewModal.vue";
import { EDITOR_CTX, createApproval, type ApprovalNode, type EditorItem } from "@/views/flow/admin/def-editor/model";
/**
* 审批节点抽屉的组件测试:验收 P2-1 的两条硬指标——
* ① 面板里有可发现的「预览候选人」入口,点开是真接口解析结果;
* ② 核心/高级分层只挪展示,不改字段与数据语义。
* 注:iView Drawer/Modal 内容 teleport 到 body,一律按真实 DOM 断言。
*/
enableAutoUnmount(afterEach);
function ok<T>(value: T) {
return { send: async () => value };
}
function makeNode(over: Partial<ApprovalNode> = {}): ApprovalNode {
return { ...createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" }), ...over } as ApprovalNode;
}
function baseCtx(over: Record<string, unknown> = {}) {
const model = reactive({
code: "c",
name: "n",
list: [] as EditorItem[],
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
});
return {
model,
depts: [
{ deptKey: "41", deptName: "校长室", count: 2 },
{ deptKey: "42", deptName: "空科室", count: 0 },
],
userNames: { 101: "陈星", 102: "刘文娟" },
routers: [],
availableVars: [],
issueOf: () => [],
openNode: () => undefined,
removeNode: () => undefined,
insertItem: () => undefined,
addBranch: () => undefined,
removeBranch: () => undefined,
exitOutlets: () => undefined,
flowGuid: "g-line-1",
...over,
};
}
function mountDrawer(node: ApprovalNode, ctxOver: Record<string, unknown> = {}): VueWrapper {
return mount(ApprovalDrawer, {
props: { modelValue: true, node },
global: {
plugins: [ViewUIPlus],
provide: { [EDITOR_CTX as unknown as symbol]: baseCtx(ctxOver) },
},
attachTo: document.body,
});
}
const bodyText = () => document.body.textContent || "";
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
const hiddenByStyle = (el: HTMLElement | null) => (el?.getAttribute("style") || "").includes("display: none");
async function clickEl(el: HTMLElement | null) {
expect(el, "目标元素不存在").toBeTruthy();
el!.click();
await nextTick();
await flushPromises();
}
function buttonNamed(text: string) {
return (Array.from(document.querySelectorAll("button")) as HTMLButtonElement[])
.find((b) => (b.textContent || "").includes(text)) || null;
}
beforeEach(() => {
document.body.innerHTML = "";
flowApiMock.adminTeacherTree.mockImplementation(() => ok([]));
flowApiMock.deptMembers.mockImplementation((deptId: string) =>
ok(deptId === "41"
? [
{ userId: 101, userName: "陈星", status: 0 },
{ userId: 102, userName: "刘文娟", status: 0 },
{ userId: 103, userName: "测试教师10", status: 1 },
]
: []),
);
flowApiMock.mapLookupUser.mockImplementation((id: number) => ok({ id, name: `用户${id}`, status: 1 }));
flowApiMock.applyPreview.mockImplementation(() => ok({ defVersion: 3, nodes: [] }));
});
afterEach(() => {
vi.clearAllMocks();
});
describe("审批节点抽屉 · 核心/高级分层", () => {
it("核心区直接可见:节点名称、审批人、预览入口;高级项默认收起", async () => {
mountDrawer(makeNode());
await flushPromises();
const text = bodyText();
expect(text).toContain("节点名称");
expect(text).toContain("审批人类型");
expect(text).toContain("科室映射");
expect(text).toContain("预览候选人");
expect(text).toContain("高级设置");
// 收起只是隐藏,不删除能力
expect(hiddenByStyle(q(".adv-body"))).toBe(true);
});
it("高级能力一个都没少:展开后会签/发起时指派/时限/多出口都在原位置", async () => {
mountDrawer(makeNode());
await flushPromises();
await clickEl(q(".adv-head"));
const adv = q(".adv-body");
expect(hiddenByStyle(adv)).toBe(false);
const text = adv?.textContent || "";
expect(text).toContain("多人审批方式");
expect(text).toContain("发起时指派");
expect(text).toContain("办理时限(小时)");
expect(text).toContain("出口路由");
});
it("已配置高级项的节点:打开抽屉自动展开,收起态也标出配了哪几项", async () => {
mountDrawer(makeNode({ countersign: "ALL", deadlineHours: 24 }));
await flushPromises();
expect(hiddenByStyle(q(".adv-body"))).toBe(false);
await clickEl(q(".adv-head"));
expect(q(".adv-badge")?.textContent).toContain("会签");
expect(q(".adv-badge")?.textContent).toContain("办理时限");
});
it("分层不改数据:折叠/展开来回,节点字段值不变", async () => {
const node = makeNode({ countersign: "ALL", deadlineHours: 8, outlets: ["同意", "退回"] });
const snapshot = JSON.stringify(node);
mountDrawer(node);
await flushPromises();
await clickEl(q(".adv-head"));
await clickEl(q(".adv-head"));
expect(JSON.stringify(node)).toEqual(snapshot);
});
});
describe("审批节点抽屉 · 预览候选人入口", () => {
async function openPreview() {
await clickEl(buttonNamed("预览候选人"));
}
it("点入口即按当前节点配置走真实接口(科室成员 + 发起预览),不是静态示例", async () => {
mountDrawer(makeNode());
await flushPromises();
await openPreview();
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("41");
expect(flowApiMock.applyPreview).toHaveBeenCalledWith("g-line-1");
const text = bodyText();
expect(text).toContain("校长室");
expect(text).toContain("陈星");
expect(text).toContain("刘文娟");
expect(text).toContain("该成员已停用,不计入候选");
expect(text).toContain("预览只读");
});
it("预览读的是抽屉里的未保存改动(换科室后立刻按新科室解析)", async () => {
mountDrawer(makeNode({ assignee: { type: "DEPT_KEY", deptKey: "42" } }));
await flushPromises();
await openPreview();
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("42");
const text = bodyText();
expect(text).toContain("0 名启用办理人");
expect(text).toContain("发布会被拦截");
});
it("指定用户节点按 UserBase 检索解析,非启用账号如实标注", async () => {
flowApiMock.mapLookupUser.mockImplementation((id: number) =>
ok(id === 101 ? { id: 101, name: "陈星", status: 1 } : { id, name: `用户${id}`, status: 0 }),
);
mountDrawer(makeNode({ assignee: { type: "USERS", userIds: [101, 102] } }));
await flushPromises();
await openPreview();
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(101);
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(102);
const text = bodyText();
expect(text).toContain("陈星");
expect(text).toContain("非启用");
});
it("流程线尚未保存时说明没有可比对的已发布版本,不假装有一版", async () => {
mountDrawer(makeNode(), { flowGuid: "" });
await flushPromises();
await openPreview();
expect(flowApiMock.applyPreview).not.toHaveBeenCalled();
expect(bodyText()).toContain("尚未保存");
});
it("预览只读:打开再关闭不写回节点、不产生任何接口写入", async () => {
const node = makeNode();
const before = JSON.stringify(node);
const wrapper = mountDrawer(node);
await flushPromises();
await openPreview();
await clickEl(buttonNamed("关闭"));
expect(JSON.stringify(node)).toEqual(before);
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
// 预览的数据源只有读接口:科室成员 / 用户检索 / 发起预览
expect(flowApiMock.deptMembers).toHaveBeenCalled();
expect(flowApiMock.applyPreview).toHaveBeenCalled();
});
it("预览弹窗组件确实挂在抽屉内(入口不是死链)", async () => {
const wrapper = mountDrawer(makeNode());
await flushPromises();
expect(wrapper.findComponent(AssigneePreviewModal).exists()).toBe(true);
});
});