feat(cms): docs/16 站点设置整页化+首页布局编辑器- SiteSetting.vue整页7分区(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO与域名/扩展参数)锚点导航sticky+IntersectionObserver+分区卡片三段式每分区独立保存(configJson单一事实源+submitSection统一提交);创建模式仅①②可编辑其余锁定,创建成功原地replace到site/setting/:id;SiteWizard三步向导退役删除(门户预览收编portalPreviewUrl);SiteManage瘦身表格+行内编辑跳整页(adminPathOf子站带站点段);ImageAssetField图片双通道(上传+路径粘贴折叠);SiteConfigForm支持groups/showExtras/siteId+asset字段接ImageAssetField+三旧键表单不再展示(ABSORBED_LEGACY_KEYS静默保留);homeLayout三件套(homeLayout.ts模型parse/serialize/validate/genFromCategories一键生成+blockTypes.ts注册表showIf条件字段+HomeLayoutEditor.vue序列编辑增删显隐上移下移);routes site/new+site/setting/:id+legacy redirect;CategoryManage home_style提示改口指向首页布局;新增siteSetting.spec+homeLayout.spec,重写siteManage.spec,117用例全绿
build / build (push) Successful in 2m19s
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点管理页回归(2026-09-10 修复批次):
|
||||
* ① 行「编辑」打开编辑弹窗——修复 themeUnregistered ref 声明缺失导致 openEdit 抛
|
||||
* ReferenceError、弹窗无法打开(编辑功能整体失效)的回归;
|
||||
* ② 已注册皮肤不出现"未注册"告警;未注册皮肤保留原值并出现告警(任务三行为)。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 themeManage.spec.ts 一致)。
|
||||
* 站点管理列表页回归(docs/16 主线A 整页化后):
|
||||
* 行「编辑」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式;
|
||||
* 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
@@ -15,9 +13,10 @@ import ViewUIPlus, { Tag } from "view-ui-plus";
|
||||
// vitest 管线不走该插件)——这里挂到全局作用域供其解析
|
||||
(globalThis as any).Tag = Tag;
|
||||
|
||||
const { siteListMock, themeListMock } = vi.hoisted(() => ({
|
||||
const { siteListMock, themeListMock, routerPushMock } = vi.hoisted(() => ({
|
||||
siteListMock: vi.fn(),
|
||||
themeListMock: vi.fn(),
|
||||
routerPushMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const siteRow = {
|
||||
@@ -38,7 +37,7 @@ const themeRow = {
|
||||
title: "高校蓝(默认)",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
params: [],
|
||||
source: "builtin",
|
||||
inUseCount: 1,
|
||||
editable: true,
|
||||
@@ -52,27 +51,12 @@ vi.mock("@/api/module/cms", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useRouter: () => ({ push: routerPushMock, replace: vi.fn() }),
|
||||
useRoute: () => ({ params: {}, query: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({ props: ["themes", "modelValue"], render: () => h("div", "theme-picker-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["theme", "modelValue"], render: () => h("div", "theme-param-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({ props: ["modelValue"], render: () => h("div", "config-form-stub") }),
|
||||
}));
|
||||
vi.mock("@/views/cms/SiteWizard.vue", () => ({
|
||||
default: defineComponent({ props: ["visible", "sites", "lockParentId"], emits: ["created"], render: () => h("div", "wizard-stub") }),
|
||||
}));
|
||||
|
||||
import SiteManage from "@/views/cms/SiteManage.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
@@ -83,11 +67,15 @@ async function flush(frames = 6) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
function mountPage(storeSetup?: (store: any) => void) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const pinia = createPinia();
|
||||
if (storeSetup) {
|
||||
storeSetup(useCmsStore(pinia));
|
||||
}
|
||||
const app = createApp(defineComponent({ render: () => h(SiteManage) }));
|
||||
app.use(createPinia());
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
@@ -98,17 +86,13 @@ function mountPage() {
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 操作列图标按钮(iView 固定列克隆两份,取第一个命中的) */
|
||||
function clickRowAction(el: ParentNode, iconClass: string) {
|
||||
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][0];
|
||||
/** 操作列图标按钮(iView 固定列克隆两份;按行序取第 rowIndex+1 个命中) */
|
||||
function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) {
|
||||
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][rowIndex];
|
||||
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
return btn;
|
||||
}
|
||||
|
||||
function modalByTitle(title: string): HTMLElement | undefined {
|
||||
return [...document.querySelectorAll(".ivu-modal")].find(m => m.querySelector(".ivu-modal-header-inner")?.textContent === title);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||
@@ -120,8 +104,8 @@ afterEach(() => {
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteManage 站点管理(2026-09-10 修复回归)", () => {
|
||||
it("① 行「编辑」打开编辑弹窗并回填数据(不再因 themeUnregistered 未声明而失效)", async () => {
|
||||
describe("SiteManage 列表页(docs/16 整页化后)", () => {
|
||||
it("① 行「编辑」跳站点设置中心(主站形态无站点段)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
@@ -129,34 +113,33 @@ describe("SiteManage 站点管理(2026-09-10 修复回归)", () => {
|
||||
expect(btn).toBeTruthy();
|
||||
await flush();
|
||||
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal).toBeTruthy();
|
||||
const codeInput = modal!.querySelector<HTMLInputElement>(".ivu-input");
|
||||
expect(codeInput?.value).toBe("demo");
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1");
|
||||
});
|
||||
|
||||
it("② 已注册皮肤无告警", async () => {
|
||||
it("② 子站行「编辑」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => {
|
||||
siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([
|
||||
siteRow,
|
||||
{ ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" },
|
||||
])));
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
// 已注册:default 在皮肤列表中 → 不出现告警
|
||||
clickRowAction(el, "icon-edit");
|
||||
clickRowAction(el, "icon-edit", 1);
|
||||
await flush();
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal?.textContent).not.toContain("未在注册表中");
|
||||
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/jdxy/site/setting/2");
|
||||
});
|
||||
|
||||
it("③ 未注册皮肤保留原值且弹出告警(不静默归一 default)", async () => {
|
||||
siteListMock.mockResolvedValue(
|
||||
JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }]))
|
||||
);
|
||||
const el = mountPage();
|
||||
it("③「新建站点」按钮跳创建模式(site/new;超管无学校上下文回落首主站编码)", async () => {
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: true } as any;
|
||||
});
|
||||
await flush();
|
||||
|
||||
clickRowAction(el, "icon-edit");
|
||||
const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建站点"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal?.textContent).toContain("未在注册表中");
|
||||
expect(modal?.textContent).toContain("skin-x");
|
||||
|
||||
expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点设置中心(docs/16 主线A)挂载回归:
|
||||
* ① 创建模式:③~⑦锁定("创建站点后开放"×5)、主按钮=「创建站点」、
|
||||
* 校级锁定本校主站时门户地址预览随编码联动(原向导 T-W4-18 能力收编);
|
||||
* ② 编辑模式:基本信息回填、未注册皮肤告警保留原值(任务三行为迁入②分区);
|
||||
* ③ 分区保存:保存基本信息 = 当前 configJson 事实源全量提交(其他分区键不丢)。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 siteManage.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||
|
||||
const routeMock = vi.hoisted(() => ({ params: {} as Record<string, string>, query: {} as Record<string, string> }));
|
||||
const routerMock = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn() }));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
siteList: vi.fn(),
|
||||
siteSave: vi.fn(),
|
||||
themeList: vi.fn(),
|
||||
}));
|
||||
|
||||
const siteRow = {
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "demo",
|
||||
parentSiteId: 0,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "云海大学门户网站",
|
||||
theme: "default",
|
||||
configJson: '{"logo":"/cms/upload/logo.png","gallery_title":"线上展厅","home_layout":{"version":1,"blocks":[{"id":"b1","type":"links"}]}}',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const themeRow = {
|
||||
name: "default",
|
||||
title: "高校蓝(默认)",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
};
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
siteApi: {
|
||||
list: () => ({ send: mocks.siteList }),
|
||||
save: (data: any) => ({ send: () => mocks.siteSave(data) }),
|
||||
},
|
||||
themeApi: { list: () => ({ send: mocks.themeList }) },
|
||||
assetUrl: (u: string) => u,
|
||||
uploadUrl: () => "/api/attachment/upload",
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => routerMock,
|
||||
useRoute: () => routeMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({ props: ["themes", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-picker-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["theme", "modelValue"], emits: ["update:modelValue"], render: () => h("div", "theme-param-stub") }),
|
||||
}));
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue", "groups", "showExtras", "siteId"],
|
||||
methods: { validate: async () => true },
|
||||
render: () => h("div", "config-form-stub"),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/views/cms/homeLayout/HomeLayoutEditor.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue", "siteId"],
|
||||
emits: ["update:modelValue", "save"],
|
||||
methods: { validate: () => null },
|
||||
render: () => h("div", "layout-editor-stub"),
|
||||
}),
|
||||
}));
|
||||
|
||||
import SiteSetting from "@/views/cms/SiteSetting.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountPage(storeSetup?: (store: any) => void) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const pinia = createPinia();
|
||||
if (storeSetup) {
|
||||
// 挂载前把上下文注入同一 pinia(defineStore 幂等,组件内拿到同一实例)
|
||||
storeSetup(useCmsStore(pinia));
|
||||
}
|
||||
const app = createApp(defineComponent({ render: () => h(SiteSetting) }));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
routeMock.params = {};
|
||||
routeMock.query = {};
|
||||
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([siteRow])));
|
||||
mocks.themeList.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
|
||||
mocks.siteSave.mockImplementation(async (data: any) => ({ ...siteRow, ...data, id: data.id ?? 99 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteSetting 创建模式(site/new,向导退役收编)", () => {
|
||||
it("③~⑦分区锁定(创建站点后开放×5),主按钮=创建站点", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const tips = (el.textContent.match(/创建站点后开放/g) || []).length;
|
||||
expect(tips).toBe(5);
|
||||
expect(el.textContent).toContain("新建站点");
|
||||
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"));
|
||||
expect(createBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("无上级时门户地址预览随编码联动(/portal/{code}/)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
|
||||
expect(input).toBeTruthy();
|
||||
setInputValue(input, "jdxy");
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("/portal/jdxy/");
|
||||
});
|
||||
|
||||
it("校级建子站:上级锁定本校主站,预览=上级地址/编码(原向导 lockParent 语义)", async () => {
|
||||
const el = mountPage(store => {
|
||||
store.user = { superAdmin: false } as any;
|
||||
store.enterSchool(
|
||||
{ schId: 1001, schName: "云海大学", mainSiteId: 1, mainSiteCode: "demo", mainSiteName: "云海大学门户网站" },
|
||||
[{ id: 1, code: "demo", path: "demo", name: "云海大学门户网站" }]
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
|
||||
setInputValue(input, "jdxy");
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("/portal/demo/jdxy/");
|
||||
// 上级锁定为本校主站(Select disabled)
|
||||
const selectEl = el.querySelector(".ivu-select-disabled");
|
||||
expect(selectEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it("创建成功:replace 到站点设置页并解锁分区", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const input = el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!;
|
||||
setInputValue(input, "jdxy");
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "机械学院门户");
|
||||
await flush();
|
||||
|
||||
const createBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("创建站点"))!;
|
||||
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const payload = mocks.siteSave.mock.calls[0][0];
|
||||
expect(payload.code).toBe("jdxy");
|
||||
expect(payload.name).toBe("机械学院门户");
|
||||
expect(routerMock.replace).toHaveBeenCalledWith("/sch/jdxy/site/setting/99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteSetting 编辑模式(site/setting/:id)", () => {
|
||||
it("基本信息回填 + 已注册皮肤无告警", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
expect(el.querySelector<HTMLInputElement>('input[placeholder="URL 路径用,如 demo"]')!.value).toBe("demo");
|
||||
expect((el.textContent || "")).not.toContain("未在注册表中");
|
||||
expect(el.textContent).toContain("云海大学门户网站");
|
||||
expect(el.textContent).not.toContain("创建站点后开放");
|
||||
});
|
||||
|
||||
it("未注册皮肤保留原值并弹出告警(不静默归一 default)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
mocks.siteList.mockResolvedValue(JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }])));
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
expect(el.textContent).toContain("未在注册表中");
|
||||
expect(el.textContent).toContain("skin-x");
|
||||
});
|
||||
|
||||
it("仅保存基本信息:以当前 configJson 事实源全量提交(home_layout 等其他分区键随行)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "改名后的门户");
|
||||
await flush();
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("仅保存基本信息"))!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
const payload = mocks.siteSave.mock.calls[0][0];
|
||||
expect(payload.id).toBe(1);
|
||||
expect(payload.name).toBe("改名后的门户");
|
||||
expect(payload.theme).toBe("default");
|
||||
const config = JSON.parse(payload.configJson);
|
||||
expect(config.logo).toBe("/cms/upload/logo.png");
|
||||
expect(config.gallery_title).toBe("线上展厅"); // 被吸收旧键随事实源保留
|
||||
expect(config.home_layout.blocks).toEqual([{ id: "b1", type: "links" }]); // 首页布局分区未动
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* W4 向导地址预览联动(方案 §8.3 T-W4-18):
|
||||
* 第 3 步「门户地址预览」随上级与编码输入联动(校级 lockParent 模式预置上级=选上级)。
|
||||
* 挂载 SiteWizard(Modal transfer 到 body),桩掉主题三件套与配置表单。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const themesMock = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => [
|
||||
{
|
||||
name: "default",
|
||||
title: "云端默认",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
themeApi: { list: () => ({ send: themesMock.list }) },
|
||||
siteApi: { save: () => ({ send: async () => ({}) }) },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePicker.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["themes", "modelValue"],
|
||||
emits: ["update:modelValue", "pick"],
|
||||
setup(_, { emit }) {
|
||||
(globalThis as any).__wizardPickTheme = (name: string) => emit("pick", name);
|
||||
return () => h("div", { class: "theme-picker-stub" }, "themes");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemeParamForm.vue", () => ({
|
||||
default: defineComponent({ props: ["modelValue"], emits: ["update:modelValue"], render: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["url", "tick"], render: () => h("div", "preview-stub") }),
|
||||
}));
|
||||
|
||||
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
methods: { validate: async () => true },
|
||||
render: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import SiteWizard from "@/views/cms/SiteWizard.vue";
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
async function flush(frames = 6) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function mountWizard(lockParentId?: number, secondMain = false) {
|
||||
const sites = [
|
||||
{ id: 1, code: "sch-3", path: "sch-3", parentSiteId: 0, schId: 1, schName: "第一中学", name: "第一中学门户", status: 1 },
|
||||
...(secondMain
|
||||
? [{ id: 2, code: "sch-8", path: "sch-8", parentSiteId: 0, schId: 2, schName: "第八中学", name: "第八中学门户", status: 1 }]
|
||||
: []),
|
||||
];
|
||||
const visible = ref(false); // 挂载后翻转以触发 SiteWizard 的 visible watch(真实打开路径)
|
||||
const pinia = createPinia();
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(SiteWizard, {
|
||||
visible: visible.value,
|
||||
sites,
|
||||
lockParentId,
|
||||
"onUpdate:visible": (v: boolean) => { visible.value = v; },
|
||||
});
|
||||
},
|
||||
}));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
// SiteWizard 第 2 步的 FormItem 不在 Form 内(现状如此),jsdom 卸载时 view-ui-plus
|
||||
// 内部 removeField/colon 读取 undefined 崩溃 → 本测试用展示桩覆盖 FormItem
|
||||
app.component("FormItem", defineComponent({
|
||||
props: ["label", "prop", "labelWidth"],
|
||||
setup(props, { slots }) {
|
||||
return () => h("div", { class: "form-item-stub" }, [
|
||||
props.label ? h("label", props.label) : null,
|
||||
slots.default?.(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
app.mount(el);
|
||||
visible.value = true;
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function gotoStep3() {
|
||||
// step1 → step2 → step3(footer 按钮 transfer 到 body)
|
||||
const clickNext = async () => {
|
||||
await flush();
|
||||
const btn = [...document.querySelectorAll(".ivu-modal-footer button")]
|
||||
.find(b => b.textContent?.includes("下一步"));
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
};
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteWizard 门户地址预览(T-W4-18)", () => {
|
||||
it("选上级(lockParent 预置 sch-3)+ 输入 jdxy → 预览 /portal/sch-3/jdxy/", async () => {
|
||||
mountWizard(1);
|
||||
await gotoStep3();
|
||||
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "jdxy");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("门户地址预览");
|
||||
expect(document.body.textContent).toContain("/portal/sch-3/jdxy/");
|
||||
});
|
||||
|
||||
it("改上级即变:换到另一主站 sch-8 → 预览 /portal/sch-8/jdxy/", async () => {
|
||||
mountWizard(2, true);
|
||||
await gotoStep3();
|
||||
|
||||
const input = document.querySelector('.ivu-modal input[placeholder="URL 路径用,如 demo"]') as HTMLInputElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input, "jdxy");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("/portal/sch-8/jdxy/");
|
||||
expect(document.body.textContent).not.toContain("/portal/sch-3/jdxy/");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user