feat(B5): 栏目工作台-树+类型化内容面板,ArticleList 组件化薄壳

- 新建 ArticleListPanel(props categoryId/fixedCategory):无props=全局检索行为不变(薄壳红线);
  固定栏目模式隐藏栏目下拉,watch categoryId 联动 reload(拖拽排序不触发,红线)
- CategoryManage 右侧:列表型=本栏目文章面板+新建文章(query.categoryId 预选)+预览;
  单页型=摘要+编辑内容+预览;外链型=URL+打开链接;空态引导;工具栏增树折叠(窄屏)
- ArticleEdit 支持 query.categoryId 新建态预选栏目
- 后端零改动(红线);vitest 32/32 绿(新增 categoryWorkbench.spec 7 例,手写挂载+vi.mock 桩)
This commit is contained in:
chenxing
2026-09-08 15:14:09 +08:00
parent e494a2d2d8
commit fc566eed8b
5 changed files with 723 additions and 274 deletions
+209
View File
@@ -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: "<p>云海第一中学始建于 1958 年。</p>", 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("选择左侧栏目");
});
});