From 34733ec2800d4a56b2933961bc8accaea3c01204 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 10 Sep 2026 15:09:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(cms):=20docs/16=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=95=B4=E9=A1=B5=E5=8C=96+=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=B8=83=E5=B1=80=E7=BC=96=E8=BE=91=E5=99=A8-=20SiteS?= =?UTF-8?q?etting.vue=E6=95=B4=E9=A1=B57=E5=88=86=E5=8C=BA(=E5=9F=BA?= =?UTF-8?q?=E6=9C=AC=E4=BF=A1=E6=81=AF/=E7=9A=AE=E8=82=A4=E4=B8=8E?= =?UTF-8?q?=E5=A4=96=E8=A7=82/=E9=A6=96=E9=A1=B5=E5=B8=83=E5=B1=80/?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=B1=95=E7=A4=BA/=E9=A1=B5=E8=84=9A?= =?UTF-8?q?=E4=BF=A1=E6=81=AF/SEO=E4=B8=8E=E5=9F=9F=E5=90=8D/=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=8F=82=E6=95=B0)=E9=94=9A=E7=82=B9=E5=AF=BC?= =?UTF-8?q?=E8=88=AAsticky+IntersectionObserver+=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=89=E6=AE=B5=E5=BC=8F=E6=AF=8F=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E7=8B=AC=E7=AB=8B=E4=BF=9D=E5=AD=98(configJson?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E4=BA=8B=E5=AE=9E=E6=BA=90+submitSection?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8F=90=E4=BA=A4);=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=BB=85=E2=91=A0=E2=91=A1=E5=8F=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=85=B6=E4=BD=99=E9=94=81=E5=AE=9A,=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=88=90=E5=8A=9F=E5=8E=9F=E5=9C=B0replace=E5=88=B0si?= =?UTF-8?q?te/setting/:id;SiteWizard=E4=B8=89=E6=AD=A5=E5=90=91=E5=AF=BC?= =?UTF-8?q?=E9=80=80=E5=BD=B9=E5=88=A0=E9=99=A4(=E9=97=A8=E6=88=B7?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=94=B6=E7=BC=96portalPreviewUrl);SiteManag?= =?UTF-8?q?e=E7=98=A6=E8=BA=AB=E8=A1=A8=E6=A0=BC+=E8=A1=8C=E5=86=85?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E8=B7=B3=E6=95=B4=E9=A1=B5(adminPathOf?= =?UTF-8?q?=E5=AD=90=E7=AB=99=E5=B8=A6=E7=AB=99=E7=82=B9=E6=AE=B5);ImageAs?= =?UTF-8?q?setField=E5=9B=BE=E7=89=87=E5=8F=8C=E9=80=9A=E9=81=93(=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0+=E8=B7=AF=E5=BE=84=E7=B2=98=E8=B4=B4=E6=8A=98?= =?UTF-8?q?=E5=8F=A0);SiteConfigForm=E6=94=AF=E6=8C=81groups/showExtras/si?= =?UTF-8?q?teId+asset=E5=AD=97=E6=AE=B5=E6=8E=A5ImageAssetField+=E4=B8=89?= =?UTF-8?q?=E6=97=A7=E9=94=AE=E8=A1=A8=E5=8D=95=E4=B8=8D=E5=86=8D=E5=B1=95?= =?UTF-8?q?=E7=A4=BA(ABSORBED=5FLEGACY=5FKEYS=E9=9D=99=E9=BB=98=E4=BF=9D?= =?UTF-8?q?=E7=95=99);homeLayout=E4=B8=89=E4=BB=B6=E5=A5=97(homeLayout.ts?= =?UTF-8?q?=E6=A8=A1=E5=9E=8Bparse/serialize/validate/genFromCategories?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E7=94=9F=E6=88=90+blockTypes.ts=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8showIf=E6=9D=A1=E4=BB=B6=E5=AD=97=E6=AE=B5+Ho?= =?UTF-8?q?meLayoutEditor.vue=E5=BA=8F=E5=88=97=E7=BC=96=E8=BE=91=E5=A2=9E?= =?UTF-8?q?=E5=88=A0=E6=98=BE=E9=9A=90=E4=B8=8A=E7=A7=BB=E4=B8=8B=E7=A7=BB?= =?UTF-8?q?);routes=20site/new+site/setting/:id+legacy=20redirect;Category?= =?UTF-8?q?Manage=20home=5Fstyle=E6=8F=90=E7=A4=BA=E6=94=B9=E5=8F=A3?= =?UTF-8?q?=E6=8C=87=E5=90=91=E9=A6=96=E9=A1=B5=E5=B8=83=E5=B1=80;?= =?UTF-8?q?=E6=96=B0=E5=A2=9EsiteSetting.spec+homeLayout.spec,=E9=87=8D?= =?UTF-8?q?=E5=86=99siteManage.spec,117=E7=94=A8=E4=BE=8B=E5=85=A8?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/siteManage.spec.ts | 89 +-- src/__tests__/siteSetting.spec.ts | 249 ++++++ src/__tests__/siteWizardUrl.spec.ts | 160 ---- src/components/ImageAssetField.vue | 152 ++++ src/router/routes.ts | 16 + src/utils/siteConfigForm.spec.ts | 16 +- src/utils/siteConfigForm.ts | 28 +- src/views/cms/CategoryManage.vue | 2 +- src/views/cms/SiteManage.vue | 293 +------ src/views/cms/SiteSetting.vue | 724 ++++++++++++++++++ src/views/cms/SiteWizard.vue | 314 -------- src/views/cms/components/SiteConfigForm.vue | 75 +- src/views/cms/homeLayout/HomeLayoutEditor.vue | 506 ++++++++++++ src/views/cms/homeLayout/blockTypes.ts | 130 ++++ src/views/cms/homeLayout/homeLayout.spec.ts | 171 +++++ src/views/cms/homeLayout/homeLayout.ts | 314 ++++++++ 16 files changed, 2399 insertions(+), 840 deletions(-) create mode 100644 src/__tests__/siteSetting.spec.ts delete mode 100644 src/__tests__/siteWizardUrl.spec.ts create mode 100644 src/components/ImageAssetField.vue create mode 100644 src/views/cms/SiteSetting.vue delete mode 100644 src/views/cms/SiteWizard.vue create mode 100644 src/views/cms/homeLayout/HomeLayoutEditor.vue create mode 100644 src/views/cms/homeLayout/blockTypes.ts create mode 100644 src/views/cms/homeLayout/homeLayout.spec.ts create mode 100644 src/views/cms/homeLayout/homeLayout.ts diff --git a/src/__tests__/siteManage.spec.ts b/src/__tests__/siteManage.spec.ts index e10c81b..0566f22 100644 --- a/src/__tests__/siteManage.spec.ts +++ b/src/__tests__/siteManage.spec.ts @@ -1,10 +1,8 @@ // @vitest-environment jsdom /** - * 站点管理页回归(2026-09-10 修复批次): - * ① 行「编辑」打开编辑弹窗——修复 themeUnregistered ref 声明缺失导致 openEdit 抛 - * ReferenceError、弹窗无法打开(编辑功能整体失效)的回归; - * ② 已注册皮肤不出现"未注册"告警;未注册皮肤保留原值并出现告警(任务三行为)。 - * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 themeManage.spec.ts 一致)。 + * 站点管理列表页回归(docs/16 主线A 整页化后): + * 行「编辑」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式; + * 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createApp, defineComponent, h, nextTick } from "vue"; @@ -15,9 +13,10 @@ import ViewUIPlus, { Tag } from "view-ui-plus"; // vitest 管线不走该插件)——这里挂到全局作用域供其解析 (globalThis as any).Tag = Tag; -const { siteListMock, themeListMock } = vi.hoisted(() => ({ +const { siteListMock, themeListMock, routerPushMock } = vi.hoisted(() => ({ siteListMock: vi.fn(), themeListMock: vi.fn(), + routerPushMock: vi.fn(), })); const siteRow = { @@ -38,7 +37,7 @@ const themeRow = { title: "高校蓝(默认)", description: "", screenshotUrl: "/api/theme/default/asset/screenshot.png", - params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }], + params: [], source: "builtin", inUseCount: 1, editable: true, @@ -52,27 +51,12 @@ vi.mock("@/api/module/cms", () => ({ })); vi.mock("vue-router", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useRouter: () => ({ push: routerPushMock, replace: vi.fn() }), useRoute: () => ({ params: {}, query: {} }), })); -vi.mock("@/components/theme/ThemePicker.vue", () => ({ - default: defineComponent({ props: ["themes", "modelValue"], render: () => h("div", "theme-picker-stub") }), -})); -vi.mock("@/components/theme/ThemeParamForm.vue", () => ({ - default: defineComponent({ props: ["theme", "modelValue"], render: () => h("div", "theme-param-stub") }), -})); -vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ - default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }), -})); -vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ - default: defineComponent({ props: ["modelValue"], render: () => h("div", "config-form-stub") }), -})); -vi.mock("@/views/cms/SiteWizard.vue", () => ({ - default: defineComponent({ props: ["visible", "sites", "lockParentId"], emits: ["created"], render: () => h("div", "wizard-stub") }), -})); - import SiteManage from "@/views/cms/SiteManage.vue"; +import { useCmsStore } from "@/store"; let cleanups: (() => void)[] = []; @@ -83,11 +67,15 @@ async function flush(frames = 6) { await new Promise(r => setTimeout(r, 0)); } -function mountPage() { +function mountPage(storeSetup?: (store: any) => void) { const el = document.createElement("div"); document.body.appendChild(el); + const pinia = createPinia(); + if (storeSetup) { + storeSetup(useCmsStore(pinia)); + } const app = createApp(defineComponent({ render: () => h(SiteManage) })); - app.use(createPinia()); + app.use(pinia); app.use(ViewUIPlus); app.mount(el); cleanups.push(() => { @@ -98,17 +86,13 @@ function mountPage() { return el; } -/** 操作列图标按钮(iView 固定列克隆两份,取第一个命中的) */ -function clickRowAction(el: ParentNode, iconClass: string) { - const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][0]; +/** 操作列图标按钮(iView 固定列克隆两份;按行序取第 rowIndex+1 个命中) */ +function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) { + const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][rowIndex]; btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); return btn; } -function modalByTitle(title: string): HTMLElement | undefined { - return [...document.querySelectorAll(".ivu-modal")].find(m => m.querySelector(".ivu-modal-header-inner")?.textContent === title); -} - beforeEach(() => { vi.clearAllMocks(); siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow]))); @@ -120,8 +104,8 @@ afterEach(() => { cleanups = []; }); -describe("SiteManage 站点管理(2026-09-10 修复回归)", () => { - it("① 行「编辑」打开编辑弹窗并回填数据(不再因 themeUnregistered 未声明而失效)", async () => { +describe("SiteManage 列表页(docs/16 整页化后)", () => { + it("① 行「编辑」跳站点设置中心(主站形态无站点段)", async () => { const el = mountPage(); await flush(); @@ -129,34 +113,33 @@ describe("SiteManage 站点管理(2026-09-10 修复回归)", () => { expect(btn).toBeTruthy(); await flush(); - const modal = modalByTitle("编辑站点"); - expect(modal).toBeTruthy(); - const codeInput = modal!.querySelector(".ivu-input"); - expect(codeInput?.value).toBe("demo"); + expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1"); }); - it("② 已注册皮肤无告警", async () => { + it("② 子站行「编辑」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => { + siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([ + siteRow, + { ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" }, + ]))); const el = mountPage(); await flush(); - // 已注册:default 在皮肤列表中 → 不出现告警 - clickRowAction(el, "icon-edit"); + clickRowAction(el, "icon-edit", 1); await flush(); - const modal = modalByTitle("编辑站点"); - expect(modal?.textContent).not.toContain("未在注册表中"); + + expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/jdxy/site/setting/2"); }); - it("③ 未注册皮肤保留原值且弹出告警(不静默归一 default)", async () => { - siteListMock.mockResolvedValue( - JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }])) - ); - const el = mountPage(); + it("③「新建站点」按钮跳创建模式(site/new;超管无学校上下文回落首主站编码)", async () => { + const el = mountPage(store => { + store.user = { superAdmin: true } as any; + }); await flush(); - clickRowAction(el, "icon-edit"); + const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建站点")); + btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); await flush(); - const modal = modalByTitle("编辑站点"); - expect(modal?.textContent).toContain("未在注册表中"); - expect(modal?.textContent).toContain("skin-x"); + + expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new"); }); }); diff --git a/src/__tests__/siteSetting.spec.ts b/src/__tests__/siteSetting.spec.ts new file mode 100644 index 0000000..8f37d4e --- /dev/null +++ b/src/__tests__/siteSetting.spec.ts @@ -0,0 +1,249 @@ +// @vitest-environment jsdom +/** + * 站点设置中心(docs/16 主线A)挂载回归: + * ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、 + * 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编); + * ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区); + * ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。 + * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick } from "vue"; +import { createPinia } from "pinia"; +import ViewUIPlus from "view-ui-plus"; + +Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {}); + +const routeMock = vi.hoisted(() => ({ params: {} as Record, query: {} as Record })); +const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() })); + +const mocks = vi.hoisted(() => ({ + siteList: vi.fn(), + siteSave: vi.fn(), + themeList: vi.fn(), +})); + +const siteRow = { + id: 1, + code: "demo", + path: "demo", + parentSiteId: 0, + schId: 1001, + schName: "云海大学", + name: "云海大学门户网站", + theme: "default", + configJson: '{"logo":"/cms/upload/logo.png","gallery_title":"线上展厅","home_layout":{"version":1,"blocks":[{"id":"b1","type":"links"}]}}', + status: 1, +}; + +const themeRow = { + name: "default", + title: "高校蓝(默认)", + screenshotUrl: "/api/theme/default/asset/screenshot.png", + params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }], +}; + +vi.mock("@/api/module/cms", () => ({ + siteApi: { + list: () => ({ send: mocks.siteList }), + save: (data: any) => ({ send: () => mocks.siteSave(data) }), + }, + themeApi: { list: () => ({ send: mocks.themeList }) }, + assetUrl: (u: string) => u, + uploadUrl: () => "/api/attachment/upload", +})); + +vi.mock("vue-router", () => ({ + useRouter: () => routerMock, + useRoute: () => routeMock, +})); + +vi.mock("@/components/theme/ThemePicker.vue", () => ({ + default: defineComponent({ props: ["themes", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-picker-stub") }), +})); +vi.mock("@/components/theme/ThemeParamForm.vue", () => ({ + default: defineComponent({ props: ["theme", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-param-stub") }), +})); +vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ + default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }), +})); +vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ + default: defineComponent({ + props: ["modelValue", "groups", "showExtras", "siteId"], + methods: { validate: async () => true }, + render: () => h("div", "config-form-stub"), + }), +})); +vi.mock("@/views/cms/homeLayout/HomeLayoutEditor.vue", () => ({ + default: defineComponent({ + props: ["modelValue", "siteId"], + emits: ["update:modelValue", "save"], + methods: { validate: () => null }, + render: () => h("div", "layout-editor-stub"), + }), +})); + +import SiteSetting from "@/views/cms/SiteSetting.vue"; +import { useCmsStore } from "@/store"; + +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(storeSetup?: (store: any) => void) { + const el = document.createElement("div"); + document.body.appendChild(el); + const pinia = createPinia(); + if (storeSetup) { + // 挂载前把上下文注入同一 pinia(defineStore 幂等,组件内拿到同一实例) + storeSetup(useCmsStore(pinia)); + } + const app = createApp(defineComponent({ render: () => h(SiteSetting) })); + app.use(pinia); + app.use(ViewUIPlus); + app.mount(el); + cleanups.push(() => { + app.unmount(); + el.remove(); + document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove()); + }); + return el; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +beforeEach(() => { + vi.clearAllMocks(); + routeMock.params = {}; + routeMock.query = {}; + mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([siteRow]))); + mocks.themeList.mockResolvedValue(JSON.parse(JSON.stringify([themeRow]))); + mocks.siteSave.mockImplementation(async (data: any) => ({ ...siteRow, ...data, id: data.id ?? 99 })); +}); + +afterEach(() => { + cleanups.forEach(fn => fn()); + cleanups = []; +}); + +describe("SiteSetting 创建模式(site/new,向导退役收编)", () => { + it("③~⑦分区锁定(创建站点后开放×5),主按钮=创建站点", async () => { + const el = mountPage(); + await flush(); + + const tips = (el.textContent.match(/创建站点后开放/g) || []).length; + expect(tips).toBe(5); + expect(el.textContent).toContain("新建站点"); + const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点")); + expect(createBtn).toBeTruthy(); + }); + + it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => { + const el = mountPage(); + await flush(); + + const input = el.querySelector('input[placeholder="URL 路径用,如 demo"]')!; + expect(input).toBeTruthy(); + setInputValue(input, "jdxy"); + await flush(); + + expect(el.textContent).toContain("/portal/jdxy/"); + }); + + it("校级建子站:上级锁定本校主站,预览=上级地址/编码(原向导 lockParent 语义)", async () => { + const el = mountPage(store => { + store.user = { superAdmin: false } as any; + store.enterSchool( + { schId: 1001, schName: "云海大学", mainSiteId: 1, mainSiteCode: "demo", mainSiteName: "云海大学门户网站" }, + [{ id: 1, code: "demo", path: "demo", name: "云海大学门户网站" }] + ); + }); + await flush(); + + const input = el.querySelector('input[placeholder="URL 路径用,如 demo"]')!; + setInputValue(input, "jdxy"); + await flush(); + + expect(el.textContent).toContain("/portal/demo/jdxy/"); + // 上级锁定为本校主站(Select disabled) + const selectEl = el.querySelector(".ivu-select-disabled"); + expect(selectEl).toBeTruthy(); + }); + + it("创建成功:replace 到站点设置页并解锁分区", async () => { + const el = mountPage(); + await flush(); + + const input = el.querySelector('input[placeholder="URL 路径用,如 demo"]')!; + setInputValue(input, "jdxy"); + const nameInput = el.querySelector('input[placeholder="如 XX学校门户网站"]')!; + setInputValue(nameInput, "机械学院门户"); + await flush(); + + const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!; + createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + expect(mocks.siteSave).toHaveBeenCalledTimes(1); + const payload = mocks.siteSave.mock.calls[0][0]; + expect(payload.code).toBe("jdxy"); + expect(payload.name).toBe("机械学院门户"); + expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99"); + }); +}); + +describe("SiteSetting 编辑模式(site/setting/:id)", () => { + it("基本信息回填 + 已注册皮肤无告警", async () => { + routeMock.params = { id: "1" }; + const el = mountPage(); + await flush(); + + expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo"); + expect((el.textContent || "")).not.toContain("未在注册表中"); + expect(el.textContent).toContain("云海大学门户网站"); + expect(el.textContent).not.toContain("创建站点后开放"); + }); + + it("未注册皮肤保留原值并弹出告警(不静默归一 default)", async () => { + routeMock.params = { id: "1" }; + mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }]))); + const el = mountPage(); + await flush(); + + expect(el.textContent).toContain("未在注册表中"); + expect(el.textContent).toContain("skin-x"); + }); + + it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => { + routeMock.params = { id: "1" }; + const el = mountPage(); + await flush(); + + const nameInput = el.querySelector('input[placeholder="如 XX学校门户网站"]')!; + setInputValue(nameInput, "改名后的门户"); + await flush(); + + const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!; + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + expect(mocks.siteSave).toHaveBeenCalledTimes(1); + const payload = mocks.siteSave.mock.calls[0][0]; + expect(payload.id).toBe(1); + expect(payload.name).toBe("改名后的门户"); + expect(payload.theme).toBe("default"); + const config = JSON.parse(payload.configJson); + expect(config.logo).toBe("/cms/upload/logo.png"); + expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留 + expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动 + }); +}); diff --git a/src/__tests__/siteWizardUrl.spec.ts b/src/__tests__/siteWizardUrl.spec.ts deleted file mode 100644 index c973157..0000000 --- a/src/__tests__/siteWizardUrl.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -// @vitest-environment jsdom -/** - * W4 向导地址预览联动(方案 §8.3 T-W4-18): - * 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。 - * 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。 - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createApp, defineComponent, h, nextTick, ref } from "vue"; -import { createPinia } from "pinia"; -import ViewUIPlus from "view-ui-plus"; - -const themesMock = vi.hoisted(() => ({ - list: vi.fn(async () => [ - { - name: "default", - title: "云端默认", - screenshotUrl: "/api/theme/default/asset/screenshot.png", - params: [], - }, - ]), -})); - -vi.mock("@/api/module/cms", () => ({ - themeApi: { list: () => ({ send: themesMock.list }) }, - siteApi: { save: () => ({ send: async () => ({}) }) }, -})); - -vi.mock("@/components/theme/ThemePicker.vue", () => ({ - default: defineComponent({ - props: ["themes", "modelValue"], - emits: ["update:modelValue", "pick"], - setup(_, { emit }) { - (globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name); - return () => h("div", { class: "theme-picker-stub" }, "themes"); - }, - }), -})); - -vi.mock("@/components/theme/ThemeParamForm.vue", () => ({ - default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }), -})); - -vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ - default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }), -})); - -vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ - default: defineComponent({ - props: ["modelValue"], - methods: { validate: async () => true }, - render: () => null, - }), -})); - -import SiteWizard from "@/views/cms/SiteWizard.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 mountWizard(lockParentId?: number, secondMain = false) { - const sites = [ - { id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 }, - ...(secondMain - ? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }] - : []), - ]; - const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径) - const pinia = createPinia(); - const el = document.createElement("div"); - document.body.appendChild(el); - const app = createApp(defineComponent({ - setup() { - return () => h(SiteWizard, { - visible: visible.value, - sites, - lockParentId, - "onUpdate:visible": (v: boolean) => { visible.value = v; }, - }); - }, - })); - app.use(pinia); - app.use(ViewUIPlus); - // SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus - // 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem - app.component("FormItem", defineComponent({ - props: ["label", "prop", "labelWidth"], - setup(props, { slots }) { - return () => h("div", { class: "form-item-stub" }, [ - props.label ? h("label", props.label) : null, - slots.default?.(), - ]); - }, - })); - app.mount(el); - visible.value = true; - cleanups.push(() => { - app.unmount(); - el.remove(); - document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove()); - }); - return el; -} - -async function gotoStep3() { - // step1 → step2 → step3(footer 按钮 transfer 到 body) - const clickNext = async () => { - await flush(); - const btn = [...document.querySelectorAll(".ivu-modal-footer button")] - .find(b => b.textContent?.includes("下一步")); - btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flush(); - }; - await clickNext(); - await clickNext(); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); -afterEach(() => { - cleanups.forEach(fn => fn()); - cleanups = []; -}); - -describe("SiteWizard 门户地址预览(T-W4-18)", () => { - it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => { - mountWizard(1); - await gotoStep3(); - - const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement; - expect(input).toBeTruthy(); - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; - setter.call(input, "jdxy"); - input.dispatchEvent(new Event("input", { bubbles: true })); - await flush(); - - expect(document.body.textContent).toContain("门户地址预览"); - expect(document.body.textContent).toContain("/portal/sch-3/jdxy/"); - }); - - it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => { - mountWizard(2, true); - await gotoStep3(); - - const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement; - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; - setter.call(input, "jdxy"); - input.dispatchEvent(new Event("input", { bubbles: true })); - await flush(); - - expect(document.body.textContent).toContain("/portal/sch-8/jdxy/"); - expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/"); - }); -}); diff --git a/src/components/ImageAssetField.vue b/src/components/ImageAssetField.vue new file mode 100644 index 0000000..336e388 --- /dev/null +++ b/src/components/ImageAssetField.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/router/routes.ts b/src/router/routes.ts index 764bd51..969ab1c 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -85,6 +85,20 @@ const cmsChildren: RouteRecordRaw[] = [ component: () => import("@/views/cms/SiteManage.vue"), meta: { title: "站点管理", modId: 6 }, }, + { + // 站点设置中心(docs/16 主线A):新建 = 创建模式(仅基本信息/皮肤可编辑),编辑 = 完整分区页。 + // 列表子页面形态(subOf),面包屑/侧栏高亮归属「站点管理」 + path: "site/new", + name: "SiteSettingNew", + component: () => import("@/views/cms/SiteSetting.vue"), + meta: { title: "新建站点", modId: 6, subOf: "/site" }, + }, + { + path: "site/setting/:id", + name: "SiteSetting", + component: () => import("@/views/cms/SiteSetting.vue"), + meta: { title: "站点设置", modId: 6, subOf: "/site" }, + }, { // 门户预览(SPA 内嵌简版门户;真实门户由后端 Thymeleaf 提供) path: "portal-preview", @@ -142,6 +156,8 @@ export const routes: RouteRecordRaw[] = [ { path: "/link", redirect: legacyRedirect("link") }, { path: "/attachment", redirect: legacyRedirect("attachment") }, { path: "/site", redirect: legacyRedirect("site") }, + { path: "/site/new", redirect: legacyRedirect("site/new") }, + { path: "/site/setting/:id", redirect: legacyRedirect("site/setting") }, { path: "/theme", redirect: "/theme-manage" }, { path: "/portal-preview", redirect: legacyRedirect("portal-preview") }, { diff --git a/src/utils/siteConfigForm.spec.ts b/src/utils/siteConfigForm.spec.ts index ebbf328..148da8b 100644 --- a/src/utils/siteConfigForm.spec.ts +++ b/src/utils/siteConfigForm.spec.ts @@ -107,10 +107,9 @@ describe("siteConfigForm", () => { expect(firstExtraError([])).toBe(""); }); - it("已知键清单与 docs/09 §0 键盘点表一致(防漂移)", () => { + it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => { expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([ "logo", "favicon", "slogan", "colleges_label", - "news_bg_image", "gallery_title", "gallery_subtitle", "contact", "footer_addresses", "qr_wechat", "qr_weibo", "social_wechat", "social_weibo", "social_douyin", "social_bilibili", "copyright", "beian", @@ -118,13 +117,12 @@ describe("siteConfigForm", () => { "domain", "public_base_url", ]); }); - it("docs/12:页脚增强与首页视觉新键 parse/serialize/校验(qr_* asset、social_* url)", () => { + it("docs/12:页脚增强新键 parse/serialize/校验(qr_* asset、social_* url)", () => { const json = JSON.stringify({ footer_addresses: "潭山校区:南湖路 237 号 邮编:464000\n淮河校区:大学路 1 号 邮编:464300", qr_wechat: "/cms/upload/qr-wechat.png", social_weibo: "https://weibo.com/xxx", social_douyin: "not-a-url", - gallery_title: "线上展厅", }); const m = parseSiteConfig(json); expect(m.fields.footer_addresses).toContain("淮河校区"); @@ -140,7 +138,15 @@ describe("siteConfigForm", () => { const out = JSON.parse(serializeSiteConfig(m)); expect(out.footer_addresses).toContain("邮编:464300"); expect(out).not.toHaveProperty("social_bilibili"); - expect(out.gallery_title).toBe("线上展厅"); + }); + it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(不落扩展行、无损回写)", () => { + const json = JSON.stringify({ gallery_title: "线上展厅", news_bg_image: "/cms/upload/campus.jpg" }); + const m = parseSiteConfig(json); + expect(m.fields).toEqual({}); // 不进任何分区表单 + expect(m.extras).toEqual([]); // 也不落扩展行 + expect(m.preserved.gallery_title).toBe("线上展厅"); + expect(m.preserved.news_bg_image).toBe("/cms/upload/campus.jpg"); + expect(JSON.parse(serializeSiteConfig(m))).toEqual(JSON.parse(json)); // 存量值无损保留 }); it("B3:custom_css 为保留键,不入扩展行,parse/serialize 无损往返", () => { const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" }); diff --git a/src/utils/siteConfigForm.ts b/src/utils/siteConfigForm.ts index 02ba4bd..09f8274 100644 --- a/src/utils/siteConfigForm.ts +++ b/src/utils/siteConfigForm.ts @@ -39,21 +39,6 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ placeholder: "留空默认「专业院系」", tip: "门户右上「专业院系」下拉的文案;仅主站有效(下拉由院系子站生成)。", }, - { - key: "news_bg_image", label: "新闻卡背景图", group: "display", kind: "asset", maxLength: 500, - placeholder: "如 /yschcms/upload/…/campus.jpg(留空用主题渐变)", - tip: "首页「双栏新闻卡」板块的全幅背景大图(建议 1920×700);仅展示型皮肤(campus-glow)消费。", - }, - { - key: "gallery_title", label: "展厅标题", group: "display", kind: "text", maxLength: 50, - placeholder: "留空默认「线上展厅」", - tip: "首页图片画廊文区头主标题。", - }, - { - key: "gallery_subtitle", label: "展厅英文副题", group: "display", kind: "text", maxLength: 100, - placeholder: "留空默认 Online Exhibition Hall", - tip: "首页图片画廊文区头英文副题。", - }, { key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true, placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号", @@ -119,9 +104,18 @@ export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [ { name: "domain", title: "域名与访问" }, ]; -/** 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护) */ +/** + * 系统托管键:不进表单,解析时原样保留、序列化时无损回写(theme_params 由主题参数表单维护; + * custom_css 由站点设置「皮肤与外观」分区维护)。 + */ export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"]; +/** + * docs/16 D4 被吸收的旧键:画廊标题/副题、新闻卡背景图改由首页布局区块属性承载(home_layout), + * 分区表单不再展示;存量值静默保留(渲染端仍作区块未设 title/background 时的默认值)。 + */ +export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"]; + /** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */ export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -160,7 +154,7 @@ export function parseSiteConfig(configJson: string | undefined | null): SiteConf return model; } const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key)); - const reserved = new Set(RESERVED_SITE_CONFIG_KEYS); + const reserved = new Set([...RESERVED_SITE_CONFIG_KEYS, ...ABSORBED_LEGACY_KEYS]); Object.keys(parsed).forEach(k => { const v = parsed![k]; if (typeof v !== "string") { diff --git a/src/views/cms/CategoryManage.vue b/src/views/cms/CategoryManage.vue index 533a962..a2b6cf9 100644 --- a/src/views/cms/CategoryManage.vue +++ b/src/views/cms/CategoryManage.vue @@ -520,7 +520,7 @@ function openLink(url: string) { -

