feat(W2): 栏目树修订-虚拟根(学校名/子站名)+工具栏收敛4按钮+高度链修复,T-W2-01..10 绿 44/44

This commit is contained in:
chenxing
2026-09-08 15:14:09 +08:00
parent 1e887906eb
commit 7b99ec398d
6 changed files with 224 additions and 36 deletions
+95 -4
View File
@@ -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<typeof useCmsStore> | 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("机械学院");
});
});
+4 -1
View File
@@ -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) {
+8
View File
@@ -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%;
}
</style>
+69
View File
@@ -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<any> = {}) => ({
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();
});
});
+37
View File
@@ -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;
}
+11 -31
View File
@@ -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<CategoryTreeNode[]> {
return categoryApi.tree(store.siteId!).send() as Promise<CategoryTreeNode[]>;
/** 根节点 label(W2 虚拟根,D5):主站=学校名(缺省回退 mainSiteName),子站=子站名;随站点上下文响应 */
const rootLabel = computed(() => rootLabelOf({ currentSchool: store.currentSchool, currentSite: store.currentSite }));
/** 树数据源(CategoryTreePanel 拉取用;站点切换即时重载;W2:包装虚拟根后返回) */
async function loadTreeApi(): Promise<CategoryTreeNode[]> {
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) {
</script>
<template>
<div class="tree-wrapper category-page" :class="{ 'tree-collapsed': treeCollapsed }">
<div class="tree-wrapper category-page">
<!-- 左侧栏目树(userbase rule-tree 风格) -->
<div class="tree-container category-tree-box">
<ul class="opera-ul">
@@ -255,16 +257,6 @@ function openLink(url: string) {
<SvgIcon name="tree-icon-5" />
</li>
</Tooltip>
<Tooltip content="搜索栏目" placement="top" transfer>
<li class="search-node-li" @click="refTreePanel?.openSearch()">
<Icon type="md-search" />
</li>
</Tooltip>
<Tooltip content="收起/展开栏目树" placement="top" transfer>
<li @click="treeCollapsed = !treeCollapsed">
<Icon :type="treeCollapsed ? 'ios-arrow-forward' : 'ios-arrow-back'" />
</li>
</Tooltip>
</ul>
<CategoryTreePanel
@@ -433,8 +425,7 @@ function openLink(url: string) {
}
}
.add-node-li,
.search-node-li {
.add-node-li {
position: relative;
}
}
@@ -450,17 +441,6 @@ function openLink(url: string) {
}
}
/* 树折叠态:隐藏左树,右侧面板占满(覆盖全局 .tree-wrapper .main-container 的 264px 边距) */
.category-page.tree-collapsed {
.tree-container {
display: none;
}
.main-container {
margin-left: 24px;
}
}
.info-panel {
background: #fff;
border-radius: 4px;