diff --git a/src/__tests__/categoryWorkbench.spec.ts b/src/__tests__/categoryWorkbench.spec.ts index 6307b58..a374aad 100644 --- a/src/__tests__/categoryWorkbench.spec.ts +++ b/src/__tests__/categoryWorkbench.spec.ts @@ -1,11 +1,12 @@ // @vitest-environment jsdom /** - * B5 栏目工作台组件行为(方案 P1-2): - * ① 列表型栏目 → 右侧 ArticleListPanel 且 categoryId 正确传递; - * ② 切换树节点 → 面板 watch categoryId 联动 reload; + * B5 栏目工作台组件行为(方案 P1-2)+ P-D1 内容管理 IA 切分(v2 §B): + * ① 列表型栏目 → 右侧「栏目内容概览」(统计 + 最近 5 条只读 + CTA),不再渲染完整操作表; + * ② 切换树节点 → 概览 watch categoryId 联动 reload; * ③ 单页型 → 摘要面板(含"编辑内容"入口)不渲染文章列表; * ④ 外链型 → linkUrl + 打开链接; - * ⑤ 新建文章按钮 → 路由跳转带 categoryId query。 + * ⑤ 新建文章按钮 → 路由跳转带 categoryId query; + * ⑥ T-D-02/03:/article 读 ?categoryId= 预筛选;概览"查看全部"深链到该地址。 * * 无 @vue/test-utils 依赖:createApp 手写挂载 + vi.mock 桩(树面板/排序弹窗/图标/确认框/api/router)。 */ @@ -14,20 +15,26 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue"; import { createPinia } from "pinia"; import ViewUIPlus from "view-ui-plus"; -const { pageMock, routerPush, openMock, saveMock, saveRejection } = vi.hoisted(() => ({ +const { pageMock, routerPush, openMock, saveMock, saveRejection, pageResult, treeFixture, routeMock } = vi.hoisted(() => ({ pageMock: vi.fn(), routerPush: vi.fn(), openMock: vi.fn(), saveMock: vi.fn(), /** 第 N 次(0 起)save.send() 拒绝;-1=全部成功(批量添加"失败继续"用) */ saveRejection: { value: -1 }, + /** articleApi.page 返回值桩:默认空列表(各用例按需覆盖 list/total) */ + pageResult: { value: { list: [] as any[], total: 0 } }, + /** categoryApi.tree 返回值桩:默认空树(用例按需给栏目层级) */ + treeFixture: { value: [] as any[] }, + /** useRoute 桩:可变 query,测 ?categoryId= 深链初始化 */ + routeMock: { value: { params: {}, query: {} as Record } }, })); vi.mock("@/api/module/cms", () => ({ articleApi: { page: (query: any) => { pageMock(query); - return { send: () => Promise.resolve({ list: [], total: 0 }) }; + return { send: () => Promise.resolve(pageResult.value) }; }, publish: () => ({ send: () => Promise.resolve({}) }), offline: () => ({ send: () => Promise.resolve({}) }), @@ -35,7 +42,7 @@ vi.mock("@/api/module/cms", () => ({ delete: () => ({ send: () => Promise.resolve({}) }), }, categoryApi: { - tree: () => ({ send: () => Promise.resolve([]) }), + tree: () => ({ send: () => Promise.resolve(treeFixture.value) }), save: (payload: any) => { const nth = saveMock.mock.calls.length; saveMock(payload); @@ -54,7 +61,7 @@ vi.mock("@/api/module/cms", () => ({ vi.mock("vue-router", () => ({ useRouter: () => ({ push: routerPush, replace: vi.fn() }), - useRoute: () => ({ params: {}, query: {} }), + useRoute: () => routeMock.value, })); /** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */ @@ -67,6 +74,8 @@ vi.mock("@/components/CategoryTreePanel.vue", () => ({ emit("update:curTreeNode", node); emit("on-change", node); }; + // P-D-01:概览统计要"直属+子孙",用 on-tree-data 把(含虚拟根的)栏目树喂给 CategoryManage.treeData + (globalThis as any).__treeEmitData = (list: any[]) => emit("on-tree-data", list); (globalThis as any).__treeProps = props; // CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API) expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} }); @@ -126,7 +135,11 @@ async function flush(frames = 5) { beforeEach(() => { vi.clearAllMocks(); (globalThis as any).__treeEmit = undefined; + (globalThis as any).__treeEmitData = undefined; (globalThis as any).__treeProps = undefined; + pageResult.value = { list: [], total: 0 }; + treeFixture.value = []; + routeMock.value = { params: {}, query: {} }; currentStore = null; window.open = openMock as any; }); @@ -168,6 +181,19 @@ describe("categoryWorkbench - ArticleListPanel(列表型右侧面板)", () = expect(pageMock.mock.calls[1][0]).toMatchObject({ categoryIds: 7 }); }); + it("T-D-02 文章页 ?categoryId= 深链:读 query 初始化栏目筛选并回显下拉", async () => { + treeFixture.value = [{ id: 5, name: "新闻中心", type: 1, children: [{ id: 51, name: "校园时讯", type: 1 }] }]; + routeMock.value = { params: {}, query: { categoryId: "5" } }; + const el = mountApp(defineComponent({ + setup: () => () => h(ArticleListPanel, {}), + })); + await flush(); + + // query 里的字符串 id 须转成数字:既对齐后端 categoryIds,也让栏目下拉回显选中项 + expect(pageMock.mock.calls[0][0]).toMatchObject({ siteId: 1, categoryIds: 5 }); + expect(el.querySelector(".ivu-select-selected-value")?.textContent).toContain("新闻中心"); + }); + it("⑤ 面板新建文章:非固定模式带 categoryId query 跳转;固定模式入口收敛到工作台面板头(面板内无重复按钮)", async () => { const state = reactive({ cid: 5, fixed: false }); const host = defineComponent({ @@ -218,15 +244,75 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => { expect(openMock).toHaveBeenCalledWith("https://www.moe.gov.cn", "_blank"); }); - it("列表型栏目:右侧渲染 ArticleListPanel(categoryId=选中栏目)", async () => { + it("T-D-01 列表型栏目:右侧降级为「栏目内容概览」(统计 + 最近 5 条只读),不再渲染操作表", async () => { + pageResult.value = { + total: 12, + list: [1, 2, 3, 4, 5, 6].map(i => ({ + id: 900 + i, + title: `云海大学第${i}次党代会筹备推进会`, + categoryId: 5, + categoryName: "新闻中心", + status: 1, + publishTime: "2026-09-20 10:00", + topmost: 0, + })), + }; + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + // 统计口径 = 直属 + 子孙栏目:树经 on-tree-data 下发(含 W2 虚拟根),子孙 51/52 须并入计数 + (globalThis as any).__treeEmitData([ + { + id: 0, + name: "演示学校", + virtual: true, + children: [ + { + id: 5, + name: "新闻中心", + type: 1, + children: [ + { id: 51, name: "校园时讯", type: 1 }, + { id: 52, name: "通知公告", type: 1 }, + ], + }, + ], + }, + ]); + (globalThis as any).__treeEmit({ id: 5, parentId: 0, name: "新闻中心", type: 1, sortNo: 0, status: 1 }); + await flush(); + + const box = el.querySelector(".category-content-summary"); + expect(box).toBeTruthy(); + // 完整的文章操作表不再出现在栏目页(唯一操作台是 /article) + expect(el.querySelector(".article-list-panel")).toBeNull(); + expect(box!.querySelector(".ivu-table")).toBeNull(); + // 概览内不得出现文章操作入口(发布/下架/置顶/删除按钮);状态说明文字不算 + const affordances = [...box!.querySelectorAll("button, a")].map(n => (n.textContent || "").trim()); + expect(affordances.some(t => ["发布", "下架", "取消置顶", "置顶", "删除"].includes(t))).toBe(false); + + // 统计查询:直属+子孙(逗号分隔,后端 List 契约)、全状态(不带 status)、只要最近 5 条 + expect(pageMock).toHaveBeenCalled(); + const q = pageMock.mock.calls[0][0]; + expect(q).toMatchObject({ siteId: 1, categoryIds: "5,51,52", page: 1, pageSize: 5 }); + expect(q.status).toBeUndefined(); + + expect(box!.textContent).toContain("共 12 篇"); + // 只读 5 条(第 6 条不渲染) + expect(box!.querySelectorAll(".ccs-row")).toHaveLength(5); + expect(box!.textContent).toContain("云海大学第1次党代会筹备推进会"); + }); + + it("T-D-03 概览「查看全部 →」跳 /article 深链(带 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({ categoryIds: 5 }); + const more = [...el.querySelectorAll("a, button")].find(b => b.textContent?.includes("查看全部")); + expect(more).toBeTruthy(); + more!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(routerPush).toHaveBeenCalledWith("/sch/demo1/article?categoryId=5"); }); it("未选中节点:右侧显示空态引导", async () => { diff --git a/src/components/article/ArticleListPanel.vue b/src/components/article/ArticleListPanel.vue index 88616ca..1804686 100644 --- a/src/components/article/ArticleListPanel.vue +++ b/src/components/article/ArticleListPanel.vue @@ -1,6 +1,6 @@ + + + + diff --git a/src/types/cms.ts b/src/types/cms.ts index 119e971..0cde756 100644 --- a/src/types/cms.ts +++ b/src/types/cms.ts @@ -170,7 +170,7 @@ export interface ArticleSaveRequest { export interface ArticlePageQuery { siteId: number; /** 栏目 id(后端字段 categoryIds: List,多选逗号分隔;空=全部栏目) */ - categoryIds?: number; + categoryIds?: number | string; status?: number; keyword?: string; page?: number; diff --git a/src/views/cms/CategoryManage.vue b/src/views/cms/CategoryManage.vue index 4dd27c4..14f7e7b 100644 --- a/src/views/cms/CategoryManage.vue +++ b/src/views/cms/CategoryManage.vue @@ -8,7 +8,7 @@ import CategoryTreePanel from "@/components/CategoryTreePanel.vue"; import CategorySortModal from "@/components/CategorySortModal.vue"; import BatchAddCategory from "@/components/BatchAddCategory.vue"; import SvgIcon from "@/components/SvgIcon.vue"; -import ArticleListPanel from "@/components/article/ArticleListPanel.vue"; +import CategoryContentSummary from "@/components/category/CategoryContentSummary.vue"; import { categoryApi } from "@/api/module/cms"; import { useCmsStore } from "@/store"; import config from "@/assets/config/host.config"; @@ -17,7 +17,9 @@ import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree"; import { portalUrlOf, adminPathOf } from "@/utils/sitePath"; /* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除)。 - B5 栏目工作台:右侧类型化面板(列表=文章表格 / 单页=富文本预览 / 外链=直达)。 */ + B5 栏目工作台:右侧类型化面板(单页=富文本预览 / 外链=直达)。 + P-D1 内容管理 IA 切分:列表型栏目右侧降级为「栏目内容概览」(只读统计 + 最近 5 条), + 文章的检索与操作统一在 /article(内容视图)。 */ const store = useCmsStore(); const refTreePanel = useTemplateRef("refTreePanel"); @@ -440,13 +442,13 @@ function openLink(url: string) { - - + @@ -551,7 +553,7 @@ function openLink(url: string) { -

首页该栏目区块的呈现形态;「双栏新闻卡/媒体聚焦列表」需皮肤包支持(如炫彩校园),旧主题按默认卡展示。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。

+

首页该栏目区块的呈现形态;展示形态由当前皮肤包的主题化实现决定,未实现的形态回落通用骨架渲染。也可在「站点设置 → 首页布局」里按区块单独指定形态(以那里为准)。

SEO 设置