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
build / build (push) Successful in 2m17s
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user