[CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿)

This commit is contained in:
chenxing
2026-09-28 12:52:57 +08:00
parent af451703e7
commit 1a913490ef
5 changed files with 617 additions and 39 deletions
+105 -9
View File
@@ -1,21 +1,24 @@
// @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 桩风格。
* 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
* T-B-09 quick_links/notice_bar 属性面板行为不变;
* F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
* 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。
*/
import { afterEach, describe, expect, it, vi } from "vitest";
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: () => ({ send: async () => [] }) },
categoryApi: { tree: () => treeMock },
}));
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
import { CategoryTreeNode } from "@/types/cms";
const cleanups: (() => void)[] = [];
@@ -41,13 +44,24 @@ const CONFIG = JSON.stringify({
},
});
async function mountEditor(configJson: string) {
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 }) {
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) }),
render: () =>
h(HomeLayoutEditor, {
modelValue: model.value,
siteId: opts?.siteId,
portalUrl: opts?.portalUrl,
"onUpdate:modelValue": (v: string) => (model.value = v),
}),
}),
);
app.use(ViewUIPlus);
@@ -74,11 +88,16 @@ function del(field: HTMLElement, index: number): HTMLElement {
return field.querySelectorAll<HTMLElement>(".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)", () => {
describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => {
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
const { el, model } = await mountEditor(CONFIG);
// 序列行摘要走 blockSummary 新增分支
@@ -140,3 +159,80 @@ describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)",
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 }) => 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();
});
});