From fc566eed8b013024fbbdbf3d6208c64ac516698c Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 7 Sep 2026 20:30:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(B5):=20=E6=A0=8F=E7=9B=AE=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0-=E6=A0=91+=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E9=9D=A2=E6=9D=BF,ArticleList=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=8C=96=E8=96=84=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 ArticleListPanel(props categoryId/fixedCategory):无props=全局检索行为不变(薄壳红线); 固定栏目模式隐藏栏目下拉,watch categoryId 联动 reload(拖拽排序不触发,红线) - CategoryManage 右侧:列表型=本栏目文章面板+新建文章(query.categoryId 预选)+预览; 单页型=摘要+编辑内容+预览;外链型=URL+打开链接;空态引导;工具栏增树折叠(窄屏) - ArticleEdit 支持 query.categoryId 新建态预选栏目 - 后端零改动(红线);vitest 32/32 绿(新增 categoryWorkbench.spec 7 例,手写挂载+vi.mock 桩) --- src/__tests__/categoryWorkbench.spec.ts | 209 ++++++++++++++ src/components/article/ArticleListPanel.vue | 293 ++++++++++++++++++++ src/views/cms/ArticleEdit.vue | 9 +- src/views/cms/ArticleList.vue | 266 +----------------- src/views/cms/CategoryManage.vue | 220 ++++++++++++++- 5 files changed, 723 insertions(+), 274 deletions(-) create mode 100644 src/__tests__/categoryWorkbench.spec.ts create mode 100644 src/components/article/ArticleListPanel.vue 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 @@ + + + + + diff --git a/src/views/cms/ArticleEdit.vue b/src/views/cms/ArticleEdit.vue index f3e1a79..2beccd7 100644 --- a/src/views/cms/ArticleEdit.vue +++ b/src/views/cms/ArticleEdit.vue @@ -112,7 +112,14 @@ async function load() { if (!store.siteId) return; const tree = await categoryApi.tree(store.siteId).send(); categoryOptions.value = flattenCategories(tree || []); - if (!editId.value) return; + if (!editId.value) { + // B5:栏目工作台「新建文章」带 categoryId 预选(仅新建态;编辑态以详情回填为准) + const preselect = route.query.categoryId ? parseInt(String(route.query.categoryId)) : undefined; + if (preselect && categoryOptions.value.some(o => o.id === preselect)) { + form.categoryId = preselect; + } + return; + } const detail = await articleApi.get(store.siteId, editId.value).send(); if (!detail) return; Object.assign(form, { diff --git a/src/views/cms/ArticleList.vue b/src/views/cms/ArticleList.vue index 09d1e18..3a1148a 100644 --- a/src/views/cms/ArticleList.vue +++ b/src/views/cms/ArticleList.vue @@ -1,270 +1,16 @@ - - diff --git a/src/views/cms/CategoryManage.vue b/src/views/cms/CategoryManage.vue index 2e3eb13..380e527 100644 --- a/src/views/cms/CategoryManage.vue +++ b/src/views/cms/CategoryManage.vue @@ -5,13 +5,18 @@ import MyConfirm from "@/components/MyConfirm"; import CategoryTreePanel from "@/components/CategoryTreePanel.vue"; import CategorySortModal from "@/components/CategorySortModal.vue"; import SvgIcon from "@/components/SvgIcon.vue"; +import ArticleListPanel from "@/components/article/ArticleListPanel.vue"; import { categoryApi } from "@/api/module/cms"; import { useCmsStore } from "@/store"; +import config from "@/assets/config/host.config"; import { CategoryTreeNode } from "@/types/cms"; /* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */ const store = useCmsStore(); +/** 树折叠(窄屏/专注内容时收起左树,右侧面板占满;不触碰 ztree 内部交互) */ +const treeCollapsed = ref(false); + const refTreePanel = useTemplateRef("refTreePanel"); const refFormData = useTemplateRef("refFormData"); const refNodeNameInput = useTemplateRef("refNodeNameInput"); @@ -195,10 +200,38 @@ async function save() { function onSortSaved() { refTreePanel.value?.refreshTree(curTreeNode.value?.id); } + +/* ===== B5 栏目工作台:右侧类型化内容面板 ===== */ + +/** 单页摘要:去 HTML 标签截断展示 */ +function summaryText(node: any): string { + const text = String(node?.content || "").replace(/<[^>]+>/g, "").trim(); + return text.length > 200 ? text.slice(0, 200) + "…" : text || "(暂无内容,点击「编辑内容」补充)"; +} + +/** 新建文章:带 categoryId 预选跳编辑页(ArticleEdit onMounted 回填) */ +function goNewArticle(categoryId: number) { + window.location.hash = `#/sch/${store.schoolCode}/article/edit?categoryId=${categoryId}`; +} + +/** 门户预览:新开 /portal/{siteCode}/c/{id} */ +function openPreview(node: any) { + const code = store.currentSite?.code || store.schoolCode; + if (!code) { + Message.warning("当前无站点上下文,无法预览"); + return; + } + window.open(`${config.serviceHost}/portal/${code}/c/${node.id}`, "_blank"); +} + +/** 外链栏目直达 */ +function openLink(url: string) { + window.open(url, "_blank"); +}