// @vitest-environment jsdom /** * 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归: * T-B-09 quick_links/notice_bar 属性面板行为不变; * F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、 * 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。 */ import { afterEach, beforeEach, 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 || (() => {}); const treeMock = vi.hoisted(() => ({ send: vi.fn() })); vi.mock("@/api/module/cms", () => ({ categoryApi: { tree: () => treeMock }, })); import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue"; import { CategoryTreeNode } from "@/types/cms"; 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" }, ], }, }); const TREE: CategoryTreeNode[] = [ { id: 11, name: "校园时讯", type: 1, children: [] }, { id: 12, name: "媒体聚焦", type: 1, children: [] }, ] as unknown as CategoryTreeNode[]; async function mountEditor( configJson: string, opts?: { siteId?: number; portalUrl?: string; siteIdRef?: ReturnType> }, ) { const el = document.createElement("div"); document.body.appendChild(el); const model = ref(configJson); const app = createApp( defineComponent({ render: () => h(HomeLayoutEditor, { modelValue: model.value, siteId: opts?.siteIdRef ? opts.siteIdRef.value : opts?.siteId, portalUrl: opts?.portalUrl, "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]; } beforeEach(() => { treeMock.send.mockReset(); treeMock.send.mockResolvedValue([]); }); afterEach(() => { cleanups.splice(0).forEach(fn => fn()); }); describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => { 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("醒目"); }); }); describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => { it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => { const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" }); expect(el.querySelector(".hle-sequence")).toBeTruthy(); expect(el.querySelector(".hle-props")).toBeTruthy(); const preview = el.querySelector(".hle-preview")!; expect(preview).toBeTruthy(); expect(preview.querySelector("iframe")).toBeTruthy(); expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/"); // 视口三档 expect(preview.textContent).toContain("桌面 1200"); expect(preview.textContent).toContain("平板 768"); expect(preview.textContent).toContain("手机 390"); const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览")); expect(refresh).toBeTruthy(); }); it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => { const { el } = await mountEditor(CONFIG); const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!; addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); await settle(); // Drawer 渲染到 body 级传送门 const drawer = document.querySelector(".hle-drawer") || document.body; const cards = drawer.querySelectorAll(".hle-type-card"); expect(cards.length).toBeGreaterThanOrEqual(6); cards.forEach(c => { expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy(); }); }); it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => { const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] }, }); const { el, model } = await mountEditor(config); (el.querySelector(".hle-row") as HTMLElement).click(); await settle(); const styleField = fieldOf(el as HTMLElement, "展示形态")!; expect(styleField.querySelector(".hle-style-cards")).toBeTruthy(); expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5); expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5); // 点击第 2 张卡(news_grid)→ 选中态 + 回写布局 (styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click(); await settle(); expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active"); expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid"); }); it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => { treeMock.send.mockResolvedValue(TREE); const noLayoutConfig = JSON.stringify({ logo: "/a.png" }); const { model } = await mountEditor(noLayoutConfig, { siteId: 1 }); const layout = JSON.parse(model.value).home_layout; expect(layout).toBeTruthy(); expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery expect(layout.blocks[0].type).toBe("ticker"); expect(layout.blocks.some((b: { type: string; title?: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true); expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true); // 其余键无损 expect(JSON.parse(model.value).logo).toBe("/a.png"); }); it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => { treeMock.send.mockResolvedValue(TREE); const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } }); const { model } = await mountEditor(emptyLayout, { siteId: 1 }); expect(JSON.parse(model.value).home_layout.blocks).toEqual([]); }); it("无列表栏目不触发自动编排", async () => { treeMock.send.mockResolvedValue([]); const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 }); expect(JSON.parse(model.value).home_layout).toBeUndefined(); }); }); describe("HomeLayoutEditor - 栏目树加载与首用编排解耦(docs/20 返工 #1)", () => { it("已有布局 + 有列表栏目 + siteId:栏目树仍无条件加载,数据栏目可勾选", async () => { treeMock.send.mockResolvedValue(TREE); const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] }, }); const { el, model } = await mountEditor(config, { siteId: 1 }); // 树加载发生(幂等标记下 treeMock 恰好 1 次调用) expect(treeMock.send).toHaveBeenCalledTimes(1); (el.querySelector(".hle-row") as HTMLElement).click(); await settle(); const cats = fieldOf(el as HTMLElement, "数据栏目")!; expect(cats).not.toBeNull(); expect(cats.querySelectorAll(".hle-cat").length).toBe(2); expect(cats.textContent).toContain("校园时讯"); expect(cats.textContent).toContain("媒体聚焦"); // 已有布局不被自动编排改写 expect(JSON.parse(model.value).home_layout.blocks[0].id).toBe("a1"); }); it("siteId 晚于挂载到位(父级异步种入):watch 补跑加载,数据栏目可用", async () => { treeMock.send.mockResolvedValue(TREE); const siteIdRef = ref(undefined); const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [], style: "card", size: 8 }] }, }); const { el } = await mountEditor(config, { siteIdRef }); await settle(); // siteId 未到位时不请求 expect(treeMock.send).not.toHaveBeenCalled(); siteIdRef.value = 1; await settle(6); expect(treeMock.send).toHaveBeenCalledTimes(1); (el.querySelector(".hle-row") as HTMLElement).click(); await settle(); const cats = fieldOf(el as HTMLElement, "数据栏目")!; expect(cats.querySelectorAll(".hle-cat").length).toBe(2); }); it("watch + onMounted 双触发下幂等:同 siteId 只请求一次", async () => { treeMock.send.mockResolvedValue(TREE); const siteIdRef = ref(1); await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteIdRef }); await settle(6); expect(treeMock.send).toHaveBeenCalledTimes(1); }); }); describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => { it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => { const config = JSON.stringify({ home_layout: { version: 1, blocks: [ { id: "s1", type: "stats_band", stats: [{ label: "在校生", value: "3.2", unit: "万" }] }, ] }, }); const { el } = await mountEditor(config); (el.querySelector(".hle-row") as HTMLElement).click(); await settle(); const field = fieldOf(el as HTMLElement, "统计项")!; expect(field.querySelector(".hle-stat-row")).toBeTruthy(); expect((field.querySelector(".hle-stat-row input") as HTMLInputElement)).toBeTruthy(); const add = field.querySelector(".hle-stat-add, .hle-link-add") as HTMLElement; add.click(); await settle(); expect(field.querySelectorAll(".hle-stat-row").length).toBe(2); }); it("gallery:布局形态下拉含瀑布流(campus_wall)选项且选中回写", async () => { const config = JSON.stringify({ home_layout: { version: 1, blocks: [{ id: "g1", type: "gallery" }] }, }); const { el, model } = await mountEditor(config); (el.querySelector(".hle-row") as HTMLElement).click(); await settle(); const field = fieldOf(el as HTMLElement, "布局形态")!; const select = field.querySelector(".ivu-select") as HTMLElement; expect(select).toBeTruthy(); // 展开下拉点选 masonry select.querySelector(".ivu-select-selection").dispatchEvent(new MouseEvent("click", { bubbles: true })); await settle(8); const opt = [...document.querySelectorAll(".ivu-select-item")].find(n => n.textContent?.includes("瀑布流")) as HTMLElement; expect(opt).toBeTruthy(); opt.click(); await settle(8); expect(JSON.parse(model.value).home_layout.blocks[0].layout).toBe("masonry"); }); });