From 7b99ec398d3e04a0cf972680d1a9ecee86a40a6d Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 8 Sep 2026 11:13:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(W2):=20=E6=A0=8F=E7=9B=AE=E6=A0=91?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2-=E8=99=9A=E6=8B=9F=E6=A0=B9(=E5=AD=A6?= =?UTF-8?q?=E6=A0=A1=E5=90=8D/=E5=AD=90=E7=AB=99=E5=90=8D)+=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A0=8F=E6=94=B6=E6=95=9B4=E6=8C=89=E9=92=AE+?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E9=93=BE=E4=BF=AE=E5=A4=8D,T-W2-01..10=20?= =?UTF-8?q?=E7=BB=BF=2044/44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/categoryWorkbench.spec.ts | 99 ++++++++++++++++++++++++- src/components/CategoryTreePanel.vue | 5 +- src/components/SchoolLayout.vue | 8 ++ src/utils/categoryTree.spec.ts | 69 +++++++++++++++++ src/utils/categoryTree.ts | 37 +++++++++ src/views/cms/CategoryManage.vue | 42 +++-------- 6 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 src/utils/categoryTree.spec.ts create mode 100644 src/utils/categoryTree.ts diff --git a/src/__tests__/categoryWorkbench.spec.ts b/src/__tests__/categoryWorkbench.spec.ts index 4817a63..6dfe5de 100644 --- a/src/__tests__/categoryWorkbench.spec.ts +++ b/src/__tests__/categoryWorkbench.spec.ts @@ -14,10 +14,11 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue"; import { createPinia } from "pinia"; import ViewUIPlus from "view-ui-plus"; -const { pageMock, routerPush, openMock } = vi.hoisted(() => ({ +const { pageMock, routerPush, openMock, saveMock } = vi.hoisted(() => ({ pageMock: vi.fn(), routerPush: vi.fn(), openMock: vi.fn(), + saveMock: vi.fn(), })); vi.mock("@/api/module/cms", () => ({ @@ -33,7 +34,10 @@ vi.mock("@/api/module/cms", () => ({ }, categoryApi: { tree: () => ({ send: () => Promise.resolve([]) }), - save: () => ({ send: () => Promise.resolve({}) }), + save: (payload: any) => { + saveMock(payload); + return { send: () => Promise.resolve({}) }; + }, delete: () => ({ send: () => Promise.resolve({}) }), }, uploadUrl: () => "/api/attachment/upload", @@ -45,16 +49,19 @@ vi.mock("vue-router", () => ({ useRoute: () => ({ params: {}, query: {} }), })); -/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点" */ +/** 树面板桩:把 emit 挂到全局,测试用它模拟"选中节点";捕获 props 供 treeApi 断言 */ vi.mock("@/components/CategoryTreePanel.vue", () => ({ default: defineComponent({ props: ["curTreeNode", "treeApi", "defaultSearchVisible", "showCloseButton"], emits: ["update:curTreeNode", "on-change", "on-tree-data"], - setup(_, { emit }) { + setup(props, { emit, expose }) { (globalThis as any).__treeEmit = (node: any) => { emit("update:curTreeNode", node); emit("on-change", node); }; + (globalThis as any).__treeProps = props; + // CategoryManage 会经模板 ref 调这些方法(真实组件的 exposed API) + expose({ loadTree: () => {}, refreshTree: () => {}, reloadTree: () => {} }); return () => h("div", { class: "tree-stub" }, "tree-stub"); }, }), @@ -72,14 +79,18 @@ vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() })); import ArticleListPanel from "@/components/article/ArticleListPanel.vue"; import CategoryManage from "@/views/cms/CategoryManage.vue"; +import MyConfirm from "@/components/MyConfirm"; +import { Message } from "view-ui-plus"; import { useCmsStore } from "@/store"; let cleanups: (() => void)[] = []; +let currentStore: ReturnType | null = null; /** 挂载:真实 pinia(预置学校/站点上下文)+ 全局 view-ui-plus */ function mountApp(root: Component) { const pinia = createPinia(); const store = useCmsStore(pinia); + currentStore = store; 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; @@ -106,6 +117,8 @@ async function flush(frames = 5) { beforeEach(() => { vi.clearAllMocks(); (globalThis as any).__treeEmit = undefined; + (globalThis as any).__treeProps = undefined; + currentStore = null; window.open = openMock as any; }); @@ -207,3 +220,81 @@ describe("categoryWorkbench - CategoryManage 右侧类型化面板", () => { expect(el.textContent).toContain("选择左侧栏目"); }); }); + +describe("W2 栏目树修订(T-W2-06..10)", () => { + const selectRoot = async () => { + (globalThis as any).__treeEmit({ id: 0, name: "演示学校", virtual: true, children: [] }); + await flush(); + }; + + it("T-W2-06: 工具栏收敛——搜索/收起展开按钮移除(仅剩 4 个操作)", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + expect(el.querySelector(".search-node-li")).toBeNull(); + expect(el.querySelectorAll(".opera-ul li")).toHaveLength(4); + }); + + it("T-W2-07: 树桩收到带根数据——treeApi 返回首节点 id=0、name=学校名", async () => { + mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + const props = (globalThis as any).__treeProps; + expect(props?.treeApi).toBeTypeOf("function"); + const wrapped = await props.treeApi(); + expect(wrapped[0]).toMatchObject({ id: 0, name: "演示学校", virtual: true }); + expect(wrapped[0].children).toEqual([]); + }); + + it("T-W2-08: 根上新增=顶级栏目(保存 payload parentId===0)", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + await selectRoot(); + const addLi = el.querySelector(".add-node-li"); + addLi!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + // Modal 经 transfer 渲染到 body(不在挂载根内);首个 input 是禁用的"上级栏目"展示框,须按 placeholder 定位名称框 + const input = document.querySelector('.ivu-modal input[placeholder="栏目名称"]') as HTMLInputElement; + expect(input).toBeTruthy(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "测试顶级栏目"); + input.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + const saveBtn = [...document.querySelectorAll(".ivu-modal button")].find(b => b.textContent?.includes("保存")); + saveBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + + expect(saveMock).toHaveBeenCalledTimes(1); + expect(saveMock.mock.calls[0][0]).toMatchObject({ parentId: 0, name: "测试顶级栏目" }); + }); + + it("T-W2-09: 根上编辑/删除被拒——仅警告、零请求", async () => { + const el = mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + await selectRoot(); + const warnSpy = vi.spyOn(Message, "warning"); + + const lis = [...el.querySelectorAll(".opera-ul li")]; + // 工具栏顺序:新增/编辑/排序/删除(搜索与收起展开已移除) + lis[1].dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(warnSpy).toHaveBeenCalled(); + expect(saveMock).not.toHaveBeenCalled(); + + lis[3].dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(MyConfirm).not.toHaveBeenCalled(); + }); + + it("T-W2-10: 站点切换根 label 跟随——切到子站后根 name=子站名", async () => { + mountApp(defineComponent({ render: () => h(CategoryManage) })); + await flush(); + const before = await (globalThis as any).__treeProps.treeApi(); + expect(before[0].name).toBe("演示学校"); + + currentStore!.setCurrentSite({ id: 2, code: "jdxy", name: "机械学院" } as any); + await flush(); + const wrapped = await (globalThis as any).__treeProps.treeApi(); + expect(wrapped[0].name).toBe("机械学院"); + }); +}); diff --git a/src/components/CategoryTreePanel.vue b/src/components/CategoryTreePanel.vue index c39f412..f8e05e0 100644 --- a/src/components/CategoryTreePanel.vue +++ b/src/components/CategoryTreePanel.vue @@ -19,6 +19,7 @@ import { ref, nextTick, useTemplateRef } from "vue"; import { cloneDeep } from "lodash"; import MyTree from '@/components/MyTree.vue' +import { initialSelectId } from '@/utils/categoryTree' const DEFAULT_CACHE_KEY = 'cms.category-tree.selected-node' @@ -112,10 +113,12 @@ function resolveInitialSelectId(list: any[], preferredId?: number | null) { const preferred = findNodeById(list, preferredId); if (preferred) return preferred.id; + // W2 虚拟根(D5):默认下钻到虚拟根下首个真实栏目,绝不落到根 id=0; + // 缓存恢复优先于下钻(B5:刷新后仍选中缓存的 REAL 节点) const cached = findNodeById(list, getCachedSelectedNodeId()); if (cached) return cached.id; - return list[0]?.id ?? null; + return initialSelectId(list) ?? list[0]?.id ?? null; } async function loadTree(preferredId?: number | null) { diff --git a/src/components/SchoolLayout.vue b/src/components/SchoolLayout.vue index 8efbe23..82ae571 100644 --- a/src/components/SchoolLayout.vue +++ b/src/components/SchoolLayout.vue @@ -372,4 +372,12 @@ watch( color: #999; font-size: 14px; } + +/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。 + layout.css 全局 .main-wrapper 只有 min-height:100% 无 height, + 子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。 + scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点(:348-349) */ +.base-wrap-main .main-wrapper { + height: 100%; +} diff --git a/src/utils/categoryTree.spec.ts b/src/utils/categoryTree.spec.ts new file mode 100644 index 0000000..2f7aa13 --- /dev/null +++ b/src/utils/categoryTree.spec.ts @@ -0,0 +1,69 @@ +/** + * W2 栏目树修订:虚拟根装配纯函数(方案 §6.3 T-W2-01..05)。 + * rootLabelOf:根 label 随站点上下文(主站→学校名,缺省回退 mainSiteName;子站→子站名); + * wrapTreeWithRoot:单根包装 {id:0,name:label,virtual:true,children}; + * initialSelectId:虚拟根下钻首个真实栏目 id,空树 null。 + */ +import { describe, expect, it } from "vitest"; +import { initialSelectId, rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree"; + +const school = (overrides: Partial = {}) => ({ + schId: 1, + schName: "演示学校", + mainSiteId: 1, + mainSiteCode: "demo1", + mainSiteName: "演示主站", + ...overrides, +}); + +describe("rootLabelOf(T-W2-01..03)", () => { + it("T-W2-01: 主站上下文 → schName", () => { + expect(rootLabelOf({ currentSchool: school(), currentSite: { id: 1, code: "demo1", name: "演示主站" } })) + .toBe("演示学校"); + }); + + it("T-W2-02: 主站 schName 缺省 → 回退 mainSiteName", () => { + expect(rootLabelOf({ currentSchool: school({ schName: "" }), currentSite: { id: 1, code: "demo1", name: "演示主站" } })) + .toBe("演示主站"); + expect(rootLabelOf({ currentSchool: school({ schName: undefined as any }), currentSite: { id: 1, code: "demo1", name: "演示主站" } })) + .toBe("演示主站"); + }); + + it("T-W2-03: 子站上下文 → 子站名", () => { + expect(rootLabelOf({ currentSchool: school(), currentSite: { id: 2, code: "jdxy", name: "机械学院" } })) + .toBe("机械学院"); + }); +}); + +describe("wrapTreeWithRoot(T-W2-04)", () => { + it("返回单根虚拟节点,原树挂 children", () => { + const tree = [ + { id: 5, parentId: 0, name: "新闻中心", children: [] }, + { id: 6, parentId: 0, name: "学校简介", children: [] }, + ]; + const root = wrapTreeWithRoot(tree, "演示学校"); + expect(root).toMatchObject({ id: 0, name: "演示学校", virtual: true }); + expect(root.children).toHaveLength(2); + expect(root.children[0].id).toBe(5); + }); + + it("空树也有根(children=[])", () => { + const root = wrapTreeWithRoot([], "演示学校"); + expect(root).toMatchObject({ id: 0, name: "演示学校", virtual: true, children: [] }); + expect(wrapTreeWithRoot(null as any, "演示学校").children).toEqual([]); + }); +}); + +describe("initialSelectId(T-W2-05)", () => { + it("虚拟根下钻首个真实栏目 id(非 0)", () => { + const root = wrapTreeWithRoot([ + { id: 5, parentId: 0, name: "新闻中心", children: [] }, + { id: 6, parentId: 0, name: "学校简介", children: [] }, + ], "演示学校"); + expect(initialSelectId(root)).toBe(5); + }); + + it("空树 → null", () => { + expect(initialSelectId(wrapTreeWithRoot([], "演示学校"))).toBeNull(); + }); +}); diff --git a/src/utils/categoryTree.ts b/src/utils/categoryTree.ts new file mode 100644 index 0000000..a8be2ba --- /dev/null +++ b/src/utils/categoryTree.ts @@ -0,0 +1,37 @@ +/** + * W2 栏目树修订:虚拟根装配纯函数(方案 §6.4 步骤 1)。 + * 虚拟根约定(D5):id=0 的前端虚拟节点包裹真实栏目树;新增子栏目→parentId=0(顶级)、 + * 子栏目排序→parentId=0、编辑/删除→拒绝并提示(CategoryManage 既有 !node?.id 守卫天然拦截)。 + */ +import type { CurrentSchool, CurrentSite } from "@/types/cms"; + +/** 根节点 label:主站上下文=学校名(schName 缺省回退 mainSiteName);子站上下文=该子站名 */ +export function rootLabelOf(ctx: { + currentSchool: CurrentSchool | null; + currentSite: CurrentSite | null; +}): string { + const school = ctx.currentSchool; + const site = ctx.currentSite; + if (site && school && site.id !== school.mainSiteId) { + return site.name || ""; + } + const label = school?.schName?.trim(); + return label || school?.mainSiteName || ""; +} + +/** 把真实栏目树包装到虚拟根下(单根;空树也有根,children 恒为数组) */ +export function wrapTreeWithRoot(tree: any[] | null | undefined, label: string): any { + return { id: 0, name: label, virtual: true, children: Array.isArray(tree) ? tree : [] }; +} + +/** 初始选中:虚拟根下钻首个真实栏目 id;空树 → null(不落到根 id=0) */ +export function initialSelectId(wrapped: any): number | null { + if (wrapped == null) { + return null; + } + const root = Array.isArray(wrapped) ? wrapped[0] : wrapped; + if (root?.virtual) { + return root.children?.length ? root.children[0].id : null; + } + return root?.id ?? null; +} diff --git a/src/views/cms/CategoryManage.vue b/src/views/cms/CategoryManage.vue index 380e527..7972001 100644 --- a/src/views/cms/CategoryManage.vue +++ b/src/views/cms/CategoryManage.vue @@ -10,13 +10,11 @@ import { categoryApi } from "@/api/module/cms"; import { useCmsStore } from "@/store"; import config from "@/assets/config/host.config"; import { CategoryTreeNode } from "@/types/cms"; +import { rootLabelOf, wrapTreeWithRoot } from "@/utils/categoryTree"; -/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除/搜索)+ ztree */ +/* UserBase group/index 同款交互:图标工具栏(新增/编辑/子组排序/删除)+ ztree(W2:搜索/收起展开按钮移除) */ const store = useCmsStore(); -/** 树折叠(窄屏/专注内容时收起左树,右侧面板占满;不触碰 ztree 内部交互) */ -const treeCollapsed = ref(false); - const refTreePanel = useTemplateRef("refTreePanel"); const refFormData = useTemplateRef("refFormData"); const refNodeNameInput = useTemplateRef("refNodeNameInput"); @@ -58,9 +56,13 @@ const rules = { type: [{ required: true, type: "number", message: "请选择类型", trigger: "change" }], }; -/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载) */ -function loadTreeApi(): Promise { - return categoryApi.tree(store.siteId!).send() as Promise; +/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */ +const rootLabel = computed(() => rootLabelOf({ currentSchool: store.currentSchool, currentSite: store.currentSite })); + +/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载;W2:包装虚拟根后返回) */ +async function loadTreeApi(): Promise { + const tree = await categoryApi.tree(store.siteId!).send(); + return [wrapTreeWithRoot(tree as any[], rootLabel.value)]; } // 站点上下文切换即时重载(docs/09 §3.3,修复切换不刷新缺陷) @@ -231,7 +233,7 @@ function openLink(url: string) {