diff --git a/mock/index.ts b/mock/index.ts index 3f96f2c..39b8943 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -479,7 +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"), + // 「皮肤包管理」已收敛为平台级页面 /theme-manage(超管工作台按钮进入),不再挂左侧菜单 ])); return true; } diff --git a/src/__tests__/themeEdit.spec.ts b/src/__tests__/themeEdit.spec.ts new file mode 100644 index 0000000..89236cf --- /dev/null +++ b/src/__tests__/themeEdit.spec.ts @@ -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, + }); + }); +}); diff --git a/src/__tests__/themeManage.spec.ts b/src/__tests__/themeManage.spec.ts index d974a10..04febc2 100644 --- a/src/__tests__/themeManage.spec.ts +++ b/src/__tests__/themeManage.spec.ts @@ -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('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('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(); + }); }); diff --git a/src/api/module/cms.ts b/src/api/module/cms.ts index cb58bce..36588fc 100644 --- a/src/api/module/cms.ts +++ b/src/api/module/cms.ts @@ -213,20 +213,26 @@ class ThemeApi { return get(`${THEME}/list`); } - /** W1:上传合规 zip(multipart,超管;免重启立即注册) */ - upload(file: File) { + /** W1:上传合规 zip(multipart,超管;免重启立即注册)。 + * 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */ + upload(file: File, meta?: { title?: string; description?: string }) { const fd = new FormData(); fd.append("file", file); + if (meta?.title) fd.append("title", meta.title); + if (meta?.description !== undefined && meta.description !== null) { + fd.append("description", meta.description); + } return alovaInstance.Post(`${THEME}/upload`, fd); } - /** W1:从现有包派生新皮肤(复制包体+改名) */ - derive(source: string, name: string) { - return postJSON(`${THEME}/derive`, { source, name }); + /** W1:从现有包派生新皮肤(复制包体+改名);任务四支持同步命名(title/description 选填) */ + derive(source: string, name: string, meta?: { title?: string; description?: string }) { + return postJSON(`${THEME}/derive`, { source, name, ...meta }); } - /** W1:编辑外部包 theme.json 的 title/参数 schema */ - updateMeta(name: string, data: { title?: string; params?: unknown[] }) { + /** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema), + * 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认) */ + updateMeta(name: string, data: { title?: string; description?: string; params?: unknown[] }) { return alovaInstance.Put(`${THEME}/${name}/meta`, data, { headers: { "Content-Type": "application/json;charset=UTF-8" }, }); diff --git a/src/components/WorkbenchHeader.vue b/src/components/WorkbenchHeader.vue new file mode 100644 index 0000000..ae5c1b9 --- /dev/null +++ b/src/components/WorkbenchHeader.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index c7754d6..b7ca02d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -8,17 +8,20 @@ const router = createRouter({ }); /** - * 学校上下文守卫(docs/09 §3.3): + * 路由守卫(docs/09 §3.3 + 任务二超管收敛): + * - meta.superOnly 页面仅超管可入(皮肤包管理),非超管回工作台;启动引导期(user 未装载)放行由页面兜底; * - /sch/:schCode 要求该主站编码在当前用户可管理站点内; * - 深链直达且上下文未对准时,按 user.sites 自动恢复学校/站点上下文; - * - 启动引导期(App.vue 未装配 user)放行,由 SchoolLayout 数据就绪后兜底校验; * - 校级用户不可管理的学校一律回工作台。 */ router.beforeEach(to => { + const store = useCmsStore(); + if (to.meta.superOnly && store.user && !store.isSuperAdmin) { + return "/workbench"; + } if (!to.path.startsWith("/sch/")) { return true; } - const store = useCmsStore(); const schCode = to.params.schCode as string; if (!schCode) { return "/workbench"; diff --git a/src/router/routes.ts b/src/router/routes.ts index 5a95d9e..5ee89b9 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -42,6 +42,13 @@ export const routes: RouteRecordRaw[] = [ component: () => import("@/views/cms/SchoolWorkbench.vue"), meta: { title: "学校工作台" }, }, + { + // 平台级:皮肤包管理(任务二收敛:仅超管,工作台按钮进入,不挂左侧菜单) + path: "/theme-manage", + name: "ThemePackageManage", + component: () => import("@/views/cms/ThemeManage.vue"), + meta: { title: "皮肤包管理", superOnly: true }, + }, { // 二级:学校专属管理壳,:schCode = 主站编码(URL 即学校上下文) // (懒加载引入,避免 routes → SchoolLayout → menuRoute → routes 循环依赖) @@ -99,13 +106,6 @@ 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", @@ -123,7 +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: "/theme", redirect: "/theme-manage" }, { path: "/portal-preview", redirect: legacyRedirect("portal-preview") }, { path: "/:pathMatch(.*)*", diff --git a/src/utils/themeEdit.ts b/src/utils/themeEdit.ts new file mode 100644 index 0000000..8d9c2ae --- /dev/null +++ b/src/utils/themeEdit.ts @@ -0,0 +1,21 @@ +/** + * 站点编辑弹窗的主题归一(任务三,2026-09-08): + * 旧行为把「不在皮肤包列表中的主题」静默归一为 default,一保存就悄悄改写站点的换肤结果 + * (皮肤包列表加载失败、或站点现用外部包未注册时必现,表现为"改了没变/被改回默认")。 + * 新行为:保留原值交服务端注册表校验(保存未注册主题会被明确拒绝),仅提示告警。 + */ +export interface EditableTheme { + /** 编辑表单主题值(未注册时保留原值,不再偷偷归一 default) */ + theme: string; + /** 主题未在注册列表中(保存将被服务端拒绝,需提示用户改选或先上传) */ + unregistered: boolean; +} + +export function resolveEditableTheme(rowTheme: string | undefined, themeNames: string[]): EditableTheme { + const value = rowTheme || "default"; + if (!themeNames.length) { + // 列表加载失败/为空:无法判定注册状态,保留原值且不误报(由服务端校验兜底) + return { theme: value, unregistered: false }; + } + return { theme: value, unregistered: !themeNames.includes(value) }; +} diff --git a/src/views/cms/SchoolWorkbench.vue b/src/views/cms/SchoolWorkbench.vue index a14b599..77073bc 100644 --- a/src/views/cms/SchoolWorkbench.vue +++ b/src/views/cms/SchoolWorkbench.vue @@ -8,6 +8,7 @@ import config from "@/assets/config/host.config"; import { SchoolCard, SchoolOption, ThemeVo } from "@/types/cms"; import { portalUrlOf } from "@/utils/sitePath"; import ThemePicker from "@/components/theme/ThemePicker.vue"; +import WorkbenchHeader from "@/components/WorkbenchHeader.vue"; /** * 学校工作台(一级页,docs/09 §3.3 方案A):全屏无壳。 @@ -23,10 +24,6 @@ const loading = ref(false); const themes = ref([]); const isSuper = computed(() => store.isSuperAdmin); -const userInfo = computed(() => ({ - name: store.session.baseUserName || "管理员", - avatar: store.session.baseUserAvatar, -})); async function run() { loading.value = true; @@ -74,6 +71,11 @@ function goAllSites() { } } +/** 超管平台级皮肤包管理(不再挂左侧菜单,工作台直达) */ +function goThemeManage() { + router.push("/theme-manage"); +} + /* ===== 添加学校(超管):绑定已有学校 + 初始化主站 ===== */ const modalVisible = ref(false); const saving = ref(false); @@ -131,7 +133,9 @@ function onSchoolPick(id: number) { form.value.schName = opt?.name || ""; if (opt) { form.value.name = `${opt.name}门户网站`; - form.value.code = `sch-${opt.id}`; + // R1 学校主站编码规则:1 + 学校ID左补零至4位(总长5位,如 id=13 → 10013); + // id≥10000 时 LPAD 不截断、编码自然超5位(当前学校量级不会触达) + form.value.code = `1${String(opt.id).padStart(4, "0")}`; } } @@ -159,30 +163,12 @@ async function create() { } } -const linkUserInfo = () => { - window.location.href = config.baseHost + "/v/userinfo"; -}; -const logout = () => { - const url = "/cas/logout?service=" + window.location.href; - window.location.href = config.ssoHost + url; -}; - onMounted(run);