feat(theme): 皮肤包管理收敛平台级+命名表单-新顶层路由/theme-manage(meta.superOnly守卫+页面watch双兜底),ThemeManage升级全屏页复用新抽WorkbenchHeader(工作台同款深色顶栏backTo=/workbench),工作台超管工具行加皮肤包管理按钮,删SiteManage兜底按钮,旧/theme重定向新页;上传/添加皮肤包改表单弹窗(zip+显示名/描述;添加=源包+标识名+命名一步),内置包可改显示名/描述(无参数区+恢复默认名称);openEdit未注册主题保留原值并告警(themeEdit util,不再静默归一default);mock删modId=7菜单节点;SiteWizard预览内容源v-model对齐path语义(W4遗留);vitest 62/62+build通过
build / build (push) Successful in 2m17s

This commit is contained in:
chenxing
2026-09-08 20:25:57 +08:00
parent 604d77049b
commit 9ba4cb135a
12 changed files with 553 additions and 218 deletions
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { resolveEditableTheme } from "@/utils/themeEdit";
/**
* 任务三(2026-09-08):站点编辑弹窗主题归一纯函数。
* 旧行为把「不在皮肤包列表中的主题」静默归一 default,保存即悄悄改写站点换肤;
* 新行为保留原值交服务端拒绝,仅标记告警。
*/
describe("resolveEditableTheme 站点编辑主题归一(任务三)", () => {
it("注册中的主题原值保留且不告警", () => {
expect(resolveEditableTheme("mint-breeze", ["default", "mint-breeze"])).toEqual({
theme: "mint-breeze",
unregistered: false,
});
});
it("未注册主题保留原值并标记 unregistered(不再归一 default)", () => {
expect(resolveEditableTheme("ghost-theme", ["default", "mint-breeze"])).toEqual({
theme: "ghost-theme",
unregistered: true,
});
});
it("空主题归一 default(站点未选过皮肤的场景)", () => {
expect(resolveEditableTheme(undefined, ["default", "mint-breeze"])).toEqual({
theme: "default",
unregistered: false,
});
});
it("列表为空(加载失败)时保留原值且不误报,交服务端校验兜底", () => {
expect(resolveEditableTheme("mint-breeze", [])).toEqual({
theme: "mint-breeze",
unregistered: false,
});
});
});
+94 -25
View File
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
/**
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24):
* ① 内置卡无删除/编辑按钮(仅"预览");
* W1 皮肤包管理页组件行为(方案 §5.3 T-W1-22..24 + 任务二/四 2026-09-08):
* ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述);
* ② 被用外部包删除禁用且 tooltip 含站点名;
* ③ 上传成功刷新列表(list 被再次调用)+ Message.success。
* ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道;
* ④ 内置编辑弹窗无参数区、保存不带 params(后端强校验拒改);
* ⑤ 添加皮肤包表单标识名为空时拦截。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -11,26 +13,30 @@ import { createApp, defineComponent, h, nextTick } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus, { Message } from "view-ui-plus";
const { listMock, uploadMock, removeMock } = vi.hoisted(() => ({
const { listMock, uploadMock, removeMock, deriveMock, updateMetaMock } = vi.hoisted(() => ({
listMock: vi.fn(),
uploadMock: vi.fn(),
removeMock: vi.fn(),
deriveMock: vi.fn(),
updateMetaMock: vi.fn(),
}));
const themes = [
{
name: "default",
title: "云端默认",
description: "",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
source: "builtin",
inUseCount: 2,
editable: false,
editable: true,
usedBySiteNames: ["第一中学门户", "机电学院"],
},
{
name: "my-skin",
title: "我的皮肤",
description: "",
screenshotUrl: "/api/theme/my-skin/asset/screenshot.png",
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#b01a2e" }],
source: "external",
@@ -41,6 +47,7 @@ const themes = [
{
name: "free-skin",
title: "空闲皮肤",
description: "",
screenshotUrl: "/api/theme/free-skin/asset/screenshot.png",
params: [],
source: "external",
@@ -53,16 +60,22 @@ const themes = [
vi.mock("@/api/module/cms", () => ({
themeApi: {
list: () => ({ send: listMock }),
upload: (file: File) => {
uploadMock(file);
upload: (file: File, meta?: unknown) => {
uploadMock(file, meta);
return { send: async () => ({}) };
},
remove: (name: string) => {
removeMock(name);
return { send: async () => ({}) };
},
derive: () => ({ send: async () => ({}) }),
updateMeta: () => ({ send: async () => ({}) }),
derive: (source: string, name: string, meta?: unknown) => {
deriveMock(source, name, meta);
return { send: async () => ({}) };
},
updateMeta: (name: string, data: unknown) => {
updateMetaMock(name, data);
return { send: async () => ({}) };
},
reload: () => ({ send: async () => ({}) }),
},
siteApi: { list: () => ({ send: async () => [] }) },
@@ -101,6 +114,17 @@ function mountPage() {
return el;
}
function clickButtonByText(root: ParentNode, text: string): HTMLElement | undefined {
const btn = [...root.querySelectorAll("button")].find(b => b.textContent?.includes(text));
btn?.click();
return btn;
}
/** 页面有多个 Modal 常驻 DOM,按特征文案定位目标弹窗 */
function modalContaining(text: string): HTMLElement | undefined {
return [...document.querySelectorAll(".ivu-modal")].find(m => m.textContent?.includes(text));
}
beforeEach(() => {
vi.clearAllMocks();
listMock.mockResolvedValue(JSON.parse(JSON.stringify(themes)));
@@ -111,8 +135,8 @@ afterEach(() => {
cleanups = [];
});
describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
it("T-W1-22: 内置卡无删除/编辑按钮(仅'预览')", async () => {
describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
it("T-W1-22: 内置卡按钮为'预览+编辑'(任务四起内置可改元数据),无删除", async () => {
const el = mountPage();
await flush();
@@ -121,7 +145,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
const builtin = cards[0];
expect(builtin.textContent).toContain("内置");
const btns = [...builtin.querySelectorAll(".actions button")].map(b => b.textContent?.trim());
expect(btns).toEqual(["预览"]);
expect(btns).toEqual(["预览", "编辑"]);
});
it("T-W1-23: 被用外部包删除禁用且 tooltip 含站点名;空闲外部包可删", async () => {
@@ -144,31 +168,76 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24)", () => {
expect(freeDelete!.getAttribute("disabled")).toBeNull();
});
it("T-W1-24: 上传成功刷新列表(list 再次调用)+ Message.success", async () => {
it("T-W1-24: 上传表单弹窗——未选文件时上传禁用;选文件提交调用 upload 并刷新列表", 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();
clickButtonByText(el, "上传皮肤包");
await flush();
// 直接调用组件内通道的等价方式:Upload before-upload 经 ivu Upload 的 input change
const input = el.querySelector<HTMLInputElement>('input[type="file"]');
// 弹窗渲染到 body:未选文件时上传按钮禁用
const uploadModal = modalContaining("皮肤包 zip");
expect(uploadModal).toBeTruthy();
const submit = [...uploadModal!.querySelectorAll(".ivu-modal-footer button")].find(b =>
b.textContent?.includes("上传")
);
expect(submit).toBeTruthy();
expect(submit!.getAttribute("disabled")).not.toBeNull();
// 选择文件(Upload before-upload 通道)→ 按钮解锁 → 提交
const input = uploadModal!.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy();
const file = new File(["zip"], "pkg.zip", { type: "application/zip" });
Object.defineProperty(input!, "files", { value: [file] });
input!.dispatchEvent(new Event("change"));
await flush();
expect(document.body.textContent).toContain("已选择:pkg.zip");
expect(uploadMock).toHaveBeenCalled();
submit!.click();
await flush();
expect(uploadMock).toHaveBeenCalledTimes(1);
expect(uploadMock.mock.calls[0][0]).toBe(file);
expect(successSpy).toHaveBeenCalled();
expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("任务四: 内置包编辑弹窗无参数区,保存调用 updateMeta 不带 params", async () => {
const el = mountPage();
await flush();
const builtin = [...el.querySelectorAll(".card")][0];
clickButtonByText(builtin, "编辑");
await flush();
// 内置提示条存在、无 ColorPicker(参数 schema 区不渲染)
const metaModal = modalContaining("内置包内容(模板/资产)不可修改");
expect(metaModal).toBeTruthy();
expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改");
expect(metaModal!.querySelectorAll(".ivu-color-picker").length).toBe(0);
clickButtonByText(metaModal!, "保存");
await flush();
expect(updateMetaMock).toHaveBeenCalledWith(
"default",
expect.objectContaining({ title: "云端默认", params: undefined })
);
});
it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => {
const el = mountPage();
await flush();
const warnSpy = vi.spyOn(Message, "warning");
clickButtonByText(el, "添加皮肤包");
await flush();
const addModal = modalContaining("复制自");
expect(addModal).toBeTruthy();
clickButtonByText(addModal!, "添加");
await flush();
expect(warnSpy).toHaveBeenCalled();
expect(deriveMock).not.toHaveBeenCalled();
});
});