diff --git a/src/__tests__/homeLayoutEditor.spec.ts b/src/__tests__/homeLayoutEditor.spec.ts new file mode 100644 index 0000000..32f926b --- /dev/null +++ b/src/__tests__/homeLayoutEditor.spec.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +/** + * 首页布局编辑器(v2 工作项 B / 手册 §2.8)挂载回归:T-B-09—— + * quick_links 的属性面板必须是「数组编辑器」(每行 name + url,可增删、受 12 项上限约束), + * notice_bar 必须是正文 + 可选跳转 + level 选择;序列行摘要走 blockSummary 新增分支。 + * 沿用 siteSetting.spec.ts 的手写 createApp + vi.mock 桩风格。 + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, ref } from "vue"; +import ViewUIPlus from "view-ui-plus"; + +Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {}); + +vi.mock("@/api/module/cms", () => ({ + categoryApi: { tree: () => ({ send: async () => [] }) }, +})); + +import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue"; + +const cleanups: (() => void)[] = []; + +/** 排空微任务 + 一个宏任务(view-ui-plus 的 Select 标签回填走 nextTick/setTimeout) */ +async function settle(frames = 4) { + for (let i = 0; i < frames; i++) { + await nextTick(); + } + await new Promise(r => setTimeout(r, 0)); + for (let i = 0; i < frames; i++) { + await nextTick(); + } +} + +const CONFIG = JSON.stringify({ + logo: "/a.png", + home_layout: { + version: 1, + blocks: [ + { id: "q1", type: "quick_links", items: [{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }] }, + { id: "n1", type: "notice_bar", text: "国庆放假安排", url: "/notice/gq", level: "warn" }, + ], + }, +}); + +async function mountEditor(configJson: string) { + const el = document.createElement("div"); + document.body.appendChild(el); + const model = ref(configJson); + const app = createApp( + defineComponent({ + render: () => h(HomeLayoutEditor, { modelValue: model.value, "onUpdate:modelValue": (v: string) => (model.value = v) }), + }), + ); + app.use(ViewUIPlus); + app.mount(el); + cleanups.push(() => { + app.unmount(); + el.remove(); + }); + await settle(6); + return { el, model }; +} + +/** 取属性面板中指定 label 的字段容器 */ +function fieldOf(el: HTMLElement, label: string): HTMLElement | null { + return ( + Array.from(el.querySelectorAll(".hle-field")).find( + f => f.querySelector(".hle-field-label")?.textContent?.trim() === label, + ) || null + ); +} + +/** 第 index 行的删除按钮 */ +function del(field: HTMLElement, index: number): HTMLElement { + return field.querySelectorAll(".hle-link-del")[index]; +} + +afterEach(() => { + cleanups.splice(0).forEach(fn => fn()); +}); + +describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)", () => { + it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => { + const { el, model } = await mountEditor(CONFIG); + // 序列行摘要走 blockSummary 新增分支 + expect(el.textContent).toContain("1 个入口"); + expect(el.textContent).toContain("醒目提示"); + + // 选中 quick_links 行 → 属性面板出现入口列表编辑器 + (el.querySelectorAll(".hle-row")[0] as HTMLElement).click(); + await settle(); + const field = fieldOf(el as HTMLElement, "入口列表"); + expect(field).not.toBeNull(); + expect(field!.querySelectorAll(".hle-link-row").length).toBe(1); + expect(field!.querySelectorAll(".hle-link-row input").length).toBe(2); + const add = field!.querySelector(".hle-link-add") as HTMLElement; + expect(add.textContent).toContain("添加入口"); + + add.click(); + await settle(); + expect(field!.querySelectorAll(".hle-link-row").length).toBe(2); + // 新增行是空行:回写布局时空行由 cleanBlock 剔除,故提交形态仍只 1 项 + const blocks = JSON.parse(model.value).home_layout.blocks; + expect(blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]); + // 其余键无损 + expect(JSON.parse(model.value).logo).toBe("/a.png"); + + // 删掉尾部的空行 → 回到 1 行,提交形态不变 + del(field!, 1).click(); + await settle(); + expect(field!.querySelectorAll(".hle-link-row").length).toBe(1); + expect(JSON.parse(model.value).home_layout.blocks[0].items).toEqual([{ name: "办事大厅", url: "https://bsch.edu.cn/hall" }]); + + // 再删唯一入口行 → 空列表(提交时 items 不落键,由后端校验拦下「至少 1 个入口」) + del(field!, 0).click(); + await settle(); + expect(field!.querySelectorAll(".hle-link-row").length).toBe(0); + expect(JSON.parse(model.value).home_layout.blocks[0].items).toBeUndefined(); + }); + + it("quick_links:12 项上限时添加按钮禁用", async () => { + const items = Array.from({ length: 12 }, (_, i) => ({ name: `入口${i}`, url: `/e${i}` })); + const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "q1", type: "quick_links", items }] } }); + const { el } = await mountEditor(config); + (el.querySelectorAll(".hle-row")[0] as HTMLElement).click(); + await settle(); + const field = fieldOf(el as HTMLElement, "入口列表")!; + expect(field.querySelectorAll(".hle-link-row").length).toBe(12); + expect((field.querySelector(".hle-link-add") as HTMLButtonElement).disabled).toBe(true); + }); + + it("notice_bar:正文输入放宽到 200 字、跳转可选、level 为 info/warn 选择器", async () => { + const { el } = await mountEditor(CONFIG); + (el.querySelectorAll(".hle-row")[1] as HTMLElement).click(); + await settle(); + const text = fieldOf(el as HTMLElement, "公告正文")!.querySelector("input")!; + expect(text.getAttribute("maxlength")).toBe("200"); + expect(fieldOf(el as HTMLElement, "跳转地址")).not.toBeNull(); + const levelField = fieldOf(el as HTMLElement, "提示级别")!; + expect(levelField.querySelector(".ivu-select")).not.toBeNull(); + expect(levelField.textContent).toContain("醒目"); + }); +}); diff --git a/src/__tests__/siteSetting.spec.ts b/src/__tests__/siteSetting.spec.ts index 8f37d4e..ec66c9e 100644 --- a/src/__tests__/siteSetting.spec.ts +++ b/src/__tests__/siteSetting.spec.ts @@ -246,4 +246,32 @@ describe("SiteSetting 编辑模式(site/setting/:id)", () => { expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留 expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动 }); + + it("T-T-05: 皮肤分区不再有参数微调区(只留选皮肤 + 站点级 CSS),保存不写 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"); + + const saveBtn = [...themeCard.querySelectorAll("button")].find(b => + b.textContent?.includes("仅保存皮肤与外观") + )!; + saveBtn.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("theme_params"); // 参数值不再由站点提交 + expect(config.logo).toBe("/cms/upload/logo.png"); // 其余键仍随事实源保留 + }); }); diff --git a/src/__tests__/themeManage.spec.ts b/src/__tests__/themeManage.spec.ts index 5d4ea13..18e23c6 100644 --- a/src/__tests__/themeManage.spec.ts +++ b/src/__tests__/themeManage.spec.ts @@ -4,8 +4,10 @@ * ① 内置卡按钮为"预览+编辑"(任务四:内置包可改显示名/描述); * ② 被用外部包删除禁用且 tooltip 含站点名; * ③ 上传改为表单弹窗(zip + 命名),提交走手动上传通道; - * ④ 内置编辑弹窗无参数区、保存不带 params(后端强校验拒改); + * ④ 内置编辑弹窗不开放参数 schema 区、保存不带 params(后端强校验拒改); * ⑤ 添加皮肤包表单标识名为空时拦截。 + * ⑥ 工作项 T(T-T-06):编辑弹窗内嵌参数微调(内置包亦开放),回显/提交包级 paramValues, + * 未触碰不带 paramValues(null=不改),卡片显示「已微调 M 项」。 * 沿用手写 createApp 挂载 + vi.mock 桩风格(与 categoryWorkbench.spec.ts 一致)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -28,6 +30,8 @@ const themes = [ description: "", screenshotUrl: "/api/theme/default/asset/screenshot.png", params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }], + // 工作项 T:包级参数值(仅含被微调过的 key) + paramValues: { primary: "#0a5c36" }, source: "builtin", inUseCount: 2, editable: true, @@ -86,7 +90,41 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ default: defineComponent({ props: ["sitePath", "theme", "themeParams", "fill"], setup: (props: any) => () => - h("div", { "data-preview-stub": "true", "data-fill": String(props.fill !== undefined && props.fill !== false) }, "preview-stub"), + h( + "div", + { + "data-preview-stub": "true", + "data-fill": String(props.fill !== undefined && props.fill !== false), + "data-params": JSON.stringify(props.themeParams || {}), + }, + "preview-stub" + ), + }), +})); + +/** + * T-T-06:参数微调迁皮肤包级后,编辑弹窗内嵌 ThemeParamForm。 + * 桩件把当前表单模型回显在 data-model 上,并给一个「改主色」按钮模拟用户改动。 + */ +vi.mock("@/components/theme/ThemeParamForm.vue", () => ({ + default: defineComponent({ + props: ["theme", "modelValue"], + emits: ["update:modelValue"], + setup: (props: any, { emit }: any) => () => + h( + "div", + { "data-param-stub": "true", "data-model": JSON.stringify(props.modelValue || {}) }, + [ + h( + "button", + { + class: "stub-tweak", + onClick: () => emit("update:modelValue", { ...(props.modelValue || {}), primary: "#111111" }), + }, + "改主色" + ), + ] + ), }), })); @@ -207,7 +245,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => { expect(listMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); - it("任务四: 内置包编辑弹窗无参数区,保存调用 updateMeta 不带 params", async () => { + it("任务四: 内置包编辑弹窗不开放参数 schema 编辑区,保存调用 updateMeta 不带 params", async () => { const el = mountPage(); await flush(); @@ -215,7 +253,7 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => { clickButtonByText(builtin, "编辑"); await flush(); - // 内置提示条存在、无 ColorPicker(参数 schema 区不渲染) + // 内置提示条存在、无 ColorPicker(参数 schema 编辑区不渲染;包级参数值是另一区,见 T-T-06) const metaModal = modalContaining("内置包内容(模板/资产)不可修改"); expect(metaModal).toBeTruthy(); expect(document.body.textContent).toContain("内置包内容(模板/资产)不可修改"); @@ -229,6 +267,50 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => { ); }); + it("T-T-06: 编辑弹窗内嵌参数微调,回显包级 paramValues(缺项回落默认),保存提交 paramValues", async () => { + const el = mountPage(); + await flush(); + + clickButtonByText([...el.querySelectorAll(".card")][0], "编辑"); + await flush(); + const metaModal = modalContaining("内置包内容(模板/资产)不可修改"); + const stub = metaModal!.querySelector('[data-param-stub]'); + expect(stub).toBeTruthy(); + // paramValuesOf:已微调的 primary 取包值,未声明微调的走 theme.json 默认 + expect(JSON.parse(stub!.getAttribute("data-model")!)).toEqual({ primary: "#0a5c36" }); + + // 改主色 → 保存:只提交「与默认值不同」的 key(=「已微调」口径,与后端整体替换语义对齐) + clickButtonByText(metaModal!, "改主色"); + await flush(); + clickButtonByText(metaModal!, "保存"); + await flush(); + expect(updateMetaMock).toHaveBeenCalledWith("default", expect.objectContaining({ paramValues: { primary: "#111111" } })); + }); + + it("T-T-06: 未触碰参数区时保存不带 paramValues(后端 null=不改,防止改名顺带清空微调)", async () => { + const el = mountPage(); + await flush(); + + clickButtonByText([...el.querySelectorAll(".card")][2], "编辑"); // free-skin(外部包,无微调) + await flush(); + const metaModal = modalContaining("编辑皮肤:free-skin"); + expect(metaModal).toBeTruthy(); + expect(JSON.parse(metaModal!.querySelector('[data-param-stub]')!.getAttribute("data-model")!)).toEqual({}); + clickButtonByText(metaModal!, "保存"); + await flush(); + const payload = updateMetaMock.mock.calls[0][1] as Record; + expect("paramValues" in payload).toBe(false); + }); + + it("T-T-06: 卡片显示「已微调 M 项」,无微调的包显示未微调", async () => { + const el = mountPage(); + await flush(); + const cards = [...el.querySelectorAll(".card")]; + expect(cards[0].textContent).toContain("参数 1 个 · 已微调 1 项"); + expect(cards[1].textContent).toContain("未微调"); // my-skin 无 paramValues + expect(cards[2].textContent).toContain("参数 0 个"); + }); + it("任务四: 添加皮肤包表单——标识名为空被拦截,不调用 derive", async () => { const el = mountPage(); await flush(); @@ -261,4 +343,19 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => { expect(stub).toBeTruthy(); expect(stub!.getAttribute("data-fill")).toBe("true"); }); + + it("T-T-06 佐证: 预览转发包级 paramValues(后端 /api/theme/preview 只认 p_*,不转发则预览丢包色)", async () => { + const el = mountPage(); + await flush(); + + clickButtonByText([...el.querySelectorAll(".card")][0], "预览"); // default:paramValues={primary:#0a5c36} + await flush(); + const stub = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!; + expect(JSON.parse(stub.getAttribute("data-params")!)).toEqual({ primary: "#0a5c36" }); + + clickButtonByText([...el.querySelectorAll(".card")][2], "预览"); // free-skin:未微调 + await flush(); + const free = modalContaining("内容源站点")!.querySelector("[data-preview-stub]")!; + expect(JSON.parse(free.getAttribute("data-params")!)).toEqual({}); + }); }); diff --git a/src/api/module/cms.ts b/src/api/module/cms.ts index f17a15a..569e478 100644 --- a/src/api/module/cms.ts +++ b/src/api/module/cms.ts @@ -106,7 +106,8 @@ class SiteApi { return get(`${SITE}/list`); } - /** 入参对齐 SiteSaveRequest(themeParams 服务端合并进 config_json.theme_params,方案 §5.3) */ + /** 入参对齐 SiteSaveRequest。themeParams 已随工作项 T 退役(参数值改存皮肤包级 paramValues), + * 服务端忽略该字段——保留仅为与 @Deprecated 的后端 DTO 同形,新代码不要再传 */ save(data: Partial & { themeParams?: Record }) { return postJSON(`${SITE}/save`, data); } @@ -230,8 +231,14 @@ class ThemeApi { } /** W1 + 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema), - * 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认) */ - updateMeta(name: string, data: { title?: string; description?: string; params?: unknown[] }) { + * 内置包显示名/描述走 .meta 覆盖层(title/description 全空=恢复默认)。 + * 工作项 T:paramValues=包级参数微调值,省略=不改,{}=清空恢复 theme.json 默认值 */ + updateMeta(name: string, data: { + title?: string; + description?: string; + params?: unknown[]; + paramValues?: Record; + }) { return alovaInstance.Put(`${THEME}/${name}/meta`, data, { headers: { "Content-Type": "application/json;charset=UTF-8" }, }); diff --git a/src/components/theme/ThemeParamForm.vue b/src/components/theme/ThemeParamForm.vue index b2cddf1..1b22680 100644 --- a/src/components/theme/ThemeParamForm.vue +++ b/src/components/theme/ThemeParamForm.vue @@ -1,18 +1,23 @@ +
+ +
+

