330 lines
16 KiB
TypeScript
330 lines
16 KiB
TypeScript
// @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<typeof ref<number | undefined>> },
|
||
) {
|
||
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<HTMLElement>(".hle-field")).find(
|
||
f => f.querySelector(".hle-field-label")?.textContent?.trim() === label,
|
||
) || null
|
||
);
|
||
}
|
||
|
||
/** 第 index 行的删除按钮 */
|
||
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,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<number | undefined>(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<number | undefined>(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");
|
||
});
|
||
});
|