diff --git a/mock/index.ts b/mock/index.ts index 39b8943..3f46ce0 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -626,14 +626,15 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string /* ---- 文章 ---- */ if (method === "GET" && path === "/api/article/page") { const siteId = num(q(req, "siteId"), 0)!; - const categoryId = num(q(req, "categoryId")); + // 与后端 ArticlePageQuery 契约一致:categoryIds(逗号分隔多选;栏目工作台传单值) + const categoryId = num(q(req, "categoryIds")); const status = num(q(req, "status")); const keyword = q(req, "keyword") || ""; const page = num(q(req, "page"), 1)!; const pageSize = num(q(req, "pageSize"), 10)!; - const categoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null; + const filterCategoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null; let rows = db.articles.filter(a => a.siteId === siteId); - if (categoryIds) rows = rows.filter(a => categoryIds.includes(a.categoryId)); + if (filterCategoryIds) rows = rows.filter(a => filterCategoryIds.includes(a.categoryId)); if (status !== undefined) rows = rows.filter(a => a.status === status); if (keyword) rows = rows.filter(a => a.title.includes(keyword) || a.summary.includes(keyword)); rows = [...rows].sort( diff --git a/src/__tests__/categoryWorkbench.spec.ts b/src/__tests__/categoryWorkbench.spec.ts index 6dfe5de..6307b58 100644 --- a/src/__tests__/categoryWorkbench.spec.ts +++ b/src/__tests__/categoryWorkbench.spec.ts @@ -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: [] }); diff --git a/src/__tests__/siteLayer.spec.ts b/src/__tests__/siteLayer.spec.ts new file mode 100644 index 0000000..31b2cbe --- /dev/null +++ b/src/__tests__/siteLayer.spec.ts @@ -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 | null = null; + +function setRoute(path: string, params: Record, 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"); + }); +}); diff --git a/src/__tests__/siteManage.spec.ts b/src/__tests__/siteManage.spec.ts new file mode 100644 index 0000000..e10c81b --- /dev/null +++ b/src/__tests__/siteManage.spec.ts @@ -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(".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"); + }); +}); diff --git a/src/__tests__/siteRouter.spec.ts b/src/__tests__/siteRouter.spec.ts new file mode 100644 index 0000000..fc184a7 --- /dev/null +++ b/src/__tests__/siteRouter.spec.ts @@ -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; + +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"); + }); +}); diff --git a/src/__tests__/themeManage.spec.ts b/src/__tests__/themeManage.spec.ts index 04febc2..5d4ea13 100644 --- a/src/__tests__/themeManage.spec.ts +++ b/src/__tests__/themeManage.spec.ts @@ -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"); + }); }); diff --git a/src/components/BatchAddCategory.vue b/src/components/BatchAddCategory.vue new file mode 100644 index 0000000..671c0cd --- /dev/null +++ b/src/components/BatchAddCategory.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/components/SchoolLayout.vue b/src/components/SchoolLayout.vue index 82ae571..5349d0b 100644 --- a/src/components/SchoolLayout.vue +++ b/src/components/SchoolLayout.vue @@ -1,14 +1,20 @@ @@ -305,14 +353,18 @@ watch(
-
+
学校列表 - + - {{ school.schName || school.mainSiteName }} + {{ school.schName || school.mainSiteName }} + + + +