From af451703e79e1ccd0431bc8fd5d48042fb86b7c5 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 12:39:21 +0800 Subject: [PATCH] =?UTF-8?q?[CMS-VISUAL]=20F1:=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20Tab=20=E5=8C=96=EF=BC=887=20Tab=20?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=A1=A8=E5=8D=95/dirty/=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E9=87=8F=E5=90=88=E5=B9=B6=E4=BA=92=E4=B8=8D?= =?UTF-8?q?=E8=BF=9E=E5=B8=A6=EF=BC=89+=20=E6=96=B0=E5=BB=BA=E6=B8=90?= =?UTF-8?q?=E8=BF=9B=E5=BC=8F=E5=90=91=E5=AF=BC=EF=BC=88=E4=B8=A4=E6=AD=A5?= =?UTF-8?q?=E5=90=8E=E5=85=A8=E5=BC=80=E6=94=BE=EF=BC=8C=E6=97=A0=E7=81=B0?= =?UTF-8?q?=E6=80=81=EF=BC=89+=20=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E7=8B=AC=E7=AB=8B=20Tab=20+=20=E9=A1=B5=E8=84=9A/?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=B1=95=E7=A4=BA=E5=AD=97=E6=AE=B5=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E4=BD=8D=E7=BD=AE=E8=AF=B4=E6=98=8E=EF=BC=88165=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/siteSetting.spec.ts | 94 +-- src/utils/siteConfigForm.ts | 32 + src/views/cms/SiteSetting.vue | 627 ++++++++++---------- src/views/cms/components/SiteConfigForm.vue | 19 +- 4 files changed, 411 insertions(+), 361 deletions(-) diff --git a/src/__tests__/siteSetting.spec.ts b/src/__tests__/siteSetting.spec.ts index 46e205f..6f90209 100644 --- a/src/__tests__/siteSetting.spec.ts +++ b/src/__tests__/siteSetting.spec.ts @@ -1,10 +1,12 @@ // @vitest-environment jsdom /** - * 站点设置中心(docs/16 主线A)挂载回归: - * ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、 - * 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编); - * ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区); - * ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。 + * 站点设置中心(docs/16 主线A → docs/20 F1 Tab 化 + 渐进式向导)挂载回归: + * ① 创建模式(渐进式向导):无任何「创建站点后开放」灰态;主按钮=「创建站点,下一步」; + * 创建成功自动进入第二步(皮肤 Tab + Steps + 「保存并完成」); + * 校级锁定本校主站时门户地址预览随编码联动; + * ② 编辑模式:7 Tab 结构、基本信息回填、未注册皮肤告警保留原值; + * ③ Tab 独立保存:保存基本信息 = 以服务端事实源 configJson 提交(其他 Tab 键不丢、不连带提交); + * ④ 自定义样式独立 Tab(textarea 代码编辑器,非折叠面板)。 * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,8 +14,6 @@ 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() })); @@ -69,8 +69,8 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ })); vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ default: defineComponent({ - props: ["modelValue", "groups", "showExtras", "siteId"], - methods: { validate: async () => true }, + props: ["modelValue", "groups", "siteId"], + methods: { validate: async () => true, ownFields: () => ({}) }, render: () => h("div", "config-form-stub"), }), })); @@ -135,16 +135,15 @@ afterEach(() => { cleanups = []; }); -describe("SiteSetting 创建模式(site/new,向导退役收编)", () => { - it("③~⑥分区锁定(创建站点后开放×4,F6 扩展参数分区已退役),主按钮=创建站点", async () => { +describe("SiteSetting 创建模式(docs/20 F1 渐进式向导)", () => { + it("第一步仅基本信息表单:无任何灰态锁定提示,主按钮=创建站点,下一步", async () => { const el = mountPage(); await flush(); - const tips = (el.textContent.match(/创建站点后开放/g) || []).length; - expect(tips).toBe(4); + expect(el.textContent).not.toContain("创建站点后开放"); expect(el.textContent).toContain("新建站点"); - const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点")); - expect(createBtn).toBeTruthy(); + expect(el.textContent).toContain("创建站点,下一步"); + expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')).toBeTruthy(); }); it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => { @@ -179,7 +178,7 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => { expect(selectEl).toBeTruthy(); }); - it("创建成功:replace 到站点设置页并解锁分区", async () => { + it("创建成功:自动进入第二步(皮肤 Tab + Steps + 保存并完成),全 Tab 开放", async () => { const el = mountPage(); await flush(); @@ -189,26 +188,34 @@ describe("SiteSetting 创建模式(site/new,向导退役收编)", () => { setInputValue(nameInput, "机械学院门户"); await flush(); - const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!; + const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点,下一步"))!; createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flush(); + await flush(10); 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"); + // 向导第二步:Steps 可见 + 皮肤 Tab 激活 + 完成按钮;无灰态 + expect(el.textContent).toContain("皮肤与外观"); + expect(el.textContent).toContain("保存并完成"); + expect(el.textContent).toContain("跳过,稍后配置"); + expect(el.textContent).not.toContain("创建站点后开放"); }); }); -describe("SiteSetting 编辑模式(site/setting/:id)", () => { - it("基本信息回填 + 已注册皮肤无告警", async () => { +describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", () => { + it("7 个 Tab 标题齐备(基本信息…自定义样式),基本信息回填,无灰态提示", async () => { routeMock.params = { id: "1" }; const el = mountPage(); await flush(); + ["基本信息", "皮肤与外观", "首页布局", "基础展示", "页脚信息", "SEO 与域名", "自定义样式"].forEach(t => + expect(el.textContent).toContain(t) + ); expect(el.querySelector('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo"); - expect((el.textContent || "")).not.toContain("未在注册表中"); + expect(el.textContent || "").not.toContain("未在注册表中"); expect(el.textContent).toContain("云海大学门户网站"); expect(el.textContent).not.toContain("创建站点后开放"); }); @@ -223,7 +230,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => { expect(el.textContent).toContain("skin-x"); }); - it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => { + it("保存基本信息:以服务端事实源 configJson 提交(home_layout 等其他 Tab 键随行不丢)", async () => { routeMock.params = { id: "1" }; const el = mountPage(); await flush(); @@ -232,7 +239,7 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => { setInputValue(nameInput, "改名后的门户"); await flush(); - const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!; + const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!; saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); await flush(); @@ -244,27 +251,21 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => { 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" }]); // 首页布局分区未动 + expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局 Tab 未动 }); - it("T-T-05: 皮肤分区不再有参数微调区(只留选皮肤 + 站点级 CSS),保存不写 theme_params", async () => { + it("T-T-05: 皮肤 Tab 只留选皮肤(无参数微调),保存不写 theme_params", async () => { routeMock.params = { id: "1" }; const el = mountPage(); await flush(); - const themeCard = el.querySelector('[data-section="theme"]')!; - expect(themeCard).toBeTruthy(); // ThemeParamForm 已被桩化为 theme-param-stub:站点设置页不再出现该桩 - expect(themeCard.textContent).not.toContain("theme-param-stub"); - expect(themeCard.textContent).not.toContain("参数微调"); - // 站点级 CSS 覆盖区保留(副标题文案 + textarea 编辑框) - expect(themeCard.textContent).toContain("站点级 CSS 覆盖"); - expect(themeCard.querySelector("textarea")).toBeTruthy(); - // v1 §三:站点只负责选哪套包(选皮肤仍在) - expect(themeCard.textContent).toContain("theme-picker-stub"); + expect(el.textContent).not.toContain("theme-param-stub"); + expect(el.textContent).not.toContain("参数微调"); + expect(el.textContent).toContain("theme-picker-stub"); - const saveBtn = [...themeCard.querySelectorAll("button")].find(b => - b.textContent?.includes("仅保存皮肤与外观") + const saveBtn = [...el.querySelectorAll("button")].find(b => + b.textContent?.includes("保存皮肤与外观") )!; saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); await flush(); @@ -274,4 +275,25 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => { expect(config).not.toHaveProperty("theme_params"); // 参数值不再由站点提交 expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留 }); + + it("F1:自定义样式为独立 Tab 的代码编辑器(textarea + 字符计数),非折叠面板", async () => { + routeMock.params = { id: "1" }; + const el = mountPage(); + await flush(); + + const cssTextarea = el.querySelector("textarea"); + expect(cssTextarea).toBeTruthy(); + expect(el.textContent).toContain("自定义样式"); + expect(el.textContent).toContain("/10240 字符"); + // 独立保存按钮 + const cssSaveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存自定义样式"))!; + expect(cssSaveBtn).toBeTruthy(); + + cssSaveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(mocks.siteSave).toHaveBeenCalledTimes(1); + const config = JSON.parse(mocks.siteSave.mock.calls[0][0].configJson); + expect(config).not.toHaveProperty("custom_css"); // 空样式 = 删除键 + expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响 + }); }); diff --git a/src/utils/siteConfigForm.ts b/src/utils/siteConfigForm.ts index e0ac74b..fe7dbc3 100644 --- a/src/utils/siteConfigForm.ts +++ b/src/utils/siteConfigForm.ts @@ -30,6 +30,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ { key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)", + tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。", }, { key: "slogan", label: "首页标语", group: "display", kind: "text", maxLength: 200, @@ -43,6 +44,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ { key: "contact", label: "联系方式", group: "footer", kind: "text", maxLength: 200, multiline: true, placeholder: "如:电话 0571-88888888 · 地址杭州市 XX 路 100 号", + tip: "显示在页脚品牌区(页脚第一行位置)。", }, { key: "footer_addresses", label: "多校区地址", group: "footer", kind: "text", maxLength: 500, multiline: true, @@ -57,30 +59,37 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ { key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)", + tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。", }, { key: "social_wechat", label: "微信公众号", group: "footer", kind: "url", maxLength: 500, placeholder: "微信公众号主页链接,如 https://mp.weixin.qq.com/…(留空不展示图标)", + tip: "页脚社媒图标行(点击跳转链接地址)。", }, { key: "social_weibo", label: "微博", group: "footer", kind: "url", maxLength: 500, placeholder: "微博主页链接 https://weibo.com/…(留空不展示图标)", + tip: "页脚社媒图标行(点击跳转链接地址)。", }, { key: "social_douyin", label: "抖音", group: "footer", kind: "url", maxLength: 500, placeholder: "抖音主页链接 https://www.douyin.com/…(留空不展示图标)", + tip: "页脚社媒图标行(点击跳转链接地址)。", }, { key: "social_bilibili", label: "哔哩哔哩", group: "footer", kind: "url", maxLength: 500, placeholder: "B 站主页链接 https://space.bilibili.com/…(留空不展示图标)", + tip: "页脚社媒图标行(点击跳转链接地址)。", }, { key: "copyright", label: "版权文字", group: "footer", kind: "text", maxLength: 200, placeholder: "如:XX学校 · 版权所有 © 2026", + tip: "页脚最底行的版权声明文字。", }, { key: "beian", label: "备案号", group: "footer", kind: "text", maxLength: 100, placeholder: "如:浙ICP备XXXXXXXX号-1", + tip: "页脚最底行版权文字旁的 ICP 备案号(可点击性质由模板决定,通常为纯文字)。", }, { key: "seo_description", label: "SEO 描述", group: "seo", kind: "text", maxLength: 500, multiline: true, @@ -180,6 +189,29 @@ export function serializeSiteConfig(model: SiteConfigFormModel): string { return JSON.stringify(out); } +/** + * F1(docs/20)Tab 独立保存:服务端事实源 base + 本 Tab 增量 patch 合并—— + * patch 值为空串/null/undefined = 删除该键;其余键原样保留。 + * 各 Tab 只提交自己的键空间,其他 Tab 未保存的内存修改不会被连带提交。 + */ +export function mergeConfigPatch(base: string | undefined | null, patch: Record): string { + let obj: Record; + try { + const parsed = base && base.trim() ? JSON.parse(base) : {}; + obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {}; + } catch { + obj = {}; + } + Object.entries(patch).forEach(([k, v]) => { + if (v === undefined || v === null || (typeof v === "string" && !v.trim())) { + delete obj[k]; + } else { + obj[k] = v; + } + }); + return JSON.stringify(obj); +} + /** 已知字段值校验:返回错误消息,空串=通过(与后端 validateConfigJson 同语义) */ export function validateFieldValue(def: SiteConfigFieldDef, value: string): string { const v = (value || "").trim(); diff --git a/src/views/cms/SiteSetting.vue b/src/views/cms/SiteSetting.vue index d1f647a..2bef78a 100644 --- a/src/views/cms/SiteSetting.vue +++ b/src/views/cms/SiteSetting.vue @@ -1,5 +1,5 @@