树结构

This commit is contained in:
2025-10-15 10:11:30 +08:00
parent 39d7d0cf93
commit abe5395b7c
2 changed files with 526 additions and 133 deletions

View File

@@ -8,57 +8,52 @@
</el-button>
</div>
<el-table
:data="deptList"
style="width: 100%"
<div class="tree-container">
<el-tree
ref="treeRef"
v-loading="loading"
border
stripe
row-key="deptID"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:data="deptList"
:props="treeProps"
node-key="deptID"
:default-expand-all="true"
:expand-on-click-node="false"
:check-on-click-node="false"
draggable
:allow-drop="allowDrop"
@node-drop="handleNodeDrop"
class="dept-tree"
>
<el-table-column prop="name" label="部门名称" min-width="200" />
<el-table-column prop="deptID" label="部门ID" min-width="150" />
<el-table-column prop="description" label="部门描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="creatorName" label="创建人" width="120" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleAddChild(row)"
link
>
新增子部门
<template #default="{ data }">
<div class="custom-tree-node">
<div class="node-label">
<el-icon class="node-icon">
<OfficeBuilding />
</el-icon>
<span class="node-name">{{ data.name }}</span>
</div>
<div class="node-info">
<span class="info-item">ID: {{ data.deptID }}</span>
<span class="info-item" v-if="data.description">{{ data.description }}</span>
<span class="info-item" v-if="data.creatorName">创建人: {{ data.creatorName }}</span>
</div>
<div class="node-actions">
<el-button size="small" type="primary" @click.stop="handleAddChild(data)">
子部门
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
link
>
<el-button size="small" type="success" @click.stop="handleEdit(data)">
编辑
</el-button>
<el-button
type="primary"
size="small"
@click="handleBindRole(row)"
link
>
绑定角色
<el-button size="small" type="warning" @click.stop="handleBindRole(data)">
角色
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
link
>
<el-button size="small" type="danger" @click.stop="handleDelete(data)">
删除
</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</el-tree>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
@@ -176,12 +171,13 @@ import { roleApi } from '@/apis/system/role';
import { SysDept, SysRole } from '@/types';
import { ref, onMounted, reactive, computed } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { Plus, OfficeBuilding } from '@element-plus/icons-vue';
// 数据状态
const deptList = ref<SysDept[]>([]);
const loading = ref(false);
const submitting = ref(false);
const treeRef = ref();
// 角色绑定相关数据
const roleList = ref<SysRole[]>([]);
@@ -217,12 +213,18 @@ const formRules: FormRules = {
// 级联选择器配置
const cascaderProps = {
value: 'deptID',
label: 'name',
value: 'value',
label: 'label',
children: 'children',
checkStrictly: true
};
// 树形组件配置
const treeProps = {
children: 'children',
label: 'name'
};
// 父部门选项
const parentDeptOptions = computed(() => {
return buildParentDeptOptions(deptList.value);
@@ -230,18 +232,35 @@ const parentDeptOptions = computed(() => {
// 构建父部门选项
function buildParentDeptOptions(depts: SysDept[]): any[] {
return depts.map(dept => ({
deptID: dept.deptID,
name: dept.name,
children: dept.children ? buildParentDeptOptions(dept.children) : undefined
}));
const result: any[] = [];
function processNode(dept: SysDept): any {
const option = {
value: dept.deptID,
label: dept.name,
children: dept.children && dept.children.length > 0
? dept.children.map(processNode)
: undefined
};
return option;
}
depts.forEach(dept => {
result.push(processNode(dept));
});
return result;
}
// 加载部门列表
async function loadDeptList() {
try {
loading.value = true;
deptList.value = await deptApi.getAllDepts();
const rawData = await deptApi.getAllDepts();
console.log('原始部门数据:', rawData);
// 将扁平数据转换为树形结构
deptList.value = buildTree(rawData);
console.log('转换后的树形数据:', deptList.value);
} catch (error) {
console.error('加载部门列表失败:', error);
ElMessage.error('加载部门列表失败');
@@ -250,6 +269,50 @@ async function loadDeptList() {
}
}
// 将扁平数据转换为树形结构
function buildTree(flatData: SysDept[]): SysDept[] {
const tree: SysDept[] = [];
const map: Record<string, SysDept> = {};
// 创建映射表
flatData.forEach(item => {
if (item.deptID) {
map[item.deptID] = { ...item, children: [] };
}
});
// 构建树形结构
flatData.forEach(item => {
if (item.deptID) {
const node = map[item.deptID];
if (item.parentID && map[item.parentID]) {
// 有父节点添加到父节点的children中
if (!map[item.parentID].children) {
map[item.parentID].children = [];
}
map[item.parentID].children!.push(node);
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node);
}
}
});
// 清理空的children数组
function cleanEmptyChildren(nodes: SysDept[]) {
nodes.forEach(node => {
if (node.children && node.children.length === 0) {
delete node.children;
} else if (node.children && node.children.length > 0) {
cleanEmptyChildren(node.children);
}
});
}
cleanEmptyChildren(tree);
return tree;
}
// 新增部门
function handleAdd() {
isEdit.value = false;
@@ -451,6 +514,48 @@ async function saveRoleBinding() {
onMounted(() => {
loadDeptList();
});
// 节点拖拽逻辑
function allowDrop(draggingNode: any, dropNode: any) {
// 禁止拖拽到自己或子节点
return !isDescendant(draggingNode.data, dropNode.data);
}
// 判断是否是后代节点
function isDescendant(ancestor: SysDept, node: SysDept): boolean {
if (ancestor.deptID === node.deptID) return true;
if (node.children) {
return node.children.some(child => isDescendant(ancestor, child));
}
return false;
}
// 处理节点拖拽
async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string) {
try {
const dragData = draggingNode.data as SysDept;
const dropData = dropNode.data as SysDept;
// 更新被拖拽节点的父ID
if (dropType === 'inner') {
dragData.parentID = dropData.deptID;
} else {
dragData.parentID = dropData.parentID;
}
// 更新到后端
await deptApi.updateDept(dragData);
ElMessage.success('部门移动成功');
// 重新加载部门列表以确保数据一致性
await loadDeptList();
} catch (error) {
console.error('移动部门失败:', error);
ElMessage.error('移动部门失败');
// 重新加载部门列表以恢复原始状态
await loadDeptList();
}
}
</script>
<style scoped lang="scss">
@@ -471,6 +576,100 @@ onMounted(() => {
}
}
.tree-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
.dept-tree {
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
.node-label {
display: flex;
align-items: center;
flex: 1;
min-width: 200px;
.node-icon {
margin-right: 8px;
color: #67C23A;
font-size: 16px;
}
.node-name {
font-size: 16px;
font-weight: 500;
color: #303133;
margin-right: 12px;
}
}
.node-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1;
margin: 0 20px;
.info-item {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
}
.node-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
.el-button {
padding: 4px 8px;
font-size: 12px;
}
}
&:hover {
background-color: #f5f7fa;
border-radius: 4px;
.node-actions {
opacity: 1;
}
}
}
}
// Element Plus Tree 组件样式覆盖
:deep(.el-tree-node__content) {
height: auto !important;
padding: 0 !important;
}
:deep(.el-tree-node__expand-icon) {
padding: 6px;
color: #67C23A;
}
:deep(.el-tree-node) {
white-space: normal;
}
:deep(.el-tree-node__children) {
padding-left: 24px !important;
}
}
.el-table {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;

View File

@@ -8,73 +8,59 @@
</el-button>
</div>
<el-table
:data="menuList"
style="width: 100%"
<div class="tree-container">
<el-tree
ref="treeRef"
v-loading="loading"
border
stripe
row-key="menuID"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:data="menuList"
:props="treeProps"
node-key="menuID"
:default-expand-all="true"
:expand-on-click-node="false"
:check-on-click-node="false"
draggable
:allow-drop="allowDrop"
@node-drop="handleNodeDrop"
class="menu-tree"
>
<el-table-column prop="name" label="菜单名称" min-width="200" />
<el-table-column prop="menuID" label="菜单ID" min-width="150" />
<el-table-column prop="url" label="菜单路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="component" label="菜单组件" min-width="200" show-overflow-tooltip />
<el-table-column prop="icon" label="菜单图标" width="100">
<template #default="{ row }">
<el-icon v-if="row.icon">
<component :is="row.icon" />
<template #default="{ data }">
<div class="custom-tree-node">
<div class="node-label">
<el-icon v-if="data.icon" class="node-icon">
<component :is="data.icon" />
</el-icon>
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="80" />
<el-table-column prop="type" label="菜单类型" width="100">
<template #default="{ row }">
<el-tag :type="getMenuTypeTagType(row.type)" size="small">
{{ getMenuTypeText(row.type) }}
<span class="node-name">{{ data.name }}</span>
<el-tag
:type="getMenuTypeTagType(data.type)"
size="small"
>
{{ getMenuTypeText(data.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="creatorName" label="创建人" width="120" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleAddChild(row)"
link
>
新增子菜单
</div>
<div class="node-info">
<span class="info-item">ID: {{ data.menuID }}</span>
<span class="info-item" v-if="data.url">{{ data.url }}</span>
<span class="info-item" v-if="data.component">{{ data.component }}</span>
<span class="info-item">排序: {{ data.orderNum || 0 }}</span>
</div>
<div class="node-actions">
<el-button size="small" type="primary" @click.stop="handleAddChild(data)">
子菜单
</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
link
>
<el-button size="small" type="success" @click.stop="handleEdit(data)">
编辑
</el-button>
<el-button
type="primary"
size="small"
@click="handleBindPermission(row)"
link
>
绑定权限
<el-button size="small" type="warning" @click.stop="handleBindPermission(data)">
权限
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
link
>
<el-button size="small" type="danger" @click.stop="handleDelete(data)">
删除
</el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</el-tree>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
@@ -236,6 +222,7 @@ import { Plus } from '@element-plus/icons-vue';
const menuList = ref<SysMenu[]>([]);
const loading = ref(false);
const submitting = ref(false);
const treeRef = ref();
// 权限绑定相关数据
const permissionList = ref<SysPermission[]>([]);
@@ -282,12 +269,18 @@ const formRules: FormRules = {
// 级联选择器配置
const cascaderProps = {
value: 'menuID',
label: 'name',
value: 'value',
label: 'label',
children: 'children',
checkStrictly: true
};
// 树形组件配置
const treeProps = {
children: 'children',
label: 'name'
};
// 父菜单选项
const parentMenuOptions = computed(() => {
return buildParentMenuOptions(menuList.value);
@@ -295,11 +288,24 @@ const parentMenuOptions = computed(() => {
// 构建父菜单选项
function buildParentMenuOptions(menus: SysMenu[]): any[] {
return menus.map(menu => ({
menuID: menu.menuID,
name: menu.name,
children: menu.children ? buildParentMenuOptions(menu.children) : undefined
}));
const result: any[] = [];
function processNode(menu: SysMenu): any {
const option = {
value: menu.menuID,
label: menu.name,
children: menu.children && menu.children.length > 0
? menu.children.map(processNode)
: undefined
};
return option;
}
menus.forEach(menu => {
result.push(processNode(menu));
});
return result;
}
// 获取菜单类型标签类型
@@ -326,7 +332,11 @@ function getMenuTypeText(type: number | undefined): string {
async function loadMenuList() {
try {
loading.value = true;
menuList.value = await menuApi.getAllMenuList();
const rawData = await menuApi.getAllMenuList();
console.log('原始菜单数据:', rawData);
// 将扁平数据转换为树形结构
menuList.value = buildTree(rawData);
console.log('转换后的树形数据:', menuList.value);
} catch (error) {
console.error('加载菜单列表失败:', error);
ElMessage.error('加载菜单列表失败');
@@ -335,6 +345,50 @@ async function loadMenuList() {
}
}
// 将扁平数据转换为树形结构
function buildTree(flatData: SysMenu[]): SysMenu[] {
const tree: SysMenu[] = [];
const map: Record<string, SysMenu> = {};
// 创建映射表
flatData.forEach(item => {
if (item.menuID) {
map[item.menuID] = { ...item, children: [] };
}
});
// 构建树形结构
flatData.forEach(item => {
if (item.menuID) {
const node = map[item.menuID];
if (item.parentID && map[item.parentID]) {
// 有父节点添加到父节点的children中
if (!map[item.parentID].children) {
map[item.parentID].children = [];
}
map[item.parentID].children!.push(node);
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node);
}
}
});
// 清理空的children数组
function cleanEmptyChildren(nodes: SysMenu[]) {
nodes.forEach(node => {
if (node.children && node.children.length === 0) {
delete node.children;
} else if (node.children && node.children.length > 0) {
cleanEmptyChildren(node.children);
}
});
}
cleanEmptyChildren(tree);
return tree;
}
// 新增菜单
function handleAdd() {
isEdit.value = false;
@@ -547,6 +601,48 @@ async function savePermissionBinding() {
onMounted(() => {
loadMenuList();
});
// 节点拖拽逻辑
function allowDrop(draggingNode: any, dropNode: any) {
// 禁止拖拽到自己或子节点
return !isDescendant(draggingNode.data, dropNode.data);
}
// 判断是否是后代节点
function isDescendant(ancestor: SysMenu, node: SysMenu): boolean {
if (ancestor.menuID === node.menuID) return true;
if (node.children) {
return node.children.some(child => isDescendant(ancestor, child));
}
return false;
}
// 处理节点拖拽
async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string) {
try {
const dragData = draggingNode.data as SysMenu;
const dropData = dropNode.data as SysMenu;
// 更新被拖拽节点的父ID
if (dropType === 'inner') {
dragData.parentID = dropData.menuID;
} else {
dragData.parentID = dropData.parentID;
}
// 更新到后端
await menuApi.updateMenu(dragData);
ElMessage.success('菜单移动成功');
// 重新加载菜单列表以确保数据一致性
await loadMenuList();
} catch (error) {
console.error('移动菜单失败:', error);
ElMessage.error('移动菜单失败');
// 重新加载菜单列表以恢复原始状态
await loadMenuList();
}
}
</script>
<style scoped lang="scss">
@@ -567,6 +663,104 @@ onMounted(() => {
}
}
.tree-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
.menu-tree {
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
.node-label {
display: flex;
align-items: center;
flex: 1;
min-width: 200px;
.node-icon {
margin-right: 8px;
color: #409EFF;
font-size: 16px;
}
.node-name {
font-size: 16px;
font-weight: 500;
color: #303133;
margin-right: 12px;
}
.el-tag {
margin-left: 8px;
}
}
.node-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1;
margin: 0 20px;
.info-item {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
}
.node-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
.el-button {
padding: 4px 8px;
font-size: 12px;
}
}
&:hover {
background-color: #f5f7fa;
border-radius: 4px;
.node-actions {
opacity: 1;
}
}
}
}
// Element Plus Tree 组件样式覆盖
:deep(.el-tree-node__content) {
height: auto !important;
padding: 0 !important;
}
:deep(.el-tree-node__expand-icon) {
padding: 6px;
color: #409EFF;
}
:deep(.el-tree-node) {
white-space: normal;
}
:deep(.el-tree-node__children) {
padding-left: 24px !important;
}
}
.el-table {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;