[CMS-VISUAL] F6: 站点设置「扩展参数」分区退役(7→6 分区),未知配置键改静默保留不丢失(vitest 164 绿)

This commit is contained in:
chenxing
2026-09-28 12:33:23 +08:00
parent 649a08629b
commit 3a0f1e0cc9
5 changed files with 34 additions and 204 deletions
+2 -2
View File
@@ -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();
+11 -41
View File
@@ -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}");
});
+11 -60
View File
@@ -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<string, string>;
extras: SiteConfigExtraRow[];
/** 系统托管键 + 非字符串未知键,无损保留 */
/** 非已知键(含系统托管键与未知键)无损保留 */
preserved: Record<string, unknown>;
}
/** 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<string, unknown> = {};
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 "";
}
+3 -28
View File
@@ -41,7 +41,8 @@ const row = ref<SiteVo | null>(null);
const sites = ref<SiteVo[]>([]);
const themes = ref<ThemeVo[]>([]);
/** 分区清单(锚点导航;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<Record<string, boolean>>({});
@@ -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());
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
<!-- ⑦ 扩展参数 -->
<div id="sec-extras" class="card setting-card" :class="{ locked: isCreate }" data-section="extras">
<div class="card-header">
<span class="card-title">扩展参数</span>
<span class="card-subtitle">面向后续扩展的预留配置键</span>
</div>
<Alert v-if="isCreate" type="info" show-icon class="ss-lock-tip">创建站点后开放</Alert>
<div class="card-body">
<SiteConfigForm ref="extrasFormRef" v-model="configJson" show-extras />
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.extras" :disabled="isCreate" @click="saveExtras">仅保存扩展参数</Button>
<span class="card-hint">仅保存本分区;其他分区未保存的修改仍在页面上,下次任一分区保存会一并带上</span>
</div>
</div>
</div>
</div>
</div>
+7 -73
View File
@@ -1,15 +1,11 @@
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import {
SITE_CONFIG_FIELD_DEFS,
SITE_CONFIG_GROUPS,
SiteConfigExtraRow,
SiteConfigFieldDef,
SiteConfigFormModel,
SiteConfigGroup,
extraRowError,
firstExtraError,
parseSiteConfig,
serializeSiteConfig,
validateFieldValue,
@@ -17,19 +13,16 @@ import {
import ImageAssetField from "@/components/ImageAssetField.vue";
/**
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域扩展):
* 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域;docs/20 F6 扩展参数区退役):
* v-model 绑定整页共享的 configJson 字符串(单一事实源)。
* props.groups 声明本分区渲染的分组;props.showExtras 控制扩展参数编辑区(每页仅一处)。
* 模型仍完整解析全部键(fields/extras/preserved 三通道),仅渲染限定分组——
* 序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
* 保存前由父级调用 validate()(含扩展行兜底校验)。
* props.groups 声明本分区渲染的分组。模型仍完整解析全部键(fields/preserved 双通道),
* 仅渲染限定分组——序列化时未渲染分区的值原样回写,保证多分区实例绑定同一 configJson 互不踩踏。
* 保存前由父级调用 validate()。
*/
const props = defineProps<{
modelValue: string;
/** 渲染的配置分组(缺省=全部分组,兼容旧整表单用法) */
groups?: SiteConfigGroup[];
/** 是否渲染扩展参数编辑区(docs/16:仅「扩展参数」分区开启) */
showExtras?: boolean;
/** 图片类字段上传归属站点(附件场景引用) */
siteId?: number;
}>();
@@ -40,9 +33,8 @@ const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.na
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
const formRef = ref();
const formModel = reactive<{ fields: Record<string, string>; extras: SiteConfigExtraRow[] }>({
const formModel = reactive<{ fields: Record<string, string> }>({
fields: {},
extras: [],
});
let preserved: Record<string, unknown> = {};
let lastEmitted: string | null = null;
@@ -51,7 +43,6 @@ function parseIn(v: string) {
const m: SiteConfigFormModel = parseSiteConfig(v);
Object.keys(formModel.fields).forEach(k => delete formModel.fields[k]);
Object.assign(formModel.fields, m.fields);
formModel.extras.splice(0, formModel.extras.length, ...m.extras.map(r => ({ ...r })));
preserved = m.preserved;
}
@@ -67,33 +58,14 @@ watch(
function emitUp() {
lastEmitted = serializeSiteConfig({
fields: { ...formModel.fields },
extras: formModel.extras.map(r => ({ ...r })),
preserved,
});
emit("update:modelValue", lastEmitted);
}
function addExtra() {
formModel.extras.push({ key: "", value: "" });
}
function removeExtra(i: number) {
formModel.extras.splice(i, 1);
emitUp();
}
/** 保存前校验:内置字段 Form 规则 + 扩展行兜底(仅渲染扩展区的实例校验,避免越区拦截);通过返回 true */
/** 保存前校验:内置字段 Form 规则;通过返回 true */
async function validate(): Promise<boolean> {
const valid = await formRef.value?.validate();
if (!valid) return false;
if (props.showExtras) {
const extraMsg = firstExtraError(formModel.extras);
if (extraMsg) {
Message.error(`扩展参数有误:${extraMsg}`);
return false;
}
}
return true;
return !!(await formRef.value?.validate());
}
defineExpose({ validate });
@@ -150,22 +122,6 @@ function defsOf(group: string): SiteConfigFieldDef[] {
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
</FormItem>
</template>
<template v-if="showExtras">
<div class="group-title">扩展参数</div>
<div v-for="(row, i) in formModel.extras" :key="i" class="extra-row">
<Input v-model="row.key" placeholder="参数名" style="width: 200px" @on-change="emitUp" />
<Input v-model="row.value" placeholder="参数值" style="flex: 1" @on-change="emitUp" />
<Button type="text" class="extra-del" icon="md-trash" @click="removeExtra(i)" />
<p v-if="extraRowError(formModel.extras, i)" class="cfg-error">
{{ extraRowError(formModel.extras, i) }}
</p>
</div>
<Button type="dashed" long icon="md-add" class="extra-add" @click="addExtra">新增参数</Button>
<p class="cfg-tip">
面向后续扩展的预留配置项(键名仅字母/数字/下划线);系统托管参数(如 theme_params)不在此显示,保存时自动保留。
</p>
</template>
</Form>
</div>
</template>
@@ -190,27 +146,5 @@ function defsOf(group: string): SiteConfigFieldDef[] {
line-height: 1.6;
margin-top: 4px;
}
.cfg-error {
color: #ed4014;
font-size: 12px;
line-height: 1.6;
margin-top: 4px;
}
.extra-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
.extra-del {
color: #999;
&:hover {
color: #ed4014;
}
}
}
.extra-add {
margin: 4px 0 8px;
}
}
</style>