fix(tree): 修复左侧树滚动条失效与叶子节点误显 +/−,统一权限一览连接线
build / build (push) Successful in 3m27s

滚动条(上次改动引入的回归):
- MyTree 根节点加 .my-tree 类并打通高度链(flex 纵向 + .ztree 承担滚动),
  父级定高时滚动条只落在树节点区域;父级 auto 的卡片内树行为不变
- GroupTreeSearchPanel 的 .tree-body 改 flex 纵向传递高度,删除已失效的 .ztree 规则

叶子节点 +/−(以组用户管理左侧树为参考统一):
- zTree 只要 children 是数组即强制 isParent=true,故空数组必须不挂键;
  修正 GroupAuth / AppModuleCheckTree / PermissionView 共 9 处节点构造
- MyTree 初始化前加节点规范化兜底,防止后续新增树重犯
- removeNode 删除末个子节点后同步父节点父/叶状态,不再残留 +/−

权限一览:
- 删除 li{line-height:26px} 覆盖(zTree 图标按 21px 节距,26px 会让连接线出现断口)
- 左列改用页面级 permission-tree 类:flex 纵向 + overflow:hidden,
  消除整列滚动与外层裁切,滚动收敛到树体
This commit is contained in:
chenxing
2026-09-16 17:46:06 +08:00
parent af94e1d2cb
commit a082e59c3e
6 changed files with 135 additions and 69 deletions
+6 -3
View File
@@ -285,10 +285,13 @@ defineExpose({
.tree-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tree-body :deep(.ztree) {
height: 100%;
overflow-y: auto;
/* 滚动职责统一由 MyTree(.my-tree > .ztree) 承担:此处只负责把可用高度传给子组件 */
.tree-body > .my-tree {
flex: 1;
min-height: 0;
}
</style>
+47 -2
View File
@@ -1,9 +1,35 @@
<template>
<div>
<div class="my-tree">
<div :id="domId" :ref="domId" class="ztree"></div>
</div>
</template>
<script>
/**
* 节点规范化兜底:zTree 判定父节点的规则是「children 是数组即 isParent=true」(jquery.ztree.all.min.js
* 中 `tools.isArray(s) ? (data.nodeIsParent(setting,n,!0), ...)`),因此叶子节点只要被构造成
* `children: []` 就会渲染出 +/− 展开图标——此时再写 isParent:false 也无效(被数组分支覆盖)。
* 这里统一按「空 children 不挂键」,与参考实现 GroupTreeSearchPanel.formatTree 的口径一致。
*/
function normalizeTreeNodes(nodes) {
if (Array.isArray(nodes)) {
nodes.forEach(normalizeTreeNode);
} else if (nodes) {
normalizeTreeNode(nodes);
}
}
function normalizeTreeNode(node) {
if (!node || typeof node !== "object") return;
const kids = node.children;
if (Array.isArray(kids) && kids.length > 0) {
node.isParent = true;
kids.forEach(normalizeTreeNode);
} else {
delete node.children;
node.isParent = false;
}
}
export default {
props: {
treeData: {
@@ -65,6 +91,7 @@ export default {
},
initTree(_data, _domId) {
const _this = this;
normalizeTreeNodes(_data);
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
// console.log(event, treeId, treeNode, clickFlag);
if (_this.isCheck) {
@@ -132,7 +159,10 @@ export default {
var node = treeObj.getNodesByFilter(node => node.id == id, true);
var pnode = node.getParentNode();
treeObj.removeNode(node);
pnode.isParent = true;
// 同步父节点的父/叶状态:删掉最后一个子节点后不能继续显示 +/− 展开图标
const hasChild = !!(pnode.children && pnode.children.length);
pnode.isParent = hasChild;
pnode.open = hasChild && pnode.open;
treeObj.updateNode(pnode);
this.selectNode(pnode.id);
},
@@ -211,6 +241,21 @@ export default {
</script>
<style>
/* 高度链:父级定高时 .ztree 成为唯一滚动体(滚动条只在树节点区域);
父级高度 auto(如卡片内按内容展开的树)时 height:100% 退化为 auto,表现为按内容展开,不受影响 */
.my-tree {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.my-tree > .ztree {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ztree-search-highlight {
background-color: #ff6600 !important;
}
+12 -4
View File
@@ -107,6 +107,7 @@ type AuthzTreeNode = {
nodeType: "app" | "module";
rawId: number;
appId: number;
isParent?: boolean;
children?: AuthzTreeNode[];
};
@@ -230,8 +231,10 @@ function isModuleAllInherit(node: AppModuleVo): boolean {
return (node.children || []).every((c) => isModuleAllInherit(c));
}
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return {
const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
const node: AuthzTreeNode = {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
@@ -240,13 +243,16 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
nodeType: "module",
rawId: Number(m.id),
appId,
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
isParent: kids.length > 0,
};
if (kids.length > 0) node.children = kids;
return node;
}
function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id);
return {
const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
const node: AuthzTreeNode = {
id: `app_${appId}`,
name: String(app.name || ""),
open: false, // 一级默认闭合,点击才展开模块
@@ -255,8 +261,10 @@ function buildAppNode(app: AppItem): AuthzTreeNode {
nodeType: "app",
rawId: appId,
appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
isParent: kids.length > 0,
};
if (kids.length > 0) node.children = kids;
return node;
}
// 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡
+55 -54
View File
@@ -1,6 +1,6 @@
<template>
<div class="main-wrapper tree-wrapper">
<div class="tree-container">
<div class="main-wrapper tree-wrapper permission-view">
<div class="tree-container permission-tree">
<Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable
class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" />
<GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true"
@@ -389,19 +389,21 @@ function formatRoleAuthzTree(apps: any[], appListRes: any, typeRes: any): any[]
});
}
// zTree 的父节点判定是「children 是数组即 isParent=true」——空数组也会渲染出 +/− 展开图标,
// 因此统一约定:有子节点才挂 children 键,否则不挂(与组用户管理左侧树的构造口径一致)
function treeNode(base: Record<string, any>, kids: any[]): any {
const node: any = { ...base, isParent: kids.length > 0 };
if (kids.length > 0) node.children = kids;
return node;
}
// 仅保留勾选(checked)的模块;父未勾选但子孙有勾选时保留父节点以维持层级
function filterCheckedModules(nodes: any[]): any[] {
const out: any[] = [];
for (const n of nodes || []) {
const children = filterCheckedModules(n.children || []);
if (n.checked || children.length > 0) {
out.push({
id: `mod_${n.id}`,
name: n.name || `模块${n.id}`,
open: true,
children,
isParent: (n.children || []).length > 0,
});
const kids = filterCheckedModules(n.children || []);
if (n.checked || kids.length > 0) {
out.push(treeNode({ id: `mod_${n.id}`, name: n.name || `模块${n.id}`, open: true }, kids));
}
}
return out;
@@ -409,13 +411,12 @@ function filterCheckedModules(nodes: any[]): any[] {
// 所属部门:祖先链树,用户直接归属的组加「本组」标记
function formatDeptTree(nodes: GroupNode[]): any[] {
return (nodes || []).map((n) => ({
id: `grp_${n.id}`,
name: n.own ? `${n.name || ""}(本组)` : n.name || "",
open: true,
children: formatDeptTree(n.children || []),
isParent: (n.children || []).length > 0,
}));
return (nodes || []).map((n) =>
treeNode(
{ id: `grp_${n.id}`, name: n.own ? `${n.name || ""}(本组)` : n.name || "", open: true },
formatDeptTree(n.children || [])
)
);
}
// 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色
@@ -438,40 +439,30 @@ function formatRoleTree(roles: RoleItem[]): any[] {
}
function formatPermissionTree(categories: CategoryNode[]): any[] {
const result: any[] = [];
for (const cat of categories) {
const catNode: any = {
id: `cat_${cat.id}`,
name: cat.name || `应用类型${cat.id}`,
open: true,
children: formatApps(cat.apps || [], cat.id),
};
catNode.isParent = catNode.children.length > 0;
result.push(catNode);
}
return result;
return (categories || []).map((cat) =>
treeNode(
{ id: `cat_${cat.id}`, name: cat.name || `应用类型${cat.id}`, open: true },
formatApps(cat.apps || [], cat.id)
)
);
}
function formatApps(apps: AppNode[], catId?: number): any[] {
return (apps || []).map((app) => ({
id: `app_${app.id}_${catId || 0}`,
name: app.name || `应用${app.id}`,
open: false, // 应用一级默认闭合,点击才展开模块
children: formatModules(app.modules || []),
}));
return (apps || []).map((app) =>
treeNode(
{ id: `app_${app.id}_${catId || 0}`, name: app.name || `应用${app.id}`, open: false }, // 应用一级默认闭合,点击才展开模块
formatModules(app.modules || [])
)
);
}
function formatModules(modules: ModuleNode[]): any[] {
return (modules || []).map((m) => {
const children = formatModules(m.children || []);
return {
id: `mod_${m.id}`,
name: m.name || `模块${m.id}`,
open: true,
children,
isParent: children.length > 0,
};
});
return (modules || []).map((m) =>
treeNode(
{ id: `mod_${m.id}`, name: m.name || `模块${m.id}`, open: true },
formatModules(m.children || [])
)
);
}
</script>
@@ -635,19 +626,29 @@ function formatModules(modules: ModuleNode[]): any[] {
color: #2d8cf0;
}
:deep(.group-tree-search-panel) {
height: 100%;
}
:deep(.ztree) {
font-size: 14px;
li {
line-height: 26px;
}
a {
color: #333;
}
}
</style>
<style lang="scss">
/* 左列对齐「组用户管理」页的写法(不覆盖全局 .tree-container):
容器改 flex 纵向并收起自身滚动,把滚动条收敛到树节点区域,避免整列滚动把搜索框滚走、树尾被裁。
注意不要在此处覆盖 zTree 的行高:zTree 官方样式的图标/连线按 21px 节距排布,
把 li line-height 改大(如 26px)会让每行多出的空隙在连接线上露出断口。 */
.tree-wrapper.permission-view .tree-container.permission-tree {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
.group-tree-search-panel {
flex: 1;
min-height: 0;
}
}
</style>
+2 -1
View File
@@ -371,7 +371,8 @@ function onSortSaved() {
left: 348px !important;
}
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域(.ztree 自带 overflow-y:auto),
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
.tree-wrapper.group-user-page .tree-container.group-user-tree {
display: flex;
+13 -5
View File
@@ -68,6 +68,7 @@ type AuthzTreeNode = {
nodeType: "app" | "module";
rawId: number;
appId: number;
isParent?: boolean;
children?: AuthzTreeNode[];
};
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
@@ -115,8 +116,10 @@ function isModuleAllChecked(node: AppModuleVo): boolean {
if (!node.checked) return false;
return (node.children || []).every((c) => isModuleAllChecked(c));
}
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return {
const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
const node: AuthzTreeNode = {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
@@ -124,21 +127,26 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
nodeType: "module",
rawId: Number(m.id),
appId,
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
isParent: kids.length > 0,
};
if (kids.length > 0) node.children = kids;
return node;
}
function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id);
return {
const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
const node: AuthzTreeNode = {
id: `app_${appId}`,
name: String(app.name || ""),
open: false, // 一级默认闭合,点击才展开模块
open: false,
checked: isAppChecked(app),
nodeType: "app",
rawId: appId,
appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
isParent: kids.length > 0,
};
if (kids.length > 0) node.children = kids;
return node;
}
// ---------------- 数据加载 ----------------