首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。

+

首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。

SEO 设置 diff --git a/src/views/cms/SiteManage.vue b/src/views/cms/SiteManage.vue index 266d6bd..204e5d3 100644 --- a/src/views/cms/SiteManage.vue +++ b/src/views/cms/SiteManage.vue @@ -1,31 +1,20 @@ + + + + diff --git a/src/views/cms/SiteWizard.vue b/src/views/cms/SiteWizard.vue deleted file mode 100644 index 81d8895..0000000 --- a/src/views/cms/SiteWizard.vue +++ /dev/null @@ -1,314 +0,0 @@ - - - - - diff --git a/src/views/cms/components/SiteConfigForm.vue b/src/views/cms/components/SiteConfigForm.vue index c4a99b3..7639f5d 100644 --- a/src/views/cms/components/SiteConfigForm.vue +++ b/src/views/cms/components/SiteConfigForm.vue @@ -7,21 +7,38 @@ import { SiteConfigExtraRow, SiteConfigFieldDef, SiteConfigFormModel, + SiteConfigGroup, extraRowError, firstExtraError, parseSiteConfig, serializeSiteConfig, validateFieldValue, } from "@/utils/siteConfigForm"; +import ImageAssetField from "@/components/ImageAssetField.vue"; /** - * 站点配置结构化表单(docs/09 §3.1):v-model 直接绑定 configJson 字符串。 - * 内部 parse/serialize 双向映射;已知键分组表单 + 扩展参数动态行; + * 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域扩展): + * v-model 绑定整页共享的 configJson 字符串(单一事实源)。 + * props.groups 声明本分区渲染的分组;props.showExtras 控制扩展参数编辑区(每页仅一处)。 + * 模型仍完整解析全部键(fields/extras/preserved 三通道),仅渲染限定分组—— + * 序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。 * 保存前由父级调用 validate()(含扩展行兜底校验)。 */ -const props = defineProps<{ modelValue: string }>(); +const props = defineProps<{ + modelValue: string; + /** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */ + groups?: SiteConfigGroup[]; + /** 是否渲染扩展参数编辑区(docs/16:仅「扩展参数」分区开启) */ + showExtras?: boolean; + /** 图片类字段上传归属站点(附件场景引用) */ + siteId?: number; +}>(); const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>(); +/** 本实例渲染的分组集合(空=全部) */ +const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.name)))(); +const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name)); + const formRef = ref(); const formModel = reactive<{ fields: Record; extras: SiteConfigExtraRow[] }>({ fields: {}, @@ -65,14 +82,16 @@ function removeExtra(i: number) { emitUp(); } -/** 保存前校验:内置字段 Form 规则 + 扩展行兜底;通过返回 true */ +/** 保存前校验:内置字段 Form 规则 + 扩展行兜底(仅渲染扩展区的实例校验,避免越区拦截);通过返回 true */ async function validate(): Promise { const valid = await formRef.value?.validate(); if (!valid) return false; - const extraMsg = firstExtraError(formModel.extras); - if (extraMsg) { - Message.error(`扩展参数有误:${extraMsg}`); - return false; + if (props.showExtras) { + const extraMsg = firstExtraError(formModel.extras); + if (extraMsg) { + Message.error(`扩展参数有误:${extraMsg}`); + return false; + } } return true; } @@ -93,13 +112,12 @@ function rulesFor(def: SiteConfigFieldDef) { function defsOf(group: string): SiteConfigFieldDef[] { return SITE_CONFIG_FIELD_DEFS.filter(d => d.group === group); -} - +}