feat(W2): 栏目树修订-虚拟根(学校名/子站名)+工具栏收敛4按钮+高度链修复,T-W2-01..10 绿 44/44
This commit is contained in:
@@ -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("机械学院");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user