From 3a0f1e0cc93accd1cc91ed92bcae3e9c02315772 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 12:33:23 +0800 Subject: [PATCH] =?UTF-8?q?[CMS-VISUAL]=20F6:=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E3=80=8C=E6=89=A9=E5=B1=95=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E3=80=8D=E5=88=86=E5=8C=BA=E9=80=80=E5=BD=B9=EF=BC=887?= =?UTF-8?q?=E2=86=926=20=E5=88=86=E5=8C=BA=EF=BC=89=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E7=9F=A5=E9=85=8D=E7=BD=AE=E9=94=AE=E6=94=B9=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=B8=8D=E4=B8=A2=E5=A4=B1=EF=BC=88vitest=20?= =?UTF-8?q?164=20=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 | 4 +- src/utils/siteConfigForm.spec.ts | 52 +++----------- src/utils/siteConfigForm.ts | 71 +++--------------- src/views/cms/SiteSetting.vue | 31 +------- src/views/cms/components/SiteConfigForm.vue | 80 ++------------------- 5 files changed, 34 insertions(+), 204 deletions(-) diff --git a/src/__tests__/siteSetting.spec.ts b/src/__tests__/siteSetting.spec.ts index ec66c9e..46e205f 100644 --- a/src/__tests__/siteSetting.spec.ts +++ b/src/__tests__/siteSetting.spec.ts @@ -136,12 +136,12 @@ afterEach(() => { }); describe("SiteSetting 创建模式(site/new,向导退役收编)", () => { - it("③~⑦分区锁定(创建站点后开放×5),主按钮=创建站点", async () => { + it("③~⑥分区锁定(创建站点后开放×4,F6 扩展参数分区已退役),主按钮=创建站点", async () => { const el = mountPage(); await flush(); const tips = (el.textContent.match(/创建站点后开放/g) || []).length; - expect(tips).toBe(5); + expect(tips).toBe(4); expect(el.textContent).toContain("新建站点"); const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点")); expect(createBtn).toBeTruthy(); diff --git a/src/utils/siteConfigForm.spec.ts b/src/utils/siteConfigForm.spec.ts index dea0a11..dc89015 100644 --- a/src/utils/siteConfigForm.spec.ts +++ b/src/utils/siteConfigForm.spec.ts @@ -1,17 +1,14 @@ import { describe, expect, it } from "vitest"; import { - EXTRA_KEY_RE, SITE_CONFIG_FIELD_DEFS, - extraRowError, - firstExtraError, parseSiteConfig, serializeSiteConfig, validateFieldValue, } from "./siteConfigForm"; -/** docs/09 §3.1 站点配置结构化:parse/serialize/校验 */ +/** docs/09 §3.1 站点配置结构化:parse/serialize/校验;docs/20 F6 扩展参数区退役(未知键静默保留) */ describe("siteConfigForm", () => { - it("parse:已知键入 fields、未知字符串键入扩展行、theme_params/对象键无损入 preserved", () => { + it("parse:已知键入 fields、未知键(字符串/对象)与系统托管键均无损入 preserved(F6)", () => { const json = JSON.stringify({ logo: "/cms/upload/logo.png", contact: "电话 0571-88888888", @@ -26,14 +23,14 @@ describe("siteConfigForm", () => { expect(m.fields.contact).toBe("电话 0571-88888888"); expect(m.fields.domain).toBe("www.xxx.edu.cn"); expect(m.fields.public_base_url).toBe("https://www.xxx.edu.cn"); - expect(m.extras).toEqual([{ key: "my_ext", value: "自定义值" }]); + expect(m.preserved.my_ext).toBe("自定义值"); // F6:未知字符串键不再进扩展行,静默保留 expect(m.preserved.theme_params).toEqual({ primary: "#b01a2e" }); expect(m.preserved.wechat).toEqual({ appid: "wx123" }); }); it("parse:非法 JSON / 空值按空配置处理,已知键非字符串值无损保留", () => { expect(parseSiteConfig("这不是json{").fields).toEqual({}); - expect(parseSiteConfig(null).extras).toEqual([]); + expect(parseSiteConfig(null).preserved).toEqual({}); expect(parseSiteConfig("").preserved).toEqual({}); const m = parseSiteConfig(JSON.stringify({ logo: 123, other: [1, 2] })); expect(m.fields.logo).toBeUndefined(); @@ -41,22 +38,17 @@ describe("siteConfigForm", () => { expect(m.preserved.other).toEqual([1, 2]); }); - it("serialize:空值剔除、扩展行空键/空值剔除、preserved 永不丢失", () => { + it("serialize:空值剔除、preserved 永不丢失", () => { const m = parseSiteConfig(JSON.stringify({ logo: "/cms/upload/logo.png", theme_params: { primary: "#fff" }, wechat: { x: 1 } })); m.fields.contact = " "; // 空白 → 剔除 - m.extras.push({ key: "k1", value: "v1" }); - m.extras.push({ key: "", value: "被剔除" }); - m.extras.push({ key: "k2", value: " " }); // 空值 → 剔除 const out = JSON.parse(serializeSiteConfig(m)); expect(out.logo).toBe("/cms/upload/logo.png"); expect(out).not.toHaveProperty("contact"); - expect(out.k1).toBe("v1"); - expect(out).not.toHaveProperty("k2"); expect(out.theme_params).toEqual({ primary: "#fff" }); expect(out.wechat).toEqual({ x: 1 }); }); - it("roundtrip:parse→serialize→parse 内容不丢(含扩展行与 preserved)", () => { + it("roundtrip:parse→serialize→parse 内容不丢(未知键与 preserved 全保留)", () => { const json = JSON.stringify({ copyright: "XX学校 · 版权所有 © 2026", seo_description: "校讯", @@ -68,12 +60,12 @@ describe("siteConfigForm", () => { const twice = parseSiteConfig(serializeSiteConfig(once)); expect(twice.fields.slogan).toBe("厚德博学"); expect(twice.fields.copyright).toBe("XX学校 · 版权所有 © 2026"); - expect(twice.extras).toEqual([{ key: "my_ext", value: "v" }]); + expect(twice.preserved.my_ext).toBe("v"); expect(twice.preserved.theme_params).toEqual({ bg: "#f7f5f2" }); }); it("validateFieldValue:url/host/asset/text 各类型通过与拒绝", () => { - const def = (kind: string, maxLength?: number) => + const def = (kind: string) => SITE_CONFIG_FIELD_DEFS.find(d => d.key === { url: "public_base_url", host: "domain", asset: "logo", text: "copyright" }[kind])!; expect(validateFieldValue(def("url"), "https://www.xxx.edu.cn")).toBe(""); expect(validateFieldValue(def("url"), "www.xxx.edu.cn")).toContain("http"); @@ -87,26 +79,6 @@ describe("siteConfigForm", () => { expect(validateFieldValue(def("url"), "")).toBe(""); // 空值一律放行(非必填) }); - it("extraRowError:格式/重名/重复校验;空键行放行", () => { - const rows = [{ key: "my_ext", value: "v" }]; - expect(extraRowError(rows, 0)).toBe(""); - expect(extraRowError([{ key: "", value: "v" }], 0)).toBe(""); - expect(extraRowError([{ key: "my-ext", value: "v" }], 0)).toContain("下划线"); - expect(extraRowError([{ key: "1bad", value: "v" }], 0)).toContain("下划线"); - expect(extraRowError([{ key: "logo", value: "v" }], 0)).toContain("重名"); - expect(extraRowError([{ key: "theme_params", value: "v" }], 0)).toContain("重名"); - expect(extraRowError([{ key: "a", value: "1" }, { key: "a", value: "2" }], 1)).toContain("重复"); - expect(EXTRA_KEY_RE.test("wechat_appid")).toBe(true); - expect(EXTRA_KEY_RE.test("_priv")).toBe(true); - expect(EXTRA_KEY_RE.test("9bad")).toBe(false); - }); - - it("firstExtraError:返回第一条错误;全部合法返回空串", () => { - expect(firstExtraError([{ key: "ok_key", value: "1" }, { key: "bad-key", value: "2" }])).toContain("下划线"); - expect(firstExtraError([{ key: "a", value: "1" }, { key: "b", value: "2" }])).toBe(""); - expect(firstExtraError([])).toBe(""); - }); - it("已知键清单与 docs/09 §0 键盘点表一致(防漂移;docs/16 D4 吸收三旧键后收敛)", () => { expect(SITE_CONFIG_FIELD_DEFS.map(d => d.key)).toEqual([ "logo", "favicon", "slogan", "colleges_label", @@ -128,7 +100,6 @@ describe("siteConfigForm", () => { expect(m.fields.footer_addresses).toContain("淮河校区"); expect(m.fields.qr_wechat).toBe("/cms/upload/qr-wechat.png"); expect(m.fields.social_weibo).toBe("https://weibo.com/xxx"); - expect(m.extras).toEqual([]); // 新键均为已知键,不落扩展行 const qrDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "qr_weibo")!; const socialDef = SITE_CONFIG_FIELD_DEFS.find(d => d.key === "social_douyin")!; expect(validateFieldValue(qrDef, "javascript:alert(1)")).toContain("路径"); @@ -139,19 +110,18 @@ describe("siteConfigForm", () => { expect(out.footer_addresses).toContain("邮编:464300"); expect(out).not.toHaveProperty("social_bilibili"); }); - it("docs/16 D4:gallery_title/gallery_subtitle/news_bg_image 被吸收为静默保留键(不落扩展行、无损回写)", () => { + 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 无损往返", () => { + it("B3:custom_css 为保留键,parse/serialize 无损往返", () => { const json = JSON.stringify({ custom_css: ".site-header{padding:16px 0}", my_ext: "v" }); const m = parseSiteConfig(json); - expect(m.extras).toEqual([{ key: "my_ext", value: "v" }]); + expect(m.preserved.my_ext).toBe("v"); expect(m.preserved.custom_css).toBe(".site-header{padding:16px 0}"); expect(JSON.parse(serializeSiteConfig(m)).custom_css).toBe(".site-header{padding:16px 0}"); }); diff --git a/src/utils/siteConfigForm.ts b/src/utils/siteConfigForm.ts index cae7bcc..e0ac74b 100644 --- a/src/utils/siteConfigForm.ts +++ b/src/utils/siteConfigForm.ts @@ -1,7 +1,8 @@ /** - * 站点配置结构化表单纯函数模块(docs/09 §3.1)。 - * 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表);未知字符串键 → 可编辑扩展行 - * (Projman「添加项」模式);未知非字符串键(theme_params 等系统托管键)原样保留无损回写。 + * 站点配置结构化表单纯函数模块(docs/09 §3.1;docs/20 F6 扩展参数分区退役)。 + * 已知键与模板/Java 实际消费方对齐(docs/09 §0 键清单表); + * 未知键(字符串与非字符串)一律无损入 preserved 静默保留——不再提供「扩展参数」编辑区 + * (后端对未知键的宽容策略不变:不拦截、不渲染、不丢失)。 * vitest 单测对象:siteConfigForm.spec.ts,不依赖组件挂载基建。 */ @@ -121,28 +122,22 @@ export const RESERVED_SITE_CONFIG_KEYS = ["theme_params", "custom_css"]; */ export const ABSORBED_LEGACY_KEYS = ["gallery_title", "gallery_subtitle", "news_bg_image"]; -/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致) */ +/** 扩展键名约束(与后端 CONFIG_KEY_PATTERN 一致;供后续功能键落位时复用) */ export const EXTRA_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; export const HOST_RE = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/; export const URL_RE = /^https?:\/\/\S+$/; export const ASSET_RE = /^(\/.+|https?:\/\/\S+)$/; -export interface SiteConfigExtraRow { - key: string; - value: string; -} - export interface SiteConfigFormModel { fields: Record; - extras: SiteConfigExtraRow[]; - /** 系统托管键 + 非字符串未知键,无损保留 */ + /** 非已知键(含系统托管键与未知键)无损保留 */ preserved: Record; } -/** configJson → 表单模型:已知键入 fields、未知字符串键入扩展行、其余无损入 preserved */ +/** configJson → 表单模型:已知键入 fields,其余(系统托管键/旧键/未知键)无损入 preserved */ export function parseSiteConfig(configJson: string | undefined | null): SiteConfigFormModel { - const model: SiteConfigFormModel = { fields: {}, extras: [], preserved: {} }; + const model: SiteConfigFormModel = { fields: {}, preserved: {} }; if (!configJson || !configJson.trim()) { return model; } @@ -159,23 +154,18 @@ 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, ...ABSORBED_LEGACY_KEYS]); Object.keys(parsed).forEach(k => { const v = parsed![k]; - if (typeof v !== "string") { - model.preserved[k] = v; - } else if (known.has(k)) { + if (known.has(k) && typeof v === "string") { model.fields[k] = v; - } else if (reserved.has(k)) { - model.preserved[k] = v; } else { - model.extras.push({ key: k, value: v }); + model.preserved[k] = v; } }); return model; } -/** 表单模型 → configJson:空值剔除;扩展行空键/空值剔除;preserved 原样回写(永不丢失) */ +/** 表单模型 → configJson:空值剔除;preserved 原样回写(永不丢失) */ export function serializeSiteConfig(model: SiteConfigFormModel): string { const out: Record = {}; SITE_CONFIG_FIELD_DEFS.forEach(def => { @@ -184,13 +174,6 @@ export function serializeSiteConfig(model: SiteConfigFormModel): string { out[def.key] = v; } }); - (model.extras || []).forEach(row => { - const k = (row.key || "").trim(); - const v = (row.value || "").trim(); - if (k && v) { - out[k] = v; - } - }); Object.keys(model.preserved || {}).forEach(k => { out[k] = model.preserved[k]; }); @@ -214,35 +197,3 @@ export function validateFieldValue(def: SiteConfigFieldDef, value: string): stri return def.maxLength && v.length > def.maxLength ? `不能超过 ${def.maxLength} 字` : ""; } } - -/** 扩展行校验:键名格式 / 与内置键或系统键冲突 / 行间重复;返回错误消息,空串=通过(空键行视为待填) */ -export function extraRowError(rows: SiteConfigExtraRow[], index: number): string { - const row = rows[index]; - const key = (row?.key || "").trim(); - if (!key) { - return ""; - } - if (!EXTRA_KEY_RE.test(key)) { - return "键名仅限字母/数字/下划线,且以字母或下划线开头"; - } - const known = new Set(SITE_CONFIG_FIELD_DEFS.map(f => f.key)); - if (known.has(key) || RESERVED_SITE_CONFIG_KEYS.includes(key)) { - return "扩展键不能与内置键/系统键重名"; - } - const dup = rows.findIndex(r => (r.key || "").trim() === key); - if (dup >= 0 && dup !== index) { - return "扩展键重复"; - } - return ""; -} - -/** 保存前兜底:返回第一条扩展行错误,空串=全部通过 */ -export function firstExtraError(rows: SiteConfigExtraRow[]): string { - for (let i = 0; i < (rows || []).length; i++) { - const msg = extraRowError(rows, i); - if (msg) { - return msg; - } - } - return ""; -} diff --git a/src/views/cms/SiteSetting.vue b/src/views/cms/SiteSetting.vue index c8e4b1d..d1f647a 100644 --- a/src/views/cms/SiteSetting.vue +++ b/src/views/cms/SiteSetting.vue @@ -41,7 +41,8 @@ const row = ref(null); const sites = ref([]); const themes = ref([]); -/** 分区清单(锚点导航;locked = 创建模式锁定) */ +/** 分区清单(锚点导航;locked = 创建模式锁定)。F6(docs/20):「扩展参数」分区退役—— + * 未知配置键由后端宽容保留,前端不再提供编辑区。 */ const sections = [ { key: "basic", title: "基本信息", desc: "编码 / 名称 / 归属 / 状态" }, { key: "theme", title: "皮肤与外观", desc: "选皮肤 / 自定义 CSS" }, @@ -49,7 +50,6 @@ const sections = [ { key: "display", title: "基础展示", desc: "品牌标识与图片" }, { key: "footer", title: "页脚信息", desc: "联系方式与社媒" }, { key: "seo", title: "SEO 与域名", desc: "收录与独立域名" }, - { key: "extras", title: "扩展参数", desc: "预留键位", locked: true }, ]; const activeSection = ref("basic"); let observer: IntersectionObserver | null = null; @@ -76,12 +76,11 @@ const customCss = ref(""); const previewVisible = ref(false); const editTheme = computed(() => themes.value.find(t => t.name === theme.value)); -/** ③~⑦ 分区表单 ref(分区独立校验) */ +/** ③~⑥ 分区表单 ref(分区独立校验) */ const layoutEditorRef = ref(); const displayFormRef = ref(); const footerFormRef = ref(); const seoFormRef = ref(); -const extrasFormRef = ref(); const saving = reactive>({}); @@ -301,14 +300,6 @@ async function saveSeo() { await submitSection("seo", {}); } -async function saveExtras() { - if (!(await extrasFormRef.value?.validate())) { - Message.warning("请填写红色星号内容!"); // bsch §4 - return; - } - await submitSection("extras", {}); -} - async function saveLayout() { const err = layoutEditorRef.value?.validate(); if (err) { @@ -557,22 +548,6 @@ watch(() => store.currentSchool?.schId, () => loadSites()); 仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上 - - -
-
- 扩展参数 - 面向后续扩展的预留配置键 -
- 创建站点后开放 -
- -
- -
diff --git a/src/views/cms/components/SiteConfigForm.vue b/src/views/cms/components/SiteConfigForm.vue index 7639f5d..ccbd13f 100644 --- a/src/views/cms/components/SiteConfigForm.vue +++ b/src/views/cms/components/SiteConfigForm.vue @@ -1,15 +1,11 @@