feat(W1): 皮肤包管理页-卡片网格+上传/派生/编辑/删除,ThemeApi五方法,路由/入口/mock modId=7,ThemeVo增source/inUseCount/editable/usedBySiteNames,T-W1-21..24 绿56/56
build / build (push) Successful in 2m16s

This commit is contained in:
chenxing
2026-09-08 15:14:10 +08:00
parent dedd4a6a3a
commit d8ac455428
8 changed files with 591 additions and 1 deletions
+174
View File
@@ -0,0 +1,174 @@
// @vitest-environment jsdom
/**
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24):
* ① 内置卡无删除/编辑按钮(仅"预览");
* ② 被用外部包删除禁用且 tooltip 含站点名;
* ③ 上传成功刷新列表(list 被再次调用)+ Message.success。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus, { Message } from "view-ui-plus";
const { listMock, uploadMock, removeMock } = vi.hoisted(() => ({
listMock: vi.fn(),
uploadMock: vi.fn(),
removeMock: vi.fn(),
}));
const themes = [
{
name: "default",
title: "云端默认",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
source: "builtin",
inUseCount: 2,
editable: false,
usedBySiteNames: ["第一中学门户", "机电学院"],
},
{
name: "my-skin",
title: "我的皮肤",
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
source: "external",
inUseCount: 1,
editable: true,
usedBySiteNames: ["删除保护门户"],
},
{
name: "free-skin",
title: "空闲皮肤",
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
params: [],
source: "external",
inUseCount: 0,
editable: true,
usedBySiteNames: [],
},
];
vi.mock("@/api/module/cms", () => ({
themeApi: {
list: () => ({ send: listMock }),
upload: (file: File) => {
uploadMock(file);
return { send: async () => ({}) };
},
remove: (name: string) => {
removeMock(name);
return { send: async () => ({}) };
},
derive: () => ({ send: async () => ({}) }),
updateMeta: () => ({ send: async () => ({}) }),
reload: () => ({ send: async () => ({}) }),
},
siteApi: { list: () => ({ send: async () => [] }) },
assetUrl: (u: string) => u,
}));
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
}));
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
import ThemeManage from "@/views/cms/ThemeManage.vue";
let cleanups: (() => void)[] = [];
async function flush(frames = 6) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
}
function mountPage() {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp(defineComponent({ render: () => h(ThemeManage) }));
app.use(createPinia());
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
});
return el;
}
beforeEach(() => {
vi.clearAllMocks();
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
});
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
});
describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
it("T-W1-22: 内置卡无删除/编辑按钮(仅'预览')", async () => {
const el = mountPage();
await flush();
const cards = [...el.querySelectorAll(".card")];
expect(cards).toHaveLength(3);
const builtin = cards[0];
expect(builtin.textContent).toContain("内置");
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
expect(btns).toEqual(["预览"]);
});
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
const el = mountPage();
await flush();
const cards = [...el.querySelectorAll(".card")];
const used = cards[1]; // my-skin,inUseCount=1
const usedDelete = [...used.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
expect(usedDelete).toBeTruthy();
expect(usedDelete!.getAttribute("disabled")).not.toBeNull();
// tooltip 内容含站点名(transfer 到 body)
usedDelete!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await flush();
expect(document.body.textContent).toContain("删除保护门户");
const free = cards[2];
const freeDelete = [...free.querySelectorAll(".actions button")].find(b => b.textContent?.includes("删除"));
expect(freeDelete!.getAttribute("disabled")).toBeNull();
});
it("T-W1-24: 上传成功刷新列表(list 再次调用)+ Message.success", async () => {
const el = mountPage();
await flush();
expect(listMock).toHaveBeenCalledTimes(1);
const successSpy = vi.spyOn(Message, "success");
// Upload 组件经 before-upload 手动上传:直接调内部通道(beforeUpload 闭包不可达,
// 经组件 props 不可得 → 以 uploadMock + list 重查语义等价驱动:触发 Upload 的 change?
// 简化:直接验证上传成功路径——页面暴露 beforeUpload 经 Upload 组件,stub 层以事件驱动)
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
// 通过 Upload 的 before-upload 触发:dispatch 到上传按钮内部 input 不稳定,
// 这里改为验证「上传成功 → Message.success + list 重查」的最小可观测路径:
(el as any).__themeUpload = (f: File) => f; // 占位,防误用
const uploadBtn = [...el.querySelectorAll(".toolbar button")].find(b => b.textContent?.includes("上传皮肤包"));
expect(uploadBtn).toBeTruthy();
// 直接调用组件内通道的等价方式:Upload before-upload 经 ivu Upload 的 input change
const input = el.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy();
Object.defineProperty(input!, "files", { value: [file] });
input!.dispatchEvent(new Event("change"));
await flush();
expect(uploadMock).toHaveBeenCalled();
expect(successSpy).toHaveBeenCalled();
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});