[CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user