feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
build / build (push) Successful in 2m43s
build / build (push) Successful in 2m43s
This commit is contained in:
@@ -14,11 +14,13 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
|
||||
const { pageMock, routerPush, openMock, saveMock } = vi.hoisted(() => ({
|
||||
const { pageMock, routerPush, openMock, saveMock, saveRejection } = vi.hoisted(() => ({
|
||||
pageMock: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
saveMock: vi.fn(),
|
||||
/** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */
|
||||
saveRejection: { value: -1 },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
@@ -35,8 +37,14 @@ vi.mock("@/api/module/cms", () => ({
|
||||
categoryApi: {
|
||||
tree: () => ({ send: () => Promise.resolve([]) }),
|
||||
save: (payload: any) => {
|
||||
const nth = saveMock.mock.calls.length;
|
||||
saveMock(payload);
|
||||
return { send: () => Promise.resolve({}) };
|
||||
return {
|
||||
send: () =>
|
||||
saveRejection.value === nth
|
||||
? Promise.reject(new Error("栏目名称已存在"))
|
||||
: Promise.resolve({ id: 100 + nth }),
|
||||
};
|
||||
},
|
||||
delete: () => ({ send: () => Promise.resolve({}) }),
|
||||
},
|
||||
@@ -79,6 +87,7 @@ vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
|
||||
import ArticleListPanel from "@/components/article/ArticleListPanel.vue";
|
||||
import CategoryManage from "@/views/cms/CategoryManage.vue";
|
||||
import BatchAddCategory from "@/components/BatchAddCategory.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useCmsStore } from "@/store";
|
||||
@@ -137,7 +146,7 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryId: 5 });
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 });
|
||||
// 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目")
|
||||
expect(el.textContent).not.toContain("全部栏目");
|
||||
expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true);
|
||||
@@ -156,11 +165,11 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
await flush();
|
||||
|
||||
expect(pageMock).toHaveBeenCalledTimes(2);
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryId: 7 });
|
||||
expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 });
|
||||
});
|
||||
|
||||
it("⑤ 面板新建文章:路由跳转带 categoryId query", async () => {
|
||||
const state = reactive({ cid: 5, fixed: true });
|
||||
it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => {
|
||||
const state = reactive({ cid: 5, fixed: false });
|
||||
const host = defineComponent({
|
||||
setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }),
|
||||
});
|
||||
@@ -172,7 +181,13 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () =
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit?categoryId=5");
|
||||
// 全局列表模式:新建不带栏目预选(栏目由用户在下拉自选)
|
||||
expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit");
|
||||
|
||||
// 固定栏目模式:新建入口由 CategoryManage 面板头承担,面板内不再渲染
|
||||
state.fixed = true;
|
||||
await flush();
|
||||
expect([...el.querySelectorAll("button")].some(b => b.textContent?.includes("新建文章"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,7 +226,7 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
|
||||
expect(el.querySelector(".article-list-panel")).toBeTruthy();
|
||||
expect(pageMock).toHaveBeenCalled();
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryId: 5 });
|
||||
expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryIds: 5 });
|
||||
});
|
||||
|
||||
it("未选中节点:右侧显示空态引导", async () => {
|
||||
@@ -221,6 +236,65 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryWorkbench - BatchAddCategory(批量添加栏目)", () => {
|
||||
/** 打开批量添加弹窗并填入多行名称(Modal transfer 到 body,按 .ivu-modal 定位) */
|
||||
async function openBatchWith(names: string, props: any = {}) {
|
||||
const onSuccess = vi.fn();
|
||||
const host = defineComponent({
|
||||
setup: () => () =>
|
||||
h(BatchAddCategory, {
|
||||
visible: true,
|
||||
defaultPid: 5,
|
||||
parentName: "新闻中心",
|
||||
defaultType: 1,
|
||||
onSuccess,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
const el = mountApp(host);
|
||||
await flush();
|
||||
|
||||
const textarea = document.querySelector(".ivu-modal textarea") as HTMLTextAreaElement;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
|
||||
setter.call(textarea, names);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const submit = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("确认"));
|
||||
submit!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush(8);
|
||||
return { onSuccess, el };
|
||||
}
|
||||
|
||||
it("多行名称逐条循环调 save,按行序写 sortNo=1..n,成功后回调", async () => {
|
||||
saveRejection.value = -1;
|
||||
const { onSuccess } = await openBatchWith("通知公告\n规章制度\n三务公开");
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 5, name: "通知公告", type: 1, sortNo: 1 });
|
||||
expect(saveMock.mock.calls[1][0]).toMatchObject({ name: "规章制度", sortNo: 2 });
|
||||
expect(saveMock.mock.calls[2][0]).toMatchObject({ name: "三务公开", sortNo: 3 });
|
||||
expect(onSuccess).toHaveBeenCalledWith(102);
|
||||
});
|
||||
|
||||
it("空行自动忽略;某行失败不影响后续行,结束统一告警", async () => {
|
||||
saveRejection.value = 1; // 第 2 条拒绝
|
||||
const warnSpy = vi.spyOn(Message, "warning");
|
||||
const { onSuccess } = await openBatchWith("通知公告\n\n规章制度\n三务公开");
|
||||
|
||||
expect(saveMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
saveRejection.value = -1;
|
||||
});
|
||||
|
||||
it("未输入名称时不发请求", async () => {
|
||||
const callsBefore = saveMock.mock.calls.length;
|
||||
await openBatchWith(" ");
|
||||
expect(saveMock.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("W2 栏目树修订(T-W2-06..10)", () => {
|
||||
const selectRoot = async () => {
|
||||
(globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] });
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* docs/13 方案 C 站点分层(URL 站点段 + 站点身份条):
|
||||
* ① 深链子站 URL → store.currentSite 同步为子站并可感知(徽章/色带);
|
||||
* ② 非法/跨校/主站编码当子站段 → 告警并打回同页主站形态;
|
||||
* ③ URL 无站点段即主站(localStorage 记忆不再作导航决策);
|
||||
* ④ 身份条下拉一步切换:只改 URL、停留当前功能页、点后收起;
|
||||
* ⑤ 子站形态菜单高亮剥段匹配(菜单 page 保持主站形态)。
|
||||
*
|
||||
* 沿用手写 createApp 挂载 + vi.mock(vue-router) 桩风格(与 categoryWorkbench.spec.ts 一致);
|
||||
* route 用 reactive 包装以驱动 SchoolLayout 的 watch(fullPath)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||
|
||||
const box = vi.hoisted(() => ({
|
||||
route: null as any,
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => box.route,
|
||||
useRouter: () => ({ push: box.push, replace: box.replace, go: box.go, getRoutes: () => [] }),
|
||||
}));
|
||||
|
||||
import SchoolLayout from "@/components/SchoolLayout.vue";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
{ id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
let currentStore: ReturnType<typeof useCmsStore> | null = null;
|
||||
|
||||
function setRoute(path: string, params: Record<string, string>, meta: any = { title: "文章列表", modId: 11 }) {
|
||||
box.route = reactive({ path, fullPath: path, params, query: {}, hash: "", meta });
|
||||
}
|
||||
|
||||
/** 挂载学校壳;restoredSite 模拟刷新首帧从 localStorage 恢复的站点残留 */
|
||||
function mountLayout(restoredSite?: any) {
|
||||
const pinia = createPinia();
|
||||
const store = useCmsStore(pinia);
|
||||
currentStore = store;
|
||||
store.user = { superAdmin: true, sites: SITES } as any;
|
||||
store.currentSchool = {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo",
|
||||
mainSiteName: "云海大学门户",
|
||||
} as any;
|
||||
store.currentSite = (restoredSite || { id: 1, code: "demo", name: "云海大学门户" }) as any;
|
||||
store.session.modList = [{ id: 11, name: "文章列表", modId: 11, page: "/article" }];
|
||||
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(SchoolLayout) }));
|
||||
app.use(pinia);
|
||||
app.use(ViewUIPlus);
|
||||
app.component("router-view", defineComponent({ render: () => h("div", { class: "rv-stub" }) }));
|
||||
// BreadcrumbItem 传 to 时经 mixins-link 解析链接,需要 $router 桩(测试不引入真实路由实例)
|
||||
app.config.globalProperties.$router = {
|
||||
resolve: (to: any) => ({ href: "#" + (typeof to === "string" ? to : to?.path || "") }),
|
||||
push: box.push,
|
||||
replace: box.replace,
|
||||
};
|
||||
app.config.globalProperties.$route = box.route;
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
async function flush(frames = 5) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await nextTick();
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
currentStore = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("docs/13 站点分层:URL 站点段 ↔ 上下文同步", () => {
|
||||
it("① 深链子站 URL:currentSite 同步为子站,徽章「院系子站」+ 橙色带 + 面包屑站点段", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(2);
|
||||
expect(currentStore!.currentSite!.code).toBe("jdxy");
|
||||
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("院系子站");
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
|
||||
// 面包屑:学校列表 > 云海大学 > [院系子站] 机电学院 > 文章列表
|
||||
expect(el.textContent).toContain("云海大学");
|
||||
expect(el.textContent).toContain("机电学院");
|
||||
expect(el.textContent).toContain("文章列表");
|
||||
});
|
||||
|
||||
it("② 非法/跨校 siteCode:告警并打回同页主站形态,上下文归主站", async () => {
|
||||
const warn = vi.spyOn(Message, "warning");
|
||||
setRoute("/sch/demo/site/nope/category", { schCode: "demo", siteCode: "nope" }, { title: "栏目管理", modId: 2 });
|
||||
mountLayout();
|
||||
await flush();
|
||||
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/category" }));
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
});
|
||||
|
||||
it("②b 把主站编码当子站段(/site/demo/)同样视为非法并打回", async () => {
|
||||
setRoute("/sch/demo/site/demo/article", { schCode: "demo", siteCode: "demo" });
|
||||
mountLayout();
|
||||
await flush();
|
||||
expect(box.replace).toHaveBeenCalledWith(expect.objectContaining({ path: "/sch/demo/article" }));
|
||||
});
|
||||
|
||||
it("③ URL 无站点段即主站:localStorage 里残留的子站上下文不再恢复", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout({ id: 2, code: "jdxy", name: "机电学院" });
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-main");
|
||||
expect(el.querySelector(".site-identity .bdg")!.textContent).toContain("校主站");
|
||||
});
|
||||
|
||||
it("②c 切换 URL 到子站:同步跟随(同一壳内导航,五联动中的 store/徽章/色带)", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
expect(currentStore!.currentSite!.id).toBe(1);
|
||||
|
||||
const p = "/sch/demo/site/wtxy/article";
|
||||
box.route.path = p;
|
||||
box.route.fullPath = p;
|
||||
box.route.params = { schCode: "demo", siteCode: "wtxy" };
|
||||
await flush();
|
||||
|
||||
expect(currentStore!.currentSite!.id).toBe(3);
|
||||
expect(el.querySelector(".path-nav")!.className).toContain("band-sub");
|
||||
expect(el.textContent).toContain("文学院");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 站点身份条:SiteSwitcher 一步切换", () => {
|
||||
it("④ 展开下拉 → 点击子站行 → push 当前功能页的子站形态 URL 并收起", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const dd = el.querySelector(".site-switcher-dd");
|
||||
expect(dd).toBeTruthy();
|
||||
// 分组结构:主站 1 行 + 院系子站 2 行
|
||||
expect(el.querySelectorAll(".site-switcher-dd .dd-item")).toHaveLength(3);
|
||||
expect(dd!.textContent).toContain("院系子站(2)");
|
||||
|
||||
const jdxy = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("机电学院")
|
||||
)!;
|
||||
jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
// 停留当前功能页(article),只改 URL;store 由路由同步接管
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article");
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeNull();
|
||||
});
|
||||
|
||||
it("④b 子站态下切回主站:URL 省略站点段;Escape 收起面板", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/category", { schCode: "demo", siteCode: "jdxy" }, { title: "栏目管理", modId: 2 });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
const mainRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("云海大学门户")
|
||||
)!;
|
||||
mainRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/category");
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeTruthy();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await flush();
|
||||
expect(el.querySelector(".site-switcher-dd")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 菜单高亮:剥段匹配", () => {
|
||||
it("⑤ 子站形态下菜单高亮命中;列表子页面面包屑链接带站点段(不落回主站)", async () => {
|
||||
setRoute(
|
||||
"/sch/demo/site/jdxy/article/edit",
|
||||
{ schCode: "demo", siteCode: "jdxy" },
|
||||
{ title: "新建文章", modId: 11, subOf: "/article" }
|
||||
);
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
const li = [...el.querySelectorAll(".left-menu li")].find(x => x.textContent?.includes("文章列表"));
|
||||
expect(li).toBeTruthy();
|
||||
expect(li!.className).toContain("active");
|
||||
// 面包屑「文章列表」为可点段(非末段),链接须带 /site/jdxy 以停留子站
|
||||
const crumb = [...el.querySelectorAll(".path-nav a")].find(a => a.textContent?.includes("文章列表"));
|
||||
expect(crumb).toBeTruthy();
|
||||
expect(crumb!.getAttribute("href")).toContain("/sch/demo/site/jdxy/article");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 站点上下文持久化:刷新首帧兜底(docs/15 T3)", () => {
|
||||
it("⑥ setCurrentSite 写入 localStorage;新 pinia restoreSite 恢复同值", () => {
|
||||
const pinia1 = createPinia();
|
||||
setActivePinia(pinia1);
|
||||
const s1 = useCmsStore(pinia1);
|
||||
s1.setCurrentSite({ id: 2, code: "jdxy", path: "demo/jdxy", name: "机电学院" } as any);
|
||||
expect(JSON.parse(localStorage.getItem("cms.currentSite")!).code).toBe("jdxy");
|
||||
|
||||
const pinia2 = createPinia();
|
||||
setActivePinia(pinia2);
|
||||
const s2 = useCmsStore(pinia2);
|
||||
s2.restoreSite();
|
||||
expect(s2.currentSite?.id).toBe(2);
|
||||
expect(s2.currentSite?.code).toBe("jdxy");
|
||||
});
|
||||
|
||||
it("⑥b setCurrentSite(null) 清除持久化;无残留时 restoreSite 归 null", () => {
|
||||
const pinia1 = createPinia();
|
||||
setActivePinia(pinia1);
|
||||
const s1 = useCmsStore(pinia1);
|
||||
s1.setCurrentSite({ id: 2, code: "jdxy", name: "机电学院" } as any);
|
||||
s1.setCurrentSite(null);
|
||||
expect(localStorage.getItem("cms.currentSite")).toBeNull();
|
||||
|
||||
const pinia2 = createPinia();
|
||||
setActivePinia(pinia2);
|
||||
const s2 = useCmsStore(pinia2);
|
||||
s2.restoreSite();
|
||||
expect(s2.currentSite).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => {
|
||||
it("⑦ 主站态「前往站点管理」:URL 不带段", async () => {
|
||||
setRoute("/sch/demo/article", { schCode: "demo" });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site");
|
||||
});
|
||||
|
||||
it("⑦b 子站态「前往站点管理」保持站点段;点当前站点行不重复 push(幂等)", async () => {
|
||||
setRoute("/sch/demo/site/jdxy/banner", { schCode: "demo", siteCode: "jdxy" }, { title: "轮播管理", modId: 3 });
|
||||
const el = mountLayout();
|
||||
await flush();
|
||||
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
// 幂等:点当前子站行,目标 URL === 当前 URL,不产生 push
|
||||
const jdxyRow = [...el.querySelectorAll(".site-switcher-dd .dd-item")].find(r =>
|
||||
r.textContent?.includes("机电学院")
|
||||
)!;
|
||||
jdxyRow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).not.toHaveBeenCalled();
|
||||
|
||||
// 再展开 → 底部入口带段保持当前站点上下文
|
||||
el.querySelector(".site-identity .bdg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
el.querySelector(".site-switcher-dd .dd-foot")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 站点管理页回归(2026-09-10 修复批次):
|
||||
* ① 行「编辑」打开编辑弹窗——修复 themeUnregistered ref 声明缺失导致 openEdit 抛
|
||||
* ReferenceError、弹窗无法打开(编辑功能整体失效)的回归;
|
||||
* ② 已注册皮肤不出现"未注册"告警;未注册皮肤保留原值并出现告警(任务三行为)。
|
||||
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 themeManage.spec.ts 一致)。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ViewUIPlus, { Tag } from "view-ui-plus";
|
||||
|
||||
// SiteManage 的 columns 渲染函数裸用 Tag(vite dev 由 unplugin-auto-import 注入,
|
||||
// vitest 管线不走该插件)——这里挂到全局作用域供其解析
|
||||
(globalThis as any).Tag = Tag;
|
||||
|
||||
const { siteListMock, themeListMock } = vi.hoisted(() => ({
|
||||
siteListMock: vi.fn(),
|
||||
themeListMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const siteRow = {
|
||||
id: 1,
|
||||
code: "demo",
|
||||
path: "demo",
|
||||
parentSiteId: 0,
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
name: "云海大学门户网站",
|
||||
theme: "default",
|
||||
configJson: '{"logo_text":"云海大学"}',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const themeRow = {
|
||||
name: "default",
|
||||
title: "高校蓝(默认)",
|
||||
description: "",
|
||||
screenshotUrl: "/api/theme/default/asset/screenshot.png",
|
||||
params: [{ key: "primary", label: "主色", type: "color", defaultValue: "#2d8cf0" }],
|
||||
source: "builtin",
|
||||
inUseCount: 1,
|
||||
editable: true,
|
||||
usedBySiteNames: ["云海大学门户网站"],
|
||||
};
|
||||
|
||||
vi.mock("@/api/module/cms", () => ({
|
||||
siteApi: { list: () => ({ send: siteListMock }) },
|
||||
themeApi: { list: () => ({ send: themeListMock }) },
|
||||
assetUrl: (u: string) => u,
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), 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";
|
||||
|
||||
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() {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp(defineComponent({ render: () => h(SiteManage) }));
|
||||
app.use(createPinia());
|
||||
app.use(ViewUIPlus);
|
||||
app.mount(el);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
el.remove();
|
||||
document.querySelectorAll(".ivu-modal-wrap").forEach(n => n.remove());
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 操作列图标按钮(iView 固定列克隆两份,取第一个命中的) */
|
||||
function clickRowAction(el: ParentNode, iconClass: string) {
|
||||
const btn = [...el.querySelectorAll(`.operating-btn.${iconClass}`)][0];
|
||||
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])));
|
||||
themeListMock.mockResolvedValue(JSON.parse(JSON.stringify([themeRow])));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.forEach(fn => fn());
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
describe("SiteManage 站点管理(2026-09-10 修复回归)", () => {
|
||||
it("① 行「编辑」打开编辑弹窗并回填数据(不再因 themeUnregistered 未声明而失效)", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const btn = clickRowAction(el, "icon-edit");
|
||||
expect(btn).toBeTruthy();
|
||||
await flush();
|
||||
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal).toBeTruthy();
|
||||
const codeInput = modal!.querySelector<HTMLInputElement>(".ivu-input");
|
||||
expect(codeInput?.value).toBe("demo");
|
||||
});
|
||||
|
||||
it("② 已注册皮肤无告警", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
// 已注册:default 在皮肤列表中 → 不出现告警
|
||||
clickRowAction(el, "icon-edit");
|
||||
await flush();
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal?.textContent).not.toContain("未在注册表中");
|
||||
});
|
||||
|
||||
it("③ 未注册皮肤保留原值且弹出告警(不静默归一 default)", async () => {
|
||||
siteListMock.mockResolvedValue(
|
||||
JSON.parse(JSON.stringify([{ ...siteRow, theme: "skin-x" }]))
|
||||
);
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
clickRowAction(el, "icon-edit");
|
||||
await flush();
|
||||
const modal = modalByTitle("编辑站点");
|
||||
expect(modal?.textContent).toContain("未在注册表中");
|
||||
expect(modal?.textContent).toContain("skin-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* docs/13 方案 C 复检:真实 vue-router 集成(docs/15 §3 T1/T4)。
|
||||
* siteLayer.spec.ts 全程 router 桩(vi.mock vue-router),覆盖不到镜像层真实匹配与守卫重定向,
|
||||
* 本文件装载 routes.ts 全量路由 + router/index.ts 守卫补位:
|
||||
* ① 路由结构:/site/{code} 无 component 镜像层存在、镜像去 name、meta.modId 保留、空段 redirect 兜底;
|
||||
* ② 镜像层真实 resolve 命中(渲染层由 router-view 对无 component 记录跳过推进,此处验匹配链);
|
||||
* ③ 守卫:子站编码当学校段 → 定向主站前缀且余部原样保留(含站点段),并恢复学校上下文;
|
||||
* ④ legacyRedirect 旧链带 id 重定向。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useCmsStore } from "@/store";
|
||||
import router from "@/router";
|
||||
import { routes } from "@/router/routes";
|
||||
|
||||
const SITES = [
|
||||
{ id: 1, code: "demo", name: "云海大学门户", parentSiteId: 0, schId: 1001, schName: "云海大学" },
|
||||
{ id: 2, code: "jdxy", name: "机电学院", parentSiteId: 1, schId: 1001, schName: "云海大学" },
|
||||
];
|
||||
|
||||
let store: ReturnType<typeof useCmsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
store = useCmsStore(pinia);
|
||||
store.user = { superAdmin: true, sites: SITES } as any;
|
||||
});
|
||||
|
||||
describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => {
|
||||
it("① /site/{code} 无 component 镜像层存在;镜像去 name、meta.modId 保留、空段 redirect 到 article", () => {
|
||||
const sch = routes.find(r => r.path === "/sch/:schCode")!;
|
||||
const siteLayer = sch.children!.find(r => r.path === "site/:siteCode") as any;
|
||||
expect(siteLayer).toBeTruthy();
|
||||
expect(siteLayer.component).toBeUndefined();
|
||||
expect(siteLayer.children![0].path).toBe("");
|
||||
expect(siteLayer.children![0].redirect).toBeTruthy();
|
||||
const mirror = siteLayer.children!.find((c: any) => c.path === "category");
|
||||
expect(mirror!.name).toBeUndefined();
|
||||
expect((mirror!.meta as any).modId).toBe(2);
|
||||
});
|
||||
|
||||
it("①b 主站清单与镜像层不重名(防重复注册告警)", () => {
|
||||
expect(router.getRoutes().filter(r => r.name === "CategoryManage")).toHaveLength(1);
|
||||
expect(router.getRoutes().filter(r => r.name === "ArticleList")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("② 子站 URL 真实 resolve 命中镜像记录;主站 URL 命中主记录", () => {
|
||||
const sub = router.resolve("/sch/demo/site/jdxy/category");
|
||||
expect(sub.matched).toHaveLength(3);
|
||||
expect(sub.name).toBeUndefined();
|
||||
expect(sub.meta.modId).toBe(2);
|
||||
|
||||
const main = router.resolve("/sch/demo/category");
|
||||
expect(main.matched).toHaveLength(2);
|
||||
expect(main.name).toBe("CategoryManage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/13 守卫与旧链兼容(真实 router.push,docs/15 T4)", () => {
|
||||
it("③ 子站编码当学校段 → 重定向主站前缀(余部保留)并恢复学校上下文", async () => {
|
||||
await router.push("/sch/jdxy/article");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/article");
|
||||
expect(store.currentSchool?.mainSiteCode).toBe("demo");
|
||||
});
|
||||
|
||||
it("③b 子站编码当学校段且 URL 已带站点段 → 站点段原样保留", async () => {
|
||||
await router.push("/sch/jdxy/site/jdxy/category");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/site/jdxy/category");
|
||||
expect(router.currentRoute.value.params.siteCode).toBe("jdxy");
|
||||
});
|
||||
|
||||
it("③c 学校编码不在用户可管理站点内 → 回工作台", async () => {
|
||||
await router.push("/sch/other-school/article");
|
||||
expect(router.currentRoute.value.path).toBe("/workbench");
|
||||
});
|
||||
|
||||
it("④ 旧链 /article/edit/:id → 当前学校上下文主站形态(id 后缀保留)", async () => {
|
||||
store.currentSchool = {
|
||||
schId: 1001,
|
||||
schName: "云海大学",
|
||||
mainSiteId: 1,
|
||||
mainSiteCode: "demo",
|
||||
mainSiteName: "云海大学门户",
|
||||
} as any;
|
||||
await router.push("/article/edit/9");
|
||||
expect(router.currentRoute.value.path).toBe("/sch/demo/article/edit/9");
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,11 @@ vi.mock("@/api/module/cms", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
|
||||
default: defineComponent({ props: ["sitePath", "theme", "themeParams"], render: () => h("div", "preview-stub") }),
|
||||
default: defineComponent({
|
||||
props: ["sitePath", "theme", "themeParams", "fill"],
|
||||
setup: (props: any) => () =>
|
||||
h("div", { "data-preview-stub": "true", "data-fill": String(props.fill !== undefined && props.fill !== false) }, "preview-stub"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
@@ -240,4 +244,21 @@ describe("ThemeManage 皮肤包管理(T-W1-22..24 + 任务二/四)", () => {
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(deriveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("2026-09-10: 预览弹窗全屏展示(fullscreen + 无 footer),预览框 fill 占满余量", async () => {
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const builtin = [...el.querySelectorAll(".card")][0];
|
||||
clickButtonByText(builtin, "预览");
|
||||
await flush();
|
||||
|
||||
const previewModal = modalContaining("内容源站点");
|
||||
expect(previewModal).toBeTruthy();
|
||||
expect(previewModal!.className).toContain("ivu-modal-fullscreen");
|
||||
expect(previewModal!.className).toContain("ivu-modal-fullscreen-no-footer");
|
||||
const stub = previewModal!.querySelector("[data-preview-stub]");
|
||||
expect(stub).toBeTruthy();
|
||||
expect(stub!.getAttribute("data-fill")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user