SpringBoot + Vue3:实现树形权限菜单

#新星杯·14天创作挑战营·第11期#

Java系列文章


引言:为什么需要树形权限设计?

在企业级后台系统中,权限管理常涉及多层级结构(如菜单权限、操作权限、数据权限)。

痛点场景:

  • 权限层级嵌套复杂(如:系统管理 > 角色管理 > 新增/删除按钮)
  • 前端展示需动态渲染树形组件
  • 后端需高效查询并转换嵌套结构

本文目标

从数据库设计 → 后端递归逻辑 → 前端树组件渲染,实现完整的树形权限链路,并提供高性能优化方案。

一、数据库设计:如何存储层级关系?

1.1 表结构设计(核心字段)

CREATE TABLE `tb_permission` (
  `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `parent_id` int DEFAULT NULL COMMENT '父级id',
  `permission_name` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '权限标识',
  `module_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '模块名称',
  `menu_type` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '菜单类型',
  `icon` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '菜单图标',
  `path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '菜单路由',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `unq_permission` (`permission_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=129 DEFAULT CHARSET=utf8 COMMENT='权限表';

设计要点:

  • parent_id:权限菜单通常采用树形结构,包含多级菜单。每一级菜单都有一个唯一的父级ID(Parent
    id),用于表示该菜单项的上级菜单。顶级菜单的父级ID可以设置为0或null。
  • permission_name:每个菜单或按钮项对应的操作权限需要通过权限标识来定义。权限标识通常是字符串格式,它描述了用户对于某个资源的访问或操作权限。例如,“ORGANIZE:USER”,代表用户管理模块-菜单权限,“ORGANIZE:USER:UPDATE”代表用户更新-按钮权限。

1.2 示例数据

在这里插入图片描述

二、后端实现:SpringBoot如何生成树结构?

2.1 实体类设计(关键注解)

@Data
public class TreeMenu {
    private Integer id;
    private Integer parentId;
    private String permissionName;
    private String moduleName;
    private String menuType;
    private String icon;
    private String path;
    private String createTime;
    private Integer level;
    private Integer key;
    private List<TreeMenu> children;
}

2.2 核心算法:递归构建树

//权限管理-树形权限
public ArrayList<TreeMenu> searchPermissionTree() {
     return  deepTree(permissionDao.searchPermissionTree());
 }
public List<TreeMenu> buildPermissionTree() {
    // 1. 查询所有权限(单次数据库查询!)
    List<TreeMenu> allPermissions = permissionDao.searchPermissionAll();
    
    // 2. 递归构建树(父节点ID=0为根)
    return deepTree(allPermissions ); 
}

/**
 * 转换树形结构
 * @param menuList
 * @return
 */
public ArrayList<TreeMenu> deepTree(ArrayList<TreeMenu> menuList) {
    //创建list集合,用于数据最终封装
    ArrayList<TreeMenu> finalNode = new ArrayList<>();

    for (TreeMenu menus : menuList) {
        Integer topId = 0;
        //判断Pid是否等于0  0是最高的节点 将查询出的数据放进list集合
        if (topId.equals(menus.getParentId())) {
            finalNode.add(selectTree(menus, menuList));
        }
    }
    // 递归设置节点层级
    for (TreeMenu menu : finalNode) {
        setNodeLevel(menu,1);
    }

    return finalNode;
}
public TreeMenu selectTree(TreeMenu m1, ArrayList<TreeMenu> menuList) {
    //因为向一层菜单里面放二层菜单,二层里面还要放三层,把对象初始化
    m1.setChildren(new ArrayList<TreeMenu>());
    //遍历所有菜单list集合,进行判断比较,比较id和pid值是否相同
    for (TreeMenu m2 : menuList) {
        //判断 id和pid值是否相同
        if (m1.getId().equals(m2.getParentId())) {
            //如果children为空,进行初始化操作
            if (m1.getChildren() == null) {
                m1.setChildren(new ArrayList<TreeMenu>());
            }
            //把查询出来的子菜单放到父菜单里面
            m1.getChildren().add(selectTree(m2, menuList));
        }
    }
    return m1;
}
// 递归设置节点层级
public void setNodeLevel(TreeMenu node, int level) {
    node.setLevel(level);
    node.setKey(node.getId());
    for (TreeMenu child : node.getChildren()) {
        setNodeLevel(child, level + 1);
    }
}

提示:这里把查询出来的权限数组,通过工具类转换成树形结构,实现方法不一

2.3 Controller:树形结构数据

2.3.1 编写树形数据接口

/**
 1. 权限管理-树形结构
 2. @return
 */
@GetMapping("/permission/searchPermissionTree")
@SaCheckLogin
public R searchPermissionTree() {
    ArrayList<TreeMenu> list = permissionService.searchPermissionTree();
    return R.success(list);
}

2.3.2 Apifox测试接口

在这里插入图片描述

三、前端渲染:Vue3 + Ant Design 动态树实现

3.1前端页面实现

在这里插入图片描述

3.2 前端代码

<template>
  <div class="menu">
    <a-card shadow="never">
      <template #title>
        <div class="menu-header">
          <div class="menu-nav">
            <a-input placeholder="请输入搜索内容" allow-clear />
            <a-button type="primary">查询</a-button>
            <a-button type="primary">重置</a-button>
          </div>
          <div><a-button type="primary" @click="onAdd">新增</a-button></div>
        </div>
      </template>
      <a-table :columns="menuTableColumn" :data-source="menuTableData" :pagination="false" rowKey="id" size="middle" bordered>
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'action'">
            <a-button type="text" @click="onAddBatch(record)">新增</a-button>
            <a-button type="text" @click="onEdit(record)">编辑</a-button>
            <a-button type="text" @click="onDelete(record)">删除</a-button>
          </template>
        </template>
      </a-table>
    </a-card>
    <diaLogModal v-model="diaLogData.show" :diaLogData="diaLogData" @submit="onDialogConfirm">
      <a-form ref="permissionFormRef" :model="dataForm" :label-col="{ style: { width: '150px' } }" :wrapper-col="{ span: 14 }">
        <a-form-item label="菜单名称" name="moduleName" :rules="[{ required: true, message: '菜单名称不能为空' }]">
          <a-input v-model:value="dataForm.moduleName" placeholder="请选择菜单名称" />
        </a-form-item>
        <a-form-item label="图标" name="icon">
          <a-input v-model:value="dataForm.icon" placeholder="请输入图标名称" />
        </a-form-item>
        <a-form-item label="权限标识" name="permissionName" :rules="[{ required: true, message: '权限标识不能为空' }]">
          <a-input v-model:value="dataForm.permissionName" placeholder="请输入权限标识" />
        </a-form-item>
        <a-form-item label="路由地址" name="path">
          <a-input v-model:value="dataForm.path" placeholder="请输入路由地址" />
        </a-form-item>
        <a-form-item label="菜单类型" name="menuType">
          <a-select v-model:value="dataForm.menuType" :options="menuTypeData" placeholder="请选择菜单类型"></a-select>
        </a-form-item>
      </a-form>
    </diaLogModal>
    <sunLoading v-model="loading"></sunLoading>
  </div>
</template>

<script setup>
import { onMounted, ref, reactive, h, watch } from 'vue'
import { searchPermissionTree, editPermission, deletePermission } from '@/api/modules/system'
import { deepData, getAllChildIds } from '@/util/util'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import { Modal, message } from 'ant-design-vue'

// 初始化数据
function useInitData() {
  const loading = ref(false)
  const menuTableData = ref([])
  const menuTableColumn = ref([
    {
      title: '菜单名称',
      dataIndex: 'moduleName',
      key: 'moduleName',
      width: 150
    },
    {
      title: '图标',
      dataIndex: 'icon',
      key: 'icon',
      width: 100
    },
    {
      title: '权限标识',
      dataIndex: 'permissionName',
      key: 'permissionName',
      width: 200,
      ellipsis: true
    },
    {
      title: '路由地址',
      dataIndex: 'path',
      key: 'path',
      width: 150
    },
    {
      title: '菜单类型',
      dataIndex: 'menuType',
      key: 'menuType',
      width: 100
    },
    {
      title: '菜单层级',
      dataIndex: 'level',
      key: 'level',
      width: 100
    },
    {
      title: '创建时间',
      dataIndex: 'createTime',
      key: 'createTime',
      width: 150
    },
    {
      title: '操作',
      dataIndex: 'action',
      key: 'action',
      width: 160
    }
  ])
  const diaLogData = reactive({
    show: false,
    title: '菜单管理',
    width: '40%',
    buttons: ['取消', '确定']
  })
  const dataForm = reactive({
    id: '',
    parentId: '',
    permissionName: '',
    moduleName: '',
    menuType: '菜单',
    icon: '',
    path: ''
  })
  const originForm = reactive({
    id: '',
    parentId: '',
    permissionName: '',
    moduleName: '',
    menuType: '菜单',
    icon: '',
    path: ''
  })
  const menuTypeData = ref([
    {
      label: '菜单',
      value: '菜单'
    },
    {
      label: '按钮',
      value: '按钮'
    }
  ])
  const permissionFormRef = ref()

  const getMenuData = () => {
    loading.value = true
    searchPermissionTree()
      .then(res => {
        loading.value = false
        menuTableData.value = res ? deepData(res) : []
      })
      .catch(err => {})
  }
  const getInitData = () => {
    // getAllPermissions()
    //   .then(res => {
    //   })
    //   .catch(err => {})
  }

  onMounted(() => {
    getMenuData()
    getInitData()
  })

  return {
    menuTableData,
    menuTableColumn,
    diaLogData,
    dataForm,
    originForm,
    menuTypeData,
    loading,
    permissionFormRef,
    getMenuData
  }
}

// 操作事件
function useAction() {
  const onAdd = () => {
    diaLogData.show = true
    Object.assign(dataForm, originForm)
    dataForm.parentId = 0
  }
  const onAddBatch = row => {
    diaLogData.show = true
    Object.assign(dataForm, originForm)
    dataForm.parentId = row.id
  }
  const onEdit = row => {
    diaLogData.show = true
    Object.assign(dataForm, row)
  }
  const onDelete = row => {
    Modal.confirm({
      title: '警告',
      icon: h(ExclamationCircleOutlined),
      content: '是否确认删除?',
      okText: '确认',
      cancelText: '取消',
      onOk() {
        let deleteIds = [row.id, ...getAllChildIds(row)]
        // 获取所有的子级ID
        deletePermission({ ids: deleteIds })
          .then(res => {
            message.success('删除成功')
            diaLogData.show = false
            getMenuData()
          })
          .catch(err => {})
      }
    })
  }

  const onDialogConfirm = () => {
    permissionFormRef.value
      .validateFields()
      .then(res => {
        console.log('dataForm', dataForm)

        editPermission(dataForm)
          .then(res => {
            message.success('操作成功')
            diaLogData.show = false
            getMenuData()
          })
          .catch(err => {})
      })
      .catch(err => {})
  }

  watch(
    () => diaLogData.show,
    res => {
      if (!res) permissionFormRef.value.resetFields()
    }
  )

  return {
    onAdd,
    onEdit,
    onAddBatch,
    onDelete,
    onDialogConfirm
  }
}

const { menuTableData, menuTableColumn, diaLogData, dataForm, originForm, menuTypeData, loading, permissionFormRef, getMenuData } = useInitData()
const { onAdd, onEdit, onAddBatch, onDelete, onDialogConfirm } = useAction()
</script>

<style lang="scss" scoped>
.menu {
  .menu-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    height: 50px;
    .menu-nav {
      display: flex;
      gap: 16px;
    }
  }
}
</style>

四、总结

树形权限在Spring Boot中的实现要点

  • 模型设计:
    • 使用自关联表结构存储具有层级关系的数据(如角色或菜单)。
    • 关键字段包括id, module_name, parent_id等。
  • 递归查询:
    • 实现获取完整树形结构的功能。可以使用Hutool工具类、MyBatis等框架简化操作。
  • 权限验证:
    • 基于用户角色和权限进行访问控制。
    • 考虑子节点继承父节点权限的逻辑。
  • 前端展示:
    • 利用JavaScript库(如Ant-Design, jsTree)将后端数据转化为树状图或菜单。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值