From d8ac455428cb041e0c58c53690f22a9be49faa5e Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 8 Sep 2026 14:52:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(W1):=20=E7=9A=AE=E8=82=A4=E5=8C=85?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5-=E5=8D=A1=E7=89=87=E7=BD=91?= =?UTF-8?q?=E6=A0=BC+=E4=B8=8A=E4=BC=A0/=E6=B4=BE=E7=94=9F/=E7=BC=96?= =?UTF-8?q?=E8=BE=91/=E5=88=A0=E9=99=A4,ThemeApi=E4=BA=94=E6=96=B9?= =?UTF-8?q?=E6=B3=95,=E8=B7=AF=E7=94=B1/=E5=85=A5=E5=8F=A3/mock=20modId=3D?= =?UTF-8?q?7,ThemeVo=E5=A2=9Esource/inUseCount/editable/usedBySiteNames,T-?= =?UTF-8?q?W1-21..24=20=E7=BB=BF56/56?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mock/index.ts | 1 + src/__tests__/themeManage.spec.ts | 174 ++++++++++++++++ src/api/module/cms.spec.ts | 41 +++- src/api/module/cms.ts | 29 +++ src/router/routes.ts | 8 + src/types/cms.ts | 8 + src/views/cms/SiteManage.vue | 10 + src/views/cms/ThemeManage.vue | 321 ++++++++++++++++++++++++++++++ 8 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/themeManage.spec.ts create mode 100644 src/views/cms/ThemeManage.vue diff --git a/mock/index.ts b/mock/index.ts index 3b9abba..3f96f2c 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -479,6 +479,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string mod(2106, 4, "友情链接", "/link"), mod(2107, 5, "附件管理", "/attachment"), mod(2108, 6, "站点管理", "/site"), + mod(2109, 7, "皮肤包管理", "/theme"), ])); return true; } diff --git a/src/__tests__/themeManage.spec.ts b/src/__tests__/themeManage.spec.ts new file mode 100644 index 0000000..d974a10 --- /dev/null +++ b/src/__tests__/themeManage.spec.ts @@ -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('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); + }); +}); diff --git a/src/api/module/cms.spec.ts b/src/api/module/cms.spec.ts index bcd3432..b4eecad 100644 --- a/src/api/module/cms.spec.ts +++ b/src/api/module/cms.spec.ts @@ -18,10 +18,12 @@ vi.mock("@/utils/alova/alova-iview", () => ({ alovaInstance: { Get: vi.fn(), Post: vi.fn(() => ({})), + Put: vi.fn(() => ({})), + Delete: vi.fn(() => ({})), }, })); -import { articleApi, assetUrl, themePreviewUrl } from "./cms"; +import { articleApi, assetUrl, themeApi, themePreviewUrl } from "./cms"; import { alovaInstance } from "@/utils/alova/alova-iview"; const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" }; @@ -100,6 +102,43 @@ describe("assetUrl 站内资产路径补 serviceHost 前缀(线上主题截图 }); }); +describe("themeApi W1 端点契约(T-W1-21)", () => { + const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" }; + + it("upload 走 multipart Post /api/theme/upload", () => { + vi.mocked(alovaInstance.Post).mockClear(); + const file = new File(["zip-bytes"], "pkg.zip", { type: "application/zip" }); + themeApi.upload(file); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/upload", expect.any(FormData)); + }); + + it("derive/updateMeta/remove/reload 的方法与路径", () => { + vi.mocked(alovaInstance.Post).mockClear(); + vi.mocked(alovaInstance.Put).mockClear(); + vi.mocked(alovaInstance.Delete).mockClear(); + + themeApi.derive("default", "my-skin"); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/derive", { source: "default", name: "my-skin" }, { + headers: JSON_HEADERS, + params: undefined, + }); + + themeApi.updateMeta("my-skin", { title: "我的皮肤" }); + expect(alovaInstance.Put).toHaveBeenCalledWith("/api/theme/my-skin/meta", { title: "我的皮肤" }, { + headers: JSON_HEADERS, + }); + + themeApi.remove("my-skin"); + expect(alovaInstance.Delete).toHaveBeenCalledWith("/api/theme/my-skin"); + + themeApi.reload(); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/theme/reload", undefined, { + headers: JSON_HEADERS, + params: undefined, + }); + }); +}); + describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => { it("query 携带 sitePath(嵌套路径 URL 编码)与 p_ 前缀参数", () => { const url = themePreviewUrl("sch-3/jdxy", "t-demo-red", { primary: "#b01a2e" }); diff --git a/src/api/module/cms.ts b/src/api/module/cms.ts index 20bae2e..cb58bce 100644 --- a/src/api/module/cms.ts +++ b/src/api/module/cms.ts @@ -212,6 +212,35 @@ class ThemeApi { list() { return get(`${THEME}/list`); } + + /** W1:上传合规 zip(multipart,超管;免重启立即注册) */ + upload(file: File) { + const fd = new FormData(); + fd.append("file", file); + return alovaInstance.Post(`${THEME}/upload`, fd); + } + + /** W1:从现有包派生新皮肤(复制包体+改名) */ + derive(source: string, name: string) { + return postJSON(`${THEME}/derive`, { source, name }); + } + + /** W1:编辑外部包 theme.json 的 title/参数 schema */ + updateMeta(name: string, data: { title?: string; params?: unknown[] }) { + return alovaInstance.Put(`${THEME}/${name}/meta`, data, { + headers: { "Content-Type": "application/json;charset=UTF-8" }, + }); + } + + /** W1:删除外部包(被用站点拒绝;内置不可删) */ + remove(name: string) { + return alovaInstance.Delete<{ ok: boolean }>(`${THEME}/${name}`); + } + + /** W1:热重载(运维直放目录后生效) */ + reload() { + return postJSON(`${THEME}/reload`); + } } /** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。 diff --git a/src/router/routes.ts b/src/router/routes.ts index e4faad4..5a95d9e 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -99,6 +99,13 @@ export const routes: RouteRecordRaw[] = [ component: () => import("@/views/cms/SiteManage.vue"), meta: { title: "站点管理", modId: 6 }, }, + { + // W1:皮肤包管理(平台模块表注册前由站点管理按钮兜底可达,R3) + path: "theme", + name: "ThemeManage", + component: () => import("@/views/cms/ThemeManage.vue"), + meta: { title: "皮肤包管理", modId: 7 }, + }, { // 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供) path: "portal-preview", @@ -116,6 +123,7 @@ export const routes: RouteRecordRaw[] = [ { path: "/link", redirect: legacyRedirect("link") }, { path: "/attachment", redirect: legacyRedirect("attachment") }, { path: "/site", redirect: legacyRedirect("site") }, + { path: "/theme", redirect: legacyRedirect("theme") }, { path: "/portal-preview", redirect: legacyRedirect("portal-preview") }, { path: "/:pathMatch(.*)*", diff --git a/src/types/cms.ts b/src/types/cms.ts index ec84a1c..2d9aec2 100644 --- a/src/types/cms.ts +++ b/src/types/cms.ts @@ -269,4 +269,12 @@ export interface ThemeVo { /** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */ screenshotUrl: string; params: ThemeParamVo[]; + /** 包来源(W1):builtin=内置只读 / external=外部可管 */ + source?: string; + /** 被用站点数(W1,删除保护口径与后端一致) */ + inUseCount?: number; + /** 是否可编辑/删除(W1:仅外部包) */ + editable?: boolean; + /** 引用站点名列表(W1:删除保护提示/tooltip) */ + usedBySiteNames?: string[]; } diff --git a/src/views/cms/SiteManage.vue b/src/views/cms/SiteManage.vue index 84f85e5..ba9cadc 100644 --- a/src/views/cms/SiteManage.vue +++ b/src/views/cms/SiteManage.vue @@ -182,6 +182,11 @@ function openWizard() { wizardVisible.value = true; } +/** W1:皮肤包管理页入口 */ +function goThemeManage() { + router.push(`/sch/${store.schoolCode}/theme`); +} + function onWizardCreated() { run(); } @@ -327,6 +332,11 @@ watch(() => store.currentSchool?.schId, () => run()); {{ isSchoolAdmin ? "新建院系子站" : "新建站点(向导)" }} + + diff --git a/src/views/cms/ThemeManage.vue b/src/views/cms/ThemeManage.vue new file mode 100644 index 0000000..5fcdf7b --- /dev/null +++ b/src/views/cms/ThemeManage.vue @@ -0,0 +1,321 @@ + + + + +