{{ f.tip }}

@@ -459,6 +508,31 @@ defineExpose({ validate }); margin: 8px 0; } } + .hle-links { + display: flex; + flex-direction: column; + gap: 6px; + .hle-link-row { + display: flex; + align-items: center; + gap: 6px; + .hle-link-name { + flex: none; + width: 100px; + } + } + .hle-link-del:hover { + color: #ed4014; + } + .hle-links-empty { + color: #999; + font-size: 12px; + text-align: center; + } + .hle-link-add { + border-style: dashed; + } + } } .hle-type-card { display: flex; diff --git a/src/views/cms/homeLayout/blockTypes.ts b/src/views/cms/homeLayout/blockTypes.ts index 50229e1..11a7d44 100644 --- a/src/views/cms/homeLayout/blockTypes.ts +++ b/src/views/cms/homeLayout/blockTypes.ts @@ -4,16 +4,17 @@ * 编辑器属性面板/添加抽屉按本表 schema 驱动自动出现。 * propFields 顺序即属性面板渲染顺序;showIf 控制条件显示(如 background 仅 news_grid)。 */ -import { LayoutBlock } from "./homeLayout"; -import { LIST_STYLES, styleLabel } from "./homeLayout"; +import { LayoutBlock, LINK_NAME_MAX, LIST_STYLES, NOTICE_TEXT_MAX, QUICK_LINK_MAX, styleLabel } from "./homeLayout"; export interface BlockPropField { key: string; label: string; - kind: "text" | "number" | "select" | "switch" | "categories"; + kind: "text" | "number" | "select" | "switch" | "categories" | "links"; options?: { value: string; label: string }[]; min?: number; max?: number; + /** text 输入框字数上限(缺省 100) */ + maxlength?: number; placeholder?: string; tip?: string; /** 条件显示(返回 false 时隐藏且不校验) */ @@ -99,6 +100,42 @@ export const BLOCK_TYPES: BlockTypeDef[] = [ { key: "group", label: "链接分组", kind: "text", placeholder: "留空显示全部分组" }, ], }, + { + type: "quick_links", + title: "快捷通道", + icon: "md-flash", + description: "入口墙:自行填写 1~12 个跳转入口(名称 + 地址),不依赖栏目与友情链接数据", + defaultProps: () => ({ items: [{ name: "", url: "" }] }), + propFields: [ + { key: "title", label: "区块标题", kind: "text", placeholder: "默认「快捷通道」" }, + { + key: "items", label: "入口列表", kind: "links", + tip: `每个入口 = 名称(${LINK_NAME_MAX} 字内)+ 地址(http(s):// 或站内 / 路径),最多 ${QUICK_LINK_MAX} 个`, + }, + ], + }, + { + type: "notice_bar", + title: "公告条", + icon: "md-volume-up", + description: "通栏提示条:一条全站公告(放假/停网/招生等),可选跳转与醒目配色", + defaultProps: () => ({ text: "", level: "info" }), + propFields: [ + { key: "title", label: "区块标题", kind: "text", placeholder: "默认「公告」,显示为左侧标签" }, + { + key: "text", label: "公告正文", kind: "text", maxlength: NOTICE_TEXT_MAX, + placeholder: "如:国庆放假安排通知如下……", tip: `通栏一行展示,建议一句话(${NOTICE_TEXT_MAX} 字内)`, + }, + { key: "url", label: "跳转地址", kind: "text", placeholder: "可选:http(s):// 或站内 / 路径" }, + { + key: "level", label: "提示级别", kind: "select", + options: [ + { value: "info", label: "一般(主题色)" }, + { value: "warn", label: "醒目(警示红)" }, + ], + }, + ], + }, ]; export function blockDefOf(type: string): BlockTypeDef | undefined { @@ -120,6 +157,15 @@ export function blockSummary(block: LayoutBlock, namesOf: (id: number) => string parts.push("线上展厅图集"); } else if (block.type === "links") { parts.push(block.group ? `分组:${block.group}` : "全部分组"); + } else if (block.type === "quick_links") { + const filled = (block.items || []).filter(i => (i.name || "").trim() || (i.url || "").trim()); + parts.push(`${filled.length} 个入口`); + } else if (block.type === "notice_bar") { + parts.push(block.level === "warn" ? "醒目提示" : "普通提示"); + const text = (block.text || "").trim(); + if (text) { + parts.push(text.length > 18 ? `${text.slice(0, 18)}…` : text); + } } const ids = block.categoryIds || []; if (ids.length) { diff --git a/src/views/cms/homeLayout/homeLayout.spec.ts b/src/views/cms/homeLayout/homeLayout.spec.ts index ee07e8e..77a0005 100644 --- a/src/views/cms/homeLayout/homeLayout.spec.ts +++ b/src/views/cms/homeLayout/homeLayout.spec.ts @@ -126,6 +126,84 @@ describe("validateHomeLayout:与后端 SiteService 同语义", () => { }); }); +describe("quick_links / notice_bar(v2 手册 §2.8):装拆与校验须与后端 S3 同语义", () => { + const link = (name: string, url: string) => ({ name, url }); + const quick = (items: LayoutBlock["items"]): LayoutBlock => ({ id: "q", type: "quick_links", visible: true, items }); + const notice = (extra: Partial): LayoutBlock => ({ id: "n", type: "notice_bar", visible: true, ...extra }); + + it("T-B-06 parseHomeLayout 接受两个新类型;normalizeBlock 还原 items/text/url/level 并剔除脏行", () => { + const json = JSON.stringify({ + home_layout: { + version: 1, + blocks: [ + { + id: "q1", type: "quick_links", title: "服务入口", items: [ + link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library"), + "脏项", { name: 1, url: 2 }, + ], + }, + { id: "n1", type: "notice_bar", text: "国庆放假", url: "/notice/gq", level: "warn" }, + { id: "n2", type: "notice_bar", text: "常规提示" }, + ], + }, + }); + const layout = parseHomeLayout(json)!; + expect(layout.blocks.length).toBe(3); + expect(layout.blocks[0]).toMatchObject({ + id: "q1", type: "quick_links", title: "服务入口", visible: true, + items: [link("办事大厅", "https://bsch.edu.cn/hall"), link("图书馆", "/library")], + }); + expect(layout.blocks[1]).toMatchObject({ text: "国庆放假", url: "/notice/gq", level: "warn" }); + expect(layout.blocks[2].level).toBeUndefined(); + // 未知类型仍被跳过(两个新类型入白名单不等于放开任意类型) + expect(parseHomeLayout(JSON.stringify({ home_layout: { blocks: [{ type: "quicklink" }] } }))!.blocks).toEqual([]); + }); + + it("T-B-07 cleanBlock:两个新类型只写业务键、缺省值不落键、空入口行剔除", () => { + expect(cleanBlock(quick([link("办事大厅", "https://x.edu.cn"), link("", "")]))).toEqual({ + id: "q", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")], + }); + expect(cleanBlock(quick([]))).toEqual({ id: "q", type: "quick_links" }); + expect(cleanBlock({ ...quick([link("图", "/lib")]), visible: false })).toEqual({ + id: "q", type: "quick_links", visible: false, items: [link("图", "/lib")], + }); + expect(cleanBlock(notice({ text: " 放假通知 ", url: "/a", level: "warn" }))).toEqual({ + id: "n", type: "notice_bar", text: "放假通知", url: "/a", level: "warn", + }); + // level=info 是缺省值 → 不写;url 空 → 不写 + expect(cleanBlock(notice({ text: "提示", level: "info", url: "" }))).toEqual({ id: "n", type: "notice_bar", text: "提示" }); + }); + + it("T-B-07b parse→serialize 往返稳定(新区块不被改写)", () => { + const json = JSON.stringify({ + home_layout: { + version: 1, + blocks: [ + { id: "q1", type: "quick_links", items: [link("办事大厅", "https://x.edu.cn")] }, + { id: "n1", type: "notice_bar", text: "放假通知", url: "/notice", level: "warn" }, + ], + }, + }); + expect(JSON.parse(serializeHomeLayout(json, parseHomeLayout(json)))).toEqual(JSON.parse(json)); + }); + + it("T-B-08 validateHomeLayout 与后端同语义:必填 / 12 项 / 20 字 / 200 字 / url 前缀 / level 白名单", () => { + expect(validateHomeLayout({ version: 1, blocks: [quick([link("办事大厅", "https://x.edu.cn")]), notice({ text: "通知" })] })).toEqual([]); + const joined = (blocks: LayoutBlock[]) => validateHomeLayout({ version: 1, blocks }).join(" "); + expect(joined([quick([])])).toContain("入口"); + expect(joined([quick(Array.from({ length: 13 }, (_, i) => link(`入口${i}`, `/e${i}`)))])).toContain("12"); + expect(joined([quick([link("长".repeat(21), "/x")])])).toContain("20"); + expect(joined([quick([link("", "/x")])])).toContain("名称"); + expect(joined([quick([link("办事大厅", "")])])).toContain("http(s)://"); + expect(joined([quick([link("办事大厅", "javascript:alert(1)")])])).toContain("http(s)://"); + expect(joined([quick([link("办事大厅", "lib.edu.cn")])])).toContain("http(s)://"); + expect(joined([notice({})])).toContain("公告正文"); + expect(joined([notice({ text: "告".repeat(201) })])).toContain("200"); + expect(joined([notice({ text: "x", url: "javascript:alert(1)" })])).toContain("http(s)://"); + expect(joined([notice({ text: "x", level: "danger" })])).toContain("info"); + }); +}); + describe("genFromCategories:一键生成与现状渲染等价", () => { const tree: CategoryTreeNode[] = [ cat(1, "校园时讯", 1, "news_grid", [cat(11, "院系动态", 1), cat(12, "单页栏目", 2), cat(13, "外链", 3)]), diff --git a/src/views/cms/homeLayout/homeLayout.ts b/src/views/cms/homeLayout/homeLayout.ts index 149f73e..8cc42f4 100644 --- a/src/views/cms/homeLayout/homeLayout.ts +++ b/src/views/cms/homeLayout/homeLayout.ts @@ -1,14 +1,22 @@ /** * 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。 * 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。 - * 与后端契约对齐:type 白名单(article_list/ticker/gallery/links)、size 1~20、title/subtitle ≤100 字; + * 与后端契约对齐:type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、 + * title/subtitle ≤100 字、quick_links items ≤12 项(name ≤20 字、url 须 http(s):// 或 / 开头)、 + * notice_bar text 必填 ≤200 字、level ∈ {info,warn}; * 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。 * vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。 */ import { CategoryTreeNode } from "@/types/cms"; -/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */ -export type BlockType = "article_list" | "ticker" | "gallery" | "links"; +/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */ +export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar"; + +/** quick_links 单个入口(v2 手册 §2.8) */ +export interface QuickLinkItem { + name: string; + url: string; +} /** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */ export const LIST_STYLES: { value: string; label: string }[] = [ @@ -44,6 +52,14 @@ export interface LayoutBlock { source?: string; /** links 分组名(空=全部分组) */ group?: string; + /** quick_links 入口列表(≤12 项;name ≤20 字、url 为 http(s):// 或站内 / 路径) */ + items?: QuickLinkItem[]; + /** notice_bar 公告正文(必填,≤200 字) */ + text?: string; + /** notice_bar 跳转地址(可选) */ + url?: string; + /** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */ + level?: string; } export interface HomeLayout { @@ -53,12 +69,20 @@ export interface HomeLayout { export const HOME_LAYOUT_VERSION = 1; -/** 服务端同款约束(SiteService §4.6) */ -export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"]; +/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */ +export const BLOCK_TYPES_ALLOWED: BlockType[] = [ + "article_list", "ticker", "gallery", "links", "quick_links", "notice_bar", +]; export const SIZE_MIN = 1; export const SIZE_MAX = 20; export const TITLE_MAX = 100; export const MAX_BLOCKS = 30; +export const QUICK_LINK_MAX = 12; +export const LINK_NAME_MAX = 20; +export const NOTICE_TEXT_MAX = 200; +export const NOTICE_LEVELS = ["info", "warn"]; +/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */ +const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/; export function genBlockId(): string { return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4); @@ -109,6 +133,24 @@ function toIdList(v: unknown): number[] | undefined { return ids.length ? ids : undefined; } +/** 入口列表容错还原:非对象项(字符串/数字/null)与非字符串 name/url 的脏行整行丢弃 */ +function toLinkItems(v: unknown): QuickLinkItem[] | undefined { + if (!Array.isArray(v)) { + return undefined; + } + const items: QuickLinkItem[] = []; + for (const raw of v as unknown[]) { + const obj = asObject(raw); + const name = typeof obj?.["name"] === "string" ? (obj["name"] as string).trim() : null; + const url = typeof obj?.["url"] === "string" ? (obj["url"] as string).trim() : null; + if (name === null || url === null) { + continue; + } + items.push({ name, url }); + } + return items.length ? items : undefined; +} + function normalizeBlock(obj: Record, type: BlockType): LayoutBlock { const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined; const block: LayoutBlock = { @@ -123,6 +165,15 @@ function normalizeBlock(obj: Record, type: BlockType): LayoutBl if (typeof obj["background"] === "boolean") block.background = obj["background"]; if (typeof obj["source"] === "string") block.source = obj["source"]; if (typeof obj["group"] === "string") block.group = obj["group"]; + if (type === "quick_links") { + const items = toLinkItems(obj["items"]); + if (items) block.items = items; + } + if (type === "notice_bar") { + if (typeof obj["text"] === "string") block.text = obj["text"]; + if (typeof obj["url"] === "string") block.url = obj["url"]; + if (typeof obj["level"] === "string") block.level = obj["level"]; + } const ids = toIdList(obj["categoryIds"]); if (ids) block.categoryIds = ids; return block; @@ -171,6 +222,26 @@ export function cleanBlock(block: LayoutBlock): Record { if (group) { out["group"] = group; } + } else if (block.type === "quick_links") { + // 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备) + const items = (block.items || []) + .map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() })) + .filter(i => i.name || i.url); + if (items.length) { + out["items"] = items; + } + } else if (block.type === "notice_bar") { + const text = (block.text || "").trim(); + if (text) { + out["text"] = text; + } + const url = (block.url || "").trim(); + if (url) { + out["url"] = url; + } + if (block.level === "warn") { + out["level"] = "warn"; + } } return out; } @@ -228,6 +299,41 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] { if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) { errors.push(`${no}:指定栏目模式请选择栏目`); } + if (b.type === "quick_links") { + const items = b.items || []; + if (!items.length) { + errors.push(`${no}:请至少填写 1 个入口`); + } else if (items.length > QUICK_LINK_MAX) { + errors.push(`${no}:入口数量不能超过 ${QUICK_LINK_MAX} 个`); + } + items.forEach(item => { + const name = (item.name || "").trim(); + const linkUrl = (item.url || "").trim(); + if (!name) { + errors.push(`${no}:入口名称不能为空`); + } else if (name.length > LINK_NAME_MAX) { + errors.push(`${no}:入口名称不能超过 ${LINK_NAME_MAX} 字`); + } + if (!LINK_URL_PATTERN.test(linkUrl)) { + errors.push(`${no}:入口「${name || linkUrl}」地址须为 http(s):// 开头或站内路径(/ 开头)`); + } + }); + } + if (b.type === "notice_bar") { + const text = (b.text || "").trim(); + if (!text) { + errors.push(`${no}:请填写公告正文`); + } else if (text.length > NOTICE_TEXT_MAX) { + errors.push(`${no}:公告正文不能超过 ${NOTICE_TEXT_MAX} 字`); + } + const linkUrl = (b.url || "").trim(); + if (linkUrl && !LINK_URL_PATTERN.test(linkUrl)) { + errors.push(`${no}:跳转地址须为 http(s):// 开头或站内路径(/ 开头)`); + } + if (b.level != null && !NOTICE_LEVELS.includes(b.level)) { + errors.push(`${no}:提示级别只能是 info 或 warn`); + } + } }); return errors; }