diff --git a/src/__tests__/categoryWorkbench.spec.ts b/src/__tests__/categoryWorkbench.spec.ts new file mode 100644 index 0000000..4817a63 --- /dev/null +++ b/src/__tests__/categoryWorkbench.spec.ts @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +/** + * B5 栏目工作台组件行为(方案 P1-2): + * ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递; + * ② 切换树节点 → 面板 watch categoryId 联动 reload; + * ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表; + * ④ 外链型 → linkUrl + 打开链接; + * ⑤ 新建文章按钮 → 路由跳转带 categoryId query。 + * + * 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, reactive } from "vue"; +import { createPinia } from "pinia"; +import ViewUIPlus from "view-ui-plus"; + +const { pageMock, routerPush, openMock } = vi.hoisted(() => ({ + pageMock: vi.fn(), + routerPush: vi.fn(), + openMock: vi.fn(), +})); + +vi.mock("@/api/module/cms", () => ({ + articleApi: { + page: (query: any) => { + pageMock(query); + return { send: () => Promise.resolve({ list: [], total: 0 }) }; + }, + publish: () => ({ send: () => Promise.resolve({}) }), + offline: () => ({ send: () => Promise.resolve({}) }), + topmost: () => ({ send: () => Promise.resolve({}) }), + delete: () => ({ send: () => Promise.resolve({}) }), + }, + categoryApi: { + tree: () => ({ send: () => Promise.resolve([]) }), + save: () => ({ send: () => Promise.resolve({}) }), + delete: () => ({ send: () => Promise.resolve({}) }), + }, + uploadUrl: () => "/api/attachment/upload", + portalBase: (code: string) => `/portal/${code}`, +})); + +vi.mock("vue-router", () => ({ + useRouter: () => ({ push: routerPush, replace: vi.fn() }), + useRoute: () => ({ params: {}, query: {} }), +})); + +/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点" */ +vi.mock("@/components/CategoryTreePanel.vue", () => ({ + default: defineComponent({ + props: ["curTreeNode", "treeApi", "defaultSearchVisible", "showCloseButton"], + emits: ["update:curTreeNode", "on-change", "on-tree-data"], + setup(_, { emit }) { + (globalThis as any).__treeEmit = (node: any) => { + emit("update:curTreeNode", node); + emit("on-change", node); + }; + return () => h("div", { class: "tree-stub" }, "tree-stub"); + }, + }), +})); + +vi.mock("@/components/CategorySortModal.vue", () => ({ + default: defineComponent({ props: ["visible", "parentId", "parentName", "children"], emits: ["saved"], render: () => null }), +})); + +vi.mock("@/components/SvgIcon.vue", () => ({ + default: defineComponent({ props: ["name"], render: () => h("span", "icon") }), +})); + +vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() })); + +import ArticleListPanel from "@/components/article/ArticleListPanel.vue"; +import CategoryManage from "@/views/cms/CategoryManage.vue"; +import { useCmsStore } from "@/store"; + +let cleanups: (() => void)[] = []; + +/** 挂载:真实 pinia(预置学校/站点上下文)+ 全局 view-ui-plus */ +function mountApp(root: Component) { + const pinia = createPinia(); + const store = useCmsStore(pinia); + store.user = { superAdmin: true, sites: [] } as any; + store.currentSchool = { schId: 1, schName: "演示学校", mainSiteId: 1, mainSiteCode: "demo1", mainSiteName: "演示主站" } as any; + store.currentSite = { id: 1, code: "demo1", name: "演示站" } as any; + const el = document.createElement("div"); + document.body.appendChild(el); + const app = createApp(root as any); + app.use(pinia); + app.use(ViewUIPlus); + 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(); + (globalThis as any).__treeEmit = undefined; + window.open = openMock as any; +}); + +afterEach(() => { + cleanups.forEach(fn => fn()); + cleanups = []; +}); + +describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () => { + it("① 固定栏目模式:categoryId 正确传给查询且隐藏栏目下拉(仅剩状态筛选)", async () => { + const state = reactive({ cid: 5, fixed: true }); + const host = defineComponent({ + setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }), + }); + const el = mountApp(host); + await flush(); + + expect(pageMock).toHaveBeenCalled(); + expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryId: 5 }); + // 栏目下拉隐藏(剩余 .ivu-select 为状态筛选与分页 sizer,占位文本不含"全部栏目") + expect(el.textContent).not.toContain("全部栏目"); + expect([...el.querySelectorAll(".ivu-select-placeholder")].every(p => !p.textContent?.includes("全部栏目"))).toBe(true); + }); + + it("② 切换树节点:categoryId 变化触发面板 reload(watch 联动)", async () => { + const state = reactive({ cid: 5, fixed: true }); + const host = defineComponent({ + setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }), + }); + mountApp(host); + await flush(); + expect(pageMock).toHaveBeenCalledTimes(1); + + state.cid = 7; + await flush(); + + expect(pageMock).toHaveBeenCalledTimes(2); + expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryId: 7 }); + }); + + it("⑤ 面板新建文章:路由跳转带 categoryId query", async () => { + const state = reactive({ cid: 5, fixed: true }); + const host = defineComponent({ + setup: () => () => h(ArticleListPanel, { categoryId: state.cid, fixedCategory: state.fixed }), + }); + const el = mountApp(host); + await flush(); + + const btn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("新建文章")); + expect(btn).toBeTruthy(); + btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article/edit?categoryId=5"); + }); +}); + +describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => { + it("③ 单页型栏目:显示摘要面板(含编辑内容入口),不渲染文章列表", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + (globalThis as any).__treeEmit({ id: 2, parentId: 0, name: "学校简介", type: 2, content: "
云海第一中学始建于 1958 年。
", sortNo: 0, status: 1 }); + await flush(); + + expect(el.textContent).toContain("学校简介"); + expect(el.textContent).toContain("云海第一中学始建于 1958 年"); + expect(el.textContent).toContain("编辑内容"); + expect(el.querySelector(".article-list-panel")).toBeNull(); + }); + + it("④ 外链型栏目:显示 linkUrl 与「打开链接」,点击新窗口打开", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + (globalThis as any).__treeEmit({ id: 3, parentId: 0, name: "教育部链接", type: 3, linkUrl: "https://www.moe.gov.cn", sortNo: 0, status: 1 }); + await flush(); + + expect(el.textContent).toContain("https://www.moe.gov.cn"); + const openBtn = [...el.querySelectorAll("button, a")].find(b => b.textContent?.includes("打开链接")); + expect(openBtn).toBeTruthy(); + openBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank"); + }); + + it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + (globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 }); + await flush(); + + expect(el.querySelector(".article-list-panel")).toBeTruthy(); + expect(pageMock).toHaveBeenCalled(); + expect(pageMock.mock.calls[0][0]).toMatchObject({ categoryId: 5 }); + }); + + it("未选中节点:右侧显示空态引导", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + expect(el.textContent).toContain("选择左侧栏目"); + }); +}); diff --git a/src/components/article/ArticleListPanel.vue b/src/components/article/ArticleListPanel.vue new file mode 100644 index 0000000..2c2b849 --- /dev/null +++ b/src/components/article/ArticleListPanel.vue @@ -0,0 +1,293 @@ + + + +类型:{{ typeDict.find(t => t.value === selected!.type)?.label }}
-外链:{{ selected.linkUrl }}
-单页内容:见门户预览
-排序号:{{ selected.sortNo }} · 状态:{{ selected.status === 1 ? "显示" : "隐藏" }}
-SEO 标题:{{ selected.seoTitle }}
-SEO 描述:{{ selected.seoDescription }}
-{{ summaryText(selected) }}
+SEO 标题:{{ selected.seoTitle }}
+SEO 描述:{{ selected.seoDescription }}
+跳转地址
+{{ selected.linkUrl }}
+