release: FL 2.0 前端候选(流程线停发/发起页指派预览/测试夹具)
build / build (push) Successful in 1m33s

配套 service 候选 500a9a2:
- flow.ts 增加 admin/def/offlineLine、enableLine(T03 线级停发/恢复)
- Defs.vue 接线级停发开关与状态展示;lineOffline.ts 收敛判定与文案
- AssignPreview.vue + assignEmbed.ts 发起时指派预览(T02 契约)
- def-editor 表单坐标绑定微调
- 新增 vitest 组件测试与 line-offline 契约测试(npm test 可跑)
This commit is contained in:
chenxing
2026-10-27 12:07:40 +08:00
parent cdea9c559b
commit f2841e2740
13 changed files with 2517 additions and 171 deletions
@@ -0,0 +1,326 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
import ViewUIPlus, { Message } from "view-ui-plus";
import { nextTick } from "vue";
const { flowApiMock } = vi.hoisted(() => ({
flowApiMock: {
defList: vi.fn(),
defInflight: vi.fn(),
appList: vi.fn(),
defOfflineLine: vi.fn(),
defEnableLine: vi.fn(),
defPublish: vi.fn(),
defOffline: vi.fn(),
defEnable: vi.fn(),
defUpdateMeta: vi.fn(),
defVersions: vi.fn(),
},
}));
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
vi.mock("@/views/flow/components/SourceDrawer.vue", () => ({ default: { template: "<div />" } }));
import MyConfirm from "@/components/MyConfirm";
import Defs from "@/views/flow/admin/Defs.vue";
/** 组件级测试:真实挂载 Defs.vue(view-ui-plus Table/操作列),只 mock HTTP 客户端、路由与确认框。 */
const confirmMock = MyConfirm as unknown as Mock;
enableAutoUnmount(afterEach);
function ok<T>(value: T) {
return { send: async () => value };
}
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function baseRows() {
return [
{
id: 1, flowGuid: "g-normal", name: "正常线", code: "c1", version: 1,
status: 1, lineOffline: 0, hasPublished: true, publishTime: "2026-10-20T10:00:00",
},
{
id: 2, flowGuid: "g-off", name: "停发线", code: "c2", version: 1,
status: 1, lineOffline: 1, hasPublished: true, publishTime: "2026-10-20T10:00:00",
},
{
id: 3, flowGuid: "", name: "历史脏行", code: "c3", version: 1,
status: 0, lineOffline: 0, hasPublished: false, publishTime: null,
},
{
id: 4, flowGuid: "g-version-off", name: "版本停用线", code: "c4", version: 2,
status: 2, lineOffline: 0, hasPublished: false, publishTime: "2026-10-19T10:00:00",
},
];
}
function offlineRows() {
return baseRows().map((r) => (r.flowGuid === "g-normal" ? { ...r, lineOffline: 1 } : r));
}
async function mountDefs() {
flowApiMock.defList.mockImplementation(() => ok(baseRows()));
flowApiMock.defInflight.mockImplementation(() => ok({ "g-normal": 2 }));
flowApiMock.appList.mockImplementation(() => ok([]));
const wrapper = mount(Defs, {
global: { plugins: [ViewUIPlus] },
attachTo: document.body,
});
await flushPromises();
return wrapper;
}
function rowsNamed(wrapper: VueWrapper, name: string) {
return wrapper.findAll("tr.ivu-table-row").filter((r) => r.text().includes(name));
}
function lineLink(wrapper: VueWrapper, name: string, label: string) {
for (const row of rowsNamed(wrapper, name)) {
const link = row.findAll(".operating-link").find((l) => l.text() === label);
if (link) return link;
}
return undefined;
}
function lastConfirmOptions(): any {
expect(confirmMock).toHaveBeenCalled();
return confirmMock.mock.calls[confirmMock.mock.calls.length - 1][0];
}
let successSpy: Mock;
let warningSpy: Mock;
let errorSpy: Mock;
let infoSpy: Mock;
beforeEach(() => {
vi.clearAllMocks();
successSpy = vi.spyOn(Message, "success").mockImplementation((() => {}) as any);
warningSpy = vi.spyOn(Message, "warning").mockImplementation((() => {}) as any);
errorSpy = vi.spyOn(Message, "error").mockImplementation((() => {}) as any);
infoSpy = vi.spyOn(Message, "info").mockImplementation((() => {}) as any);
});
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("Defs 流程线状态展示(分组视图)", () => {
it("线状态与版本状态独立展示,入口随线状态切换", async () => {
const wrapper = await mountDefs();
expect(wrapper.text()).toContain("线状态");
const normal = rowsNamed(wrapper, "正常线")[0]?.text() || "";
expect(normal).toContain("正常");
expect(normal).toContain("已发布");
expect(normal).toContain("停发流程线");
expect(normal).not.toContain("恢复流程线");
const offline = rowsNamed(wrapper, "停发线")[0]?.text() || "";
expect(offline).toContain("已停发");
expect(offline).toContain("已发布");
expect(offline).toContain("恢复流程线");
expect(offline).not.toContain("停发流程线");
// 版本已停用但线正常:线状态不得被版本 status 带偏
const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || "";
expect(versionOff).toContain("正常");
expect(versionOff).toContain("停用");
expect(versionOff).toContain("停发流程线");
});
it("空 flowGuid 历史行显示 —,不出现任何线级操作入口", async () => {
const wrapper = await mountDefs();
const rows = rowsNamed(wrapper, "历史脏行");
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) {
expect(row.text()).toContain("—");
expect(row.text()).not.toContain("停发流程线");
expect(row.text()).not.toContain("恢复流程线");
}
expect(lineLink(wrapper, "历史脏行", "停发流程线")).toBeUndefined();
expect(lineLink(wrapper, "历史脏行", "恢复流程线")).toBeUndefined();
});
});
describe("Defs 流程线状态展示(平铺视图)", () => {
it("每个版本行展示共享线状态,线级入口与版本级停用并存", async () => {
const wrapper = await mountDefs();
const radios = wrapper.findAll("input[type=radio]");
expect(radios.length).toBeGreaterThan(1);
await radios[1].setValue(true);
await flushPromises();
expect(wrapper.find(".fix").text()).toContain("平铺所有版本行");
expect(rowsNamed(wrapper, "正常线")[0]?.text()).toContain("正常");
expect(rowsNamed(wrapper, "停发线")[0]?.text()).toContain("已停发");
const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || "";
expect(versionOff).toContain("正常");
expect(versionOff).toContain("停用");
// 平铺视图长操作集进入「更多」,菜单内容仍由同一 lineActions 过滤逻辑产生
const menuItems = Array.from(document.querySelectorAll(".ivu-dropdown-item"))
.map((e) => e.textContent || "");
expect(menuItems).toContain("停发流程线");
expect(menuItems).toContain("恢复流程线");
expect(menuItems).toContain("停用");
});
});
describe("Defs 流程线停发/恢复交互", () => {
it("停发:确认文案含在途数,请求期间显示处理中并禁用入口,成功后刷新列表与在途", async () => {
flowApiMock.defList
.mockImplementationOnce(() => ok(baseRows()))
.mockImplementationOnce(() => ok(offlineRows()));
const wrapper = await mountDefs();
const gate = deferred<number>();
flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise });
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
const opts = lastConfirmOptions();
expect(opts.title).toBe("停发流程线");
expect(opts.msg).toContain("正常线");
expect(opts.info).toContain("在途办件不受影响");
expect(opts.info).toContain("当前在途 2 件");
const pending = opts.onOk();
await nextTick();
await flushPromises();
expect(wrapper.text()).toContain("处理中…");
expect(lineLink(wrapper, "正常线", "停发流程线")!.classes()).toContain("disabled");
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal");
gate.resolve(2);
await pending;
await flushPromises();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(2 个版本"));
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
expect(flowApiMock.defInflight).toHaveBeenCalledTimes(2);
expect(wrapper.text()).toContain("已停发");
expect(lineLink(wrapper, "正常线", "恢复流程线")).toBeTruthy();
expect(wrapper.text()).not.toContain("处理中…");
});
it("在途期间重复确认/重复点击不会产生第二次请求", async () => {
const wrapper = await mountDefs();
const gate = deferred<number>();
flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise });
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
const opts = lastConfirmOptions();
const first = opts.onOk();
await nextTick();
// 入口已置灰(点击回调不再触发);再次确认也走 busy 守卫
const disabledLink = lineLink(wrapper, "正常线", "停发流程线")!;
expect(disabledLink.classes()).toContain("disabled");
await disabledLink.trigger("click");
expect(confirmMock).toHaveBeenCalledTimes(1);
await opts.onOk();
expect(flowApiMock.defOfflineLine).toHaveBeenCalledTimes(1);
expect(infoSpy).toHaveBeenCalledWith("该流程线操作正在处理中,请稍候");
gate.resolve(1);
await first;
await flushPromises();
});
it("恢复:已停发线只调 enableLine,成功后刷新并切换回停发入口", async () => {
flowApiMock.defList
.mockImplementationOnce(() => ok(baseRows()))
.mockImplementationOnce(() => ok(baseRows()));
const wrapper = await mountDefs();
const gate = deferred<number>();
flowApiMock.defEnableLine.mockReturnValue({ send: () => gate.promise });
await lineLink(wrapper, "停发线", "恢复流程线")!.trigger("click");
const opts = lastConfirmOptions();
expect(opts.title).toBe("恢复流程线");
expect(opts.info).toContain("仅恢复流程线可用性");
const pending = opts.onOk();
await nextTick();
expect(wrapper.text()).toContain("处理中…");
expect(lineLink(wrapper, "停发线", "恢复流程线")!.classes()).toContain("disabled");
gate.resolve(1);
await pending;
await flushPromises();
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off");
expect(flowApiMock.defOfflineLine).not.toHaveBeenCalled();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已恢复(1 个版本"));
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
});
it("接口失败/无权限/网络异常:明确报错,不刷新,不误报成功", async () => {
const cases = [
{ error: "无流程管理权限", expected: /无流程管理权限/ },
{ error: "网络错误", expected: /网络异常/ },
];
for (const c of cases) {
vi.clearAllMocks();
const wrapper = await mountDefs();
flowApiMock.defOfflineLine.mockReturnValue({
send: () => Promise.reject(new Error(c.error)),
});
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
await lastConfirmOptions().onOk();
await flushPromises();
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(c.expected));
expect(successSpy).not.toHaveBeenCalled();
expect(flowApiMock.defList).toHaveBeenCalledTimes(1);
expect(wrapper.text()).toContain("正常");
wrapper.unmount();
}
});
it("接口返回 0 行:提示状态未更新并要求刷新确认", async () => {
const wrapper = await mountDefs();
flowApiMock.defOfflineLine.mockReturnValue(ok(0));
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
await lastConfirmOptions().onOk();
await flushPromises();
expect(warningSpy).toHaveBeenCalledWith("未更新任何版本,流程线状态可能已变化,请刷新后确认");
expect(successSpy).not.toHaveBeenCalled();
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
});
it("操作成功但列表刷新失败:保留成功结果并明确提示刷新失败", async () => {
flowApiMock.defList
.mockImplementationOnce(() => ok(baseRows()))
.mockImplementationOnce(() => ({ send: async () => { throw new Error("网络异常"); } }));
const wrapper = await mountDefs();
flowApiMock.defOfflineLine.mockReturnValue(ok(1));
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
await lastConfirmOptions().onOk();
await flushPromises();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(1 个版本"));
expect(warningSpy).toHaveBeenCalledWith("操作已提交,但列表刷新失败,请手动刷新后确认最新状态");
expect(errorSpy).not.toHaveBeenCalled();
});
});
+31
View File
@@ -0,0 +1,31 @@
// jsdom 缺少浏览器布局/观察器能力:只补组件运行所需的最小桩,不替业务逻辑兜底。
if (!("ResizeObserver" in globalThis)) {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver = ResizeObserverStub;
}
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
})) as any;
}
if (!navigator.clipboard) {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async () => undefined },
});
}
+147
View File
@@ -0,0 +1,147 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { lineActionErrorDetail, runLineAction } from "../src/utils/lineOffline";
/** 线级启停纯逻辑单测:不发真实请求,边界=请求/刷新编排,不代表后端或真实 UI 已验证 */
test("停发成功:按 flowGuid 调一次接口,按返回行数刷新", async () => {
const calls: string[] = [];
let reloads = 0;
const out = await runLineAction({
flowGuid: " g1 ",
lineOffline: 0,
action: "offline",
request: async (guid) => { calls.push(guid); return 3; },
reload: async () => { reloads++; },
});
assert.deepEqual(calls, ["g1"]);
assert.equal(out.kind, "updated");
assert.equal(out.affected, 3);
assert.equal(out.reloadFailed, false);
assert.equal(reloads, 1);
});
test("恢复成功:已停发线才发恢复请求", async () => {
let requested = "";
let reloads = 0;
const out = await runLineAction({
flowGuid: "g2",
lineOffline: 1,
action: "enable",
request: async (guid) => { requested = guid; return 2; },
reload: async () => { reloads++; },
});
assert.equal(requested, "g2");
assert.equal(out.kind, "updated");
assert.equal(out.affected, 2);
assert.equal(reloads, 1);
});
test("重复停发被前端状态守卫拦下:不发请求、不刷新", async () => {
let requests = 0;
let reloads = 0;
const out = await runLineAction({
flowGuid: "g3",
lineOffline: 1,
action: "offline",
request: async () => { requests++; return 1; },
reload: async () => { reloads++; },
});
assert.equal(out.kind, "skipped");
assert.equal(out.reason, "already-offline");
assert.equal(requests, 0);
assert.equal(reloads, 0);
});
test("恢复一条未停发的线被拦下:不发请求、不刷新", async () => {
let requests = 0;
const out = await runLineAction({
flowGuid: "g4",
lineOffline: 0,
action: "enable",
request: async () => { requests++; return 1; },
reload: async () => {},
});
assert.equal(out.kind, "skipped");
assert.equal(out.reason, "not-offline");
assert.equal(requests, 0);
});
test("空 flowGuid 不发请求(历史脏行防误操作整线)", async () => {
let requests = 0;
const out = await runLineAction({
flowGuid: "",
lineOffline: 0,
action: "offline",
request: async () => { requests++; return 1; },
reload: async () => {},
});
assert.equal(out.kind, "skipped");
assert.equal(out.reason, "no-guid");
assert.equal(requests, 0);
});
test("接口返回 0 行:报 empty 但仍刷新列表以同步真实状态", async () => {
let reloads = 0;
const out = await runLineAction({
flowGuid: "g5",
lineOffline: 0,
action: "offline",
request: async () => 0,
reload: async () => { reloads++; },
});
assert.equal(out.kind, "empty");
assert.equal(out.affected, 0);
assert.equal(reloads, 1);
});
test("接口失败向上抛出,且不触发刷新", async () => {
let reloads = 0;
await assert.rejects(() => runLineAction({
flowGuid: "g6",
lineOffline: 0,
action: "offline",
request: async () => { throw new Error("无流程管理权限"); },
reload: async () => { reloads++; },
}), /无流程管理权限/);
assert.equal(reloads, 0);
});
test("操作成功但刷新失败:保留成功结果并标记 reloadFailed", async () => {
const out = await runLineAction({
flowGuid: "g7",
lineOffline: 1,
action: "enable",
request: async () => 1,
reload: async () => { throw new Error("网络异常"); },
});
assert.equal(out.kind, "updated");
assert.equal(out.affected, 1);
assert.equal(out.reloadFailed, true);
});
test("错误文案:权限/网络异常与后端业务错误分开提示", () => {
assert.match(lineActionErrorDetail("网络错误"), /权限/);
assert.match(lineActionErrorDetail("Failed to fetch"), /网络异常/);
assert.match(lineActionErrorDetail(""), /网络异常/);
assert.equal(lineActionErrorDetail("流程线不存在:g1"), "流程线不存在:g1");
});
test("静态接线:Defs.vue 使用线级接口,不混入 listAll 或重复查询", () => {
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
assert.match(defs, /defOfflineLine/);
assert.match(defs, /defEnableLine/);
assert.match(defs, /lineOffline/);
assert.match(defs, /停发流程线/);
assert.match(defs, /恢复流程线/);
assert.match(defs, /versionActions/);
assert.doesNotMatch(defs, /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 \}/);
});