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
+48
View File
@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { toAppHashUrl } from "../src/utils/appUrl";
/** 整页跳转地址单测:只锁「最终只有一个 hash」这条线上缺陷口径,不替代浏览器实测 */
test("办理页入口:resolve 出的 #/diagram 不再追加到当前 hash 之后", () => {
const out = toAppHashUrl(
"#/diagram?instanceId=107&mode=user",
"https://base.yhxt.com.cn/flow-center/front/index.html#/task/339",
);
assert.equal(out, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=107&mode=user");
assert.equal(out.split("#").length - 1, 1);
});
test("参数原样保留:instanceId 与 mode 不因去 hash 而丢失", () => {
const out = toAppHashUrl("#/diagram?instanceId=107&mode=dept", "https://h/flow-center/front/index.html#/task/339");
assert.match(out, /instanceId=107/);
assert.match(out, /mode=dept/);
});
test("无 hash 的入口地址:直接接上目标 hash", () => {
assert.equal(
toAppHashUrl("#/diagram?instanceId=1", "https://h/flow-center/front/index.html"),
"https://h/flow-center/front/index.html#/diagram?instanceId=1",
);
});
test("真实 query 保留在 hash 之前(部署带 ?v= 缓存位时不被吞)", () => {
assert.equal(
toAppHashUrl("#/diagram?instanceId=1", "https://h/index.html?v=9#/task/339"),
"https://h/index.html?v=9#/diagram?instanceId=1",
);
});
test("不带前导 # 的 resolve 结果同样归一,且不出现双 hash", () => {
const out = toAppHashUrl("/diagram?instanceId=1", "https://h/index.html#/task/339");
assert.equal(out, "https://h/index.html#/diagram?instanceId=1");
});
test("查看大图:不再用 origin+BASE_URL 拼出 host 尾点地址", () => {
// 旧写法 origin + "./" + "#/diagram?…" 会得到 https://base.yhxt.com.cn./#/diagram?…
const legacy = `${"https://base.yhxt.com.cn"}${"./"}#/diagram?instanceId=1`;
assert.match(legacy, /com\.cn\.\//);
const fixed = toAppHashUrl("#/diagram?instanceId=1", "https://base.yhxt.com.cn/flow-center/front/index.html#/admin/instances");
assert.ok(!/\.cn\.\//.test(fixed), fixed);
assert.equal(fixed, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=1");
});
+162
View File
@@ -0,0 +1,162 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveNodePreview } from "../src/views/flow/admin/def-editor/assignPreview";
import type { PreviewApi, PreviewDept, PreviewNodeConfig } from "../src/views/flow/admin/def-editor/assignPreview";
/**
* 编辑器指派预览解析单测:断言的是「用什么接口、给什么结论、失败怎么讲」。
* 数据用桩,不代表真实科室数据与线上服务已验证。
*/
const depts: PreviewDept[] = [
{ deptKey: "41", deptName: "校长室", count: 2 },
{ deptKey: "42", deptName: "空科室", count: 0 },
];
function cfg(over: Partial<PreviewNodeConfig> = {}): PreviewNodeConfig {
return {
uid: "u3",
name: "科室审核",
type: "DEPT_KEY",
deptKey: "41",
userIds: [],
countersign: "ANY",
assignPolicy: null,
...over,
};
}
function stubApi(over: Partial<PreviewApi> = {}): PreviewApi & { calls: string[] } {
const calls: string[] = [];
return {
calls,
deptMembers: async (deptId) => {
calls.push(`deptMembers:${deptId}`);
if (deptId === "41") {
return [
{ userId: 101, userName: "陈星", status: 0 },
{ userId: 102, userName: "刘文娟", status: 0 },
{ userId: 103, userName: "测试教师10", status: 1 },
];
}
return [];
},
lookupUser: async (userId) => {
calls.push(`lookupUser:${userId}`);
if (userId === 101) return { id: 101, name: "陈星", status: 1 };
if (userId === 999) return null;
return { id: userId, name: `用户${userId}`, status: 0 };
},
applyPreview: async (flowGuid) => {
calls.push(`applyPreview:${flowGuid}`);
return { flowGuid, defVersion: 3, nodes: [] };
},
...over,
};
}
test("科室映射节点:读既有科室成员接口,启用成员进候选、停用成员标注不计入", async () => {
const api = stubApi();
const out = await resolveNodePreview(cfg(), depts, api);
assert.equal(out.ok, true);
assert.ok(out.headline.includes("校长室"));
assert.ok(out.headline.includes("2 名启用办理人"));
assert.deepEqual(api.calls.filter((c) => c.startsWith("deptMembers")), ["deptMembers:41"]);
assert.equal(out.rows.length, 3);
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "刘文娟", "测试教师10"]);
assert.equal(out.rows[2].warn, "该成员已停用,不计入候选");
assert.ok(out.notes.some((n) => n.includes("预览只读")));
});
test("未选科室=明确的不可预览原因,而不是空列表里什么都不说", async () => {
const out = await resolveNodePreview(cfg({ deptKey: "" }), depts, stubApi());
assert.equal(out.ok, false);
assert.equal(out.blocked, "尚未选择科室映射,请先在「科室映射」里选一个科室。");
});
test("科室已被删除:点名到映射键并说明发布会被拦截", async () => {
const out = await resolveNodePreview(cfg({ deptKey: "99" }), depts, stubApi());
assert.equal(out.ok, false);
assert.ok(out.blocked?.includes("「99」"));
assert.ok(out.blocked?.includes("发布会被拦截"));
});
test("科室 0 名启用成员:给出补人路径,不误报成功", async () => {
const out = await resolveNodePreview(cfg({ deptKey: "42" }), depts, stubApi());
assert.equal(out.ok, false);
assert.ok(out.blocked?.includes("0 名启用办理人"));
assert.ok(out.blocked?.includes("办理人映射"));
});
test("指定用户节点:逐个走 UserBase 检索,禁用账号标注服务端会拒绝", async () => {
const api = stubApi();
const out = await resolveNodePreview(
cfg({ type: "USERS", deptKey: "", userIds: [101, 102, 999] }),
depts,
api,
);
assert.equal(out.ok, true);
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "用户102", "#999"]);
assert.ok(out.rows[1].warn?.includes("非启用"));
assert.ok(out.rows[2].warn?.includes("UserBase 查不到"));
});
test("指定用户检索失败(服务不可达):原因可读,堆栈类原文不外泄", async () => {
const api = stubApi({
lookupUser: async () => {
throw new Error("java.lang.RuntimeException: connect timed out at com.yunhai.UserClient");
},
});
const out = await resolveNodePreview(cfg({ type: "USERS", deptKey: "", userIds: [101] }), depts, api);
assert.equal(out.ok, false);
assert.ok(out.blocked?.includes("发布会被拦截"));
assert.ok(!out.rows[0].warn?.includes("java."));
assert.ok(out.rows[0].warn?.includes("服务未能处理本次请求"));
});
test("新建未保存线:已发布版本对账说明「尚未保存」,不假装有一版可比", async () => {
const out = await resolveNodePreview(cfg(), depts, stubApi(), "");
assert.equal(out.published?.state, "unavailable");
assert.ok(out.published?.text.includes("尚未保存"));
});
test("草稿节点不在已发布预览中:区分「未开启指派」与「尚未发布」", async () => {
const out = await resolveNodePreview(cfg(), depts, stubApi(), "g1");
assert.equal(out.published?.state, "missing");
assert.ok(out.published?.text.includes("v3"));
assert.ok(out.published?.text.includes("未开启发起时指派"));
});
test("已发布版本有本节点:候选人按服务端解析结果展示(含提单人剔除后的集)", async () => {
const api = stubApi({
applyPreview: async () => ({
defVersion: 4,
nodes: [{ nodeId: "u3", nodeName: "科室审核", candidates: [{ userId: 102, userName: "刘文娟" }] }],
}),
});
const out = await resolveNodePreview(cfg(), depts, api, "g1");
assert.equal(out.published?.state, "ok");
assert.equal(out.published?.defVersion, 4);
assert.deepEqual(out.published?.rows.map((r) => r.userName), ["刘文娟"]);
});
test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => {
const api = stubApi({
applyPreview: async () => {
throw new Error("流程线已停发,不可发起新办件");
},
});
const out = await resolveNodePreview(cfg(), depts, api, "g1");
assert.equal(out.published?.state, "unavailable");
assert.ok(out.published?.text.includes("流程线已停发,不可发起新办件"));
});
test("会签/发起时指派口径进说明文案(分层后信息不丢)", async () => {
const out = await resolveNodePreview(
cfg({ countersign: "ALL", assignPolicy: { mode: "SINGLE", required: true } }),
depts,
stubApi(),
);
assert.ok(out.notes.some((n) => n.includes("会签(全部同意才通过)")));
assert.ok(out.notes.some((n) => n.includes("单选 1 人") && n.includes("必选")));
});
@@ -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);
});
});
@@ -0,0 +1,204 @@
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 } from "vue";
import FlowAssignEmbed from "@/components/FlowAssignEmbed.vue";
/**
* FL 侧选人 iframe 宿主的协议测试(自动化测试宿主,不是产品入口)。
* 覆盖:地址构造(单 hash)、四道校验、监听器生命周期、取消保留旧选择、确认后 B 替换 A。
*/
enableAutoUnmount(afterEach);
const PROTO = "flow";
const VERSION = 1;
const APP_ORIGIN = window.location.origin;
function baseData(over: Record<string, unknown> = {}) {
return {
proto: PROTO,
v: VERSION,
type: "assign-confirmed",
contextId: "",
flowGuid: "g-embed",
defVersion: 3,
assigns: [{ nodeId: "u2", userIds: [101] }],
...over,
};
}
/** view-ui-plus Modal 默认把内容 teleport 到 body,组件子树里查不到——按真实 DOM 取 */
function frameEl(): HTMLIFrameElement | null {
return document.querySelector("iframe") as HTMLIFrameElement | null;
}
/** 父页收到消息的唯一入口:直接派发 MessageEvent,绕开真实 iframe 加载 */
function post(wrapper: VueWrapper, data: unknown, opts: { origin?: string; source?: unknown } = {}) {
const frame = frameEl() as HTMLIFrameElement;
const ev = new MessageEvent("message", {
origin: opts.origin ?? APP_ORIGIN,
source: (opts.source ?? frame.contentWindow) as Window | null,
data,
});
window.dispatchEvent(ev);
return nextTick();
}
function mountHost(props: Record<string, unknown> = {}) {
return mount(FlowAssignEmbed, {
props: { flowGuid: "g-embed", ...props },
global: { plugins: [ViewUIPlus] },
attachTo: document.body,
});
}
/** 开框后 jsdom 仍不会真正加载子文档:给 iframe 挂一个显式窗口桩,父侧按它核 event.source */
async function openHost(wrapper: VueWrapper) {
(wrapper.vm as any).open();
await nextTick();
await flushPromises();
const frame = frameEl() as HTMLIFrameElement;
Object.defineProperty(frame, "contentWindow", { value: { __frame: true }, configurable: true });
}
beforeEach(() => {
document.body.innerHTML = "";
});
describe("FlowAssignEmbed 地址与会话", () => {
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || "";
expect(src.split("#").length - 1).toBe(1);
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain("contextId=");
expect((wrapper.vm as any).contextId).toBeTruthy();
});
it("缺少 flowGuid 时不开框、不监听", async () => {
const addSpy = vi.spyOn(window, "addEventListener");
const wrapper = mountHost({ flowGuid: "" });
(wrapper.vm as any).open();
await nextTick();
expect(frameEl()).toBeNull();
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
addSpy.mockRestore();
});
});
describe("FlowAssignEmbed 消息校验", () => {
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
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");
});
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");
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("确认消息缺 assigns 时形状校验拦下(不落成空选择)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
});
describe("FlowAssignEmbed 生命周期与选择语义", () => {
it("取消:只通知宿主,不产生确认,旧选择不被覆盖", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, type: "assign-cancelled" }));
expect(wrapper.emitted("cancelled")).toHaveLength(1);
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
it("确认后同一会话的迟到消息不再触发(监听器已摘)", async () => {
const wrapper = mountHost();
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] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
});
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
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();
});
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] }] }));
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);
await post(wrapper, baseData({ contextId: cid2, assigns: [{ nodeId: "u2", userIds: [102, 103] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(2);
expect((events[1][0] as any).assigns[0].userIds).toEqual([102, 103]);
});
it("卸载后监听器摘掉,后续消息不再回传", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
const frame = frameEl() as HTMLIFrameElement;
wrapper.unmount();
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
window.dispatchEvent(new MessageEvent("message", {
origin: APP_ORIGIN,
source: frame.contentWindow as Window,
data: baseData({ contextId: cid }),
}));
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
removeSpy.mockRestore();
});
});
@@ -205,7 +205,7 @@ describe("Defs 流程线停发/恢复交互", () => {
expect(wrapper.text()).toContain("处理中…");
expect(lineLink(wrapper, "正常线", "停发流程线")!.classes()).toContain("disabled");
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal");
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal", { silentBizToast: true });
gate.resolve(2);
await pending;
@@ -265,7 +265,7 @@ describe("Defs 流程线停发/恢复交互", () => {
await pending;
await flushPromises();
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off");
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off", { silentBizToast: true });
expect(flowApiMock.defOfflineLine).not.toHaveBeenCalled();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已恢复(1 个版本"));
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
+145
View File
@@ -0,0 +1,145 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { toContentJson, fromContentJson } from "../src/views/flow/admin/def-editor/convert";
import { createApproval, createCondition, createNotify, createParallel } from "../src/views/flow/admin/def-editor/model";
import type { EditorItem, EditorModel } from "../src/views/flow/admin/def-editor/model";
/**
* 流程定义序列化回归闸门(任务书 §6.8「检查流程定义序列化」)。
* 本轮只动编辑器面板的**展示分层**,这里钉住:结构语义不因这些改动漂移。
* 对比口径按语义集合比(节点字段集 + 边三元组集),不比数组顺序——空泳道会产生纯顺序差。
*/
type Json = ReturnType<typeof toContentJson>;
function model(items: EditorItem[]): EditorModel {
return {
code: "rt-demo",
name: "往返回归",
list: items,
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
};
}
const clone = (o: unknown) => JSON.parse(JSON.stringify(o));
function fullItems(): EditorItem[] {
const approval = createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" });
approval.countersign = "ALL";
approval.deadlineHours = 8;
approval.assignPolicy = { mode: "SINGLE", required: true };
const outletNode = createApproval("多出口", { type: "USERS", userIds: [101, 102] });
outletNode.outlets = ["同意", "退回", "加签"];
outletNode.branches = [[createApproval("分支一")], [], [createNotify("分支三抄送")]];
const cond = createCondition();
cond.judgeMode = "EXPRESSION";
cond.outlets = ["大额", "小额"];
cond.conditions = ["amount > 1000", ""];
cond.branches = [[createApproval("大额复核")], [createApproval("小额直过")]];
const par = createParallel();
par.branches = [[createNotify("并行抄送A")], [createApproval("并行复核B", { type: "DEPT_KEY", deptKey: "42" })]];
return [approval, cond, par, outletNode];
}
/** 已知缺陷用:非首位并行容器重解析后 split/join 会换成新 id(见最后一条用例) */
function parallelDerivedIds(json: Json): string[] {
return json.nodes.filter((n) => n.type === "PARALLEL_SPLIT" || n.type === "PARALLEL_JOIN").map((n) => n.id);
}
function roundTrip(times: number, json: Json): Json {
let cur = json;
for (let i = 0; i < times; i++) {
cur = toContentJson({ ...model([]), ...fromContentJson(clone(cur)) } as EditorModel);
}
return cur;
}
test("结构往返两轮:每个节点的配置语义逐项一致(并行派生 id 另用例专门记录)", () => {
const first = toContentJson(model(fullItems()));
const second = roundTrip(1, first);
const third = roundTrip(2, first);
const byId = (json: Json) => {
const m = new Map<string, string>();
json.nodes.forEach((n) => m.set(n.id, JSON.stringify(n)));
return m;
};
const a = byId(first);
const b = byId(second);
const c = byId(third);
// 配置类节点(审批/抄送/条件)的 id 与内容必须逐字不变
["u1", "u2", "u3", "u4", "u5c", "u8", "u9", "start", "end"].forEach((id) => {
assert.equal(b.get(id), a.get(id), `节点 ${id} 内容在第二轮发生变化`);
assert.equal(c.get(id), a.get(id), `节点 ${id} 内容在第三轮发生变化`);
});
assert.deepEqual(second.settings, first.settings);
assert.deepEqual(third.settings, first.settings);
// 并行分支内的两名办理人同样不变
const branchIds = ["u13", "u14"];
branchIds.forEach((id) => assert.equal(b.get(id), a.get(id), `并行分支节点 ${id} 变化`));
// 出口边(含条件表达式)在两轮都存在
const edgeSet = (json: Json) => new Set(json.edges.map((e) => `${e.from}->${e.to}${e.name ? `(${e.name})` : ""}${e.condition ? `[${e.condition}]` : ""}`));
const ea = edgeSet(first);
const eb = edgeSet(second);
["u2->u3(同意)", "u2->u4(加签)", "u2->end(退回)", "u5c->u8(大额)[amount > 1000]", "u5c->u9(小额)"].forEach((e) => {
assert.ok(ea.has(e), `第一轮缺少边 ${e}`);
assert.ok(eb.has(e), `第二轮缺少边 ${e}`);
});
});
test("关键语义字段全部活过往返,不被展示层分层影响", () => {
const first = toContentJson(model(fullItems()));
const parsed = fromContentJson(clone(first));
const second = toContentJson({ ...model([]), ...parsed } as EditorModel);
// 会签 ALL 是后端缺省值,生成 JSON 时按设计省略(model.ts 注释);口径以解析回编辑态为准
const approvalItem = parsed.list.find((it) => it.kind === "approval" && it.assignee?.type === "DEPT_KEY");
assert.ok(approvalItem && approvalItem.kind === "approval");
assert.equal(approvalItem.countersign, "ALL", "会签语义在解析端丢失");
assert.equal(approvalItem.deadlineHours, 8, "办理时限丢失");
assert.deepEqual(approvalItem.assignPolicy, { mode: "SINGLE", required: true }, "发起时指派策略丢失");
assert.ok(second.nodes.some((n) => n.type === "NOTIFY" && n.receivers?.type === "USERS"), "抄送对象丢失");
assert.ok(second.nodes.some((n) => n.judgeMode === "EXPRESSION"), "条件判断方式丢失");
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_SPLIT"), "并行拆分丢失");
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_JOIN" && n.waitFor === "ALL"), "并行汇聚语义丢失");
assert.ok(second.nodes.some((n) => n.id === "start") && second.nodes.some((n) => n.id === "end"), "端点丢失");
assert.ok(second.edges.filter((e) => e.name).length >= 3, "多出口/条件分支的出口名丢失");
});
test("设置与端点名不被丢:rejectPolicy/urge/start-end name 原样回写", () => {
const m = model([createApproval("单节点")]);
m.startName = "提交申请";
m.endName = "办结";
const json = toContentJson(m);
assert.equal(json.settings.rejectPolicy, "END_REJECTED");
assert.equal(json.settings.urge.intervalHours, 24);
assert.equal((json.nodes.find((n) => n.type === "START") || {}).name, "提交申请");
assert.equal((json.nodes.find((n) => n.type === "END") || {}).name, "办结");
});
test("已知缺陷(本轮不修,修它等于改序列化契约):并行容器重解析后 split/join 换成新 id", () => {
const first = toContentJson(model([createApproval("a"), createParallel()]));
const before = parallelDerivedIds(first);
const rt = roundTrip(1, first);
const after = parallelDerivedIds(rt);
assert.equal(after.length, before.length, "并行拆/合节点数量发生变化");
assert.notDeepEqual(after, before, "并行 id 改名行为已变化——若已被修好,请把本用例改成断言 id 稳定并同步终审");
// 改名只涉及 id 字面,汇聚语义不变
const join = rt.nodes.find((n) => n.type === "PARALLEL_JOIN");
assert.equal(rt.nodes.filter((n) => n.type === "PARALLEL_SPLIT").length, 1);
assert.equal(join?.waitFor, "ALL", "并行汇聚 waitFor 语义丢失");
// 拆分/汇聚与分支节点的连接关系仍然闭合(每个分支节点都连到某条 split/join)
const anchors = new Set(after);
const linked = rt.edges.filter((e) => anchors.has(e.from) || anchors.has(e.to));
assert.ok(linked.length >= 4, `并行连线丢失,只剩 ${linked.length} 条`);
});
+55
View File
@@ -0,0 +1,55 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { describeFlowError, flowFailTip, FLOW_GENERIC_FALLBACK, FLOW_NETWORK_FALLBACK } from "../src/utils/flowError";
/** 审批失败文案单测:口径=具体业务话原样透出,通用/异常形态换兜底话术且不外泄内部信息 */
test("后端给出通用 code=6 占位文案:换成不误导的兜底话术,不猜原因", () => {
assert.equal(describeFlowError("内部服务错误"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError("内部服务错误!"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError("业务错误"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(""), FLOW_GENERIC_FALLBACK);
assert.ok(!FLOW_GENERIC_FALLBACK.includes("成功"));
});
test("契约内的具体业务 msg 原样透出,不改写不误导", () => {
assert.equal(describeFlowError("流程线不存在:8d07f049"), "流程线不存在:8d07f049");
assert.equal(describeFlowError("该任务已被他人办理"), "该任务已被他人办理");
assert.equal(
describeFlowError("该待办缺少流程推进记录,需管理员回填后办理"),
"该待办缺少流程推进记录,需管理员回填后办理",
);
});
test("异常/SQL/令牌形态不回显:堆栈与凭据不进页面", () => {
const leaks = [
"java.lang.IllegalStateException: D09 task has no proven activation; audited backfill required",
"at com.yunhai.flowcenter.service.FlowTaskService.handle(FlowTaskService.java:100)",
"Caused by: SQLException error code [1064]",
"refresh token=Bearer abcdef123456",
"<html><body>500 Internal Server Error</body></html>",
];
for (const raw of leaks) {
const out = describeFlowError(raw);
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out));
}
});
test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => {
assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError("Failed to fetch"), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError("timeout of 20000ms exceeded"), FLOW_NETWORK_FALLBACK);
});
test("Error 对象可直接传入(取 message)", () => {
assert.equal(describeFlowError(new Error("流程线不存在:g1")), "流程线不存在:g1");
assert.equal(describeFlowError(new Error("内部服务错误")), FLOW_GENERIC_FALLBACK);
});
test("页面内联提示带动作名,明确「未生效」而不是成功", () => {
const tip = flowFailTip("同意", new Error("内部服务错误"));
assert.ok(tip.startsWith("同意未完成:"));
assert.ok(tip.includes(FLOW_GENERIC_FALLBACK));
assert.ok(!tip.includes("已同意"));
});
+12 -2
View File
@@ -142,6 +142,16 @@ test("静态接线:Defs.vue 使用线级接口,不混入 listAll 或重复
test("静态接线:API 客户端使用既有 admin 端点与 flowGuid 请求体", () => {
const api = readFileSync(resolve(process.cwd(), "src/api/module/flow.ts"), "utf8");
assert.match(api, /`\$\{ROOT\}\/admin\/def\/offlineLine`, \{ flowGuid \}/);
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}/);
assert.match(api, /`\$\{ROOT\}\/admin\/def\/offlineLine`, \{ flowGuid \}, config/);
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}, config/);
});
test("静态接线:线级启停失败只有一条错误面(调用方带 silentBizToast,全局 toast 认这个旗标)", () => {
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
const core = readFileSync(resolve(process.cwd(), "src/utils/alova/alova-core.ts"), "utf8");
assert.match(defs, /defOfflineLine\(guid, \{ silentBizToast: true \}\)/);
assert.match(defs, /defEnableLine\(guid, \{ silentBizToast: true \}\)/);
assert.match(core, /silentBizToast/);
assert.match(core, /if \(!isSilentBizToast\(method\)\) onBizError/);
assert.match(core, /if \(!isSilentBizToast\(method\)\) onHttpError/);
});