feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s

This commit is contained in:
chenxing
2026-09-22 18:34:50 +08:00
parent abccca4397
commit d05272b820
14 changed files with 893 additions and 186 deletions
+142
View File
@@ -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<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];
}
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("醒目");
});
});