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:
@@ -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("选择左侧栏目");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user