重要链接:
「系列文章目录」

「项目源码(GitHub)」

本篇目录

  • 前言
  • 一、后端实现
    • 1.表设计
    • 2.pojo
    • 3.菜单查询接口(树结构查询)
  • 二、前端实现
    • 1.后台页面设计
    • 2.数据处理
    • 3.添加路由与渲染菜单
  • 下一步

前言

果然不出大家所料,我又没能按时写完文章。时至今日我已经没有什么愧疚的感觉了,这大概就是成长吧。不过话又说回来了,曾经我也是一个月写 10 篇的男人啊。

这篇文章的主要内容是实现按照用户角色动态加载后台管理页面的菜单,有如下几个重点:

  • 如何设计数据库以建立用户-角色-菜单之间的联系
  • 如何查询与处理树结构的数据
  • Vue 如何实现动态加载路由

在正文之前,给大家讲讲这两周背后的故事:

1、在官方活动 ——「CSDN 原力计划」 的加持下,我体验了一波飞速增长,读者数量翻了一倍。有那么几天我膨胀的不行,甚至一度想下定决心周更以早日达到破万的目标。后来我在房间里沉思良久,嚯完了一大杯快乐水,放弃了这个危险的念头。

2、最近我发现咱们这个项目除了入门练习,还可以作为一些常见应用的 脚手架。比如图书查询与管理系统、个人博客(主页)、企业门户网站之类。上周我甚至在这个项目的基础上做了一个投票系统给公司内部举办活动用,虽然自己感觉稀碎,但忽悠不懂代码的同事足够了。

为了让这个项目看起来正经一点,我对结构做了一些调整:

  • 把原来的开发的部分作为前台,并取消登录拦截
  • 登录后跳转到后台,在后台登出后跳转到前台首页
  • 去除图书馆页面增删改功能,之后会放在后台内容管理部分

其实就是前端配置了下路由,修改了图书组件,后端取消了图书查询端口的拦截,相信大家自己能做到哈。之前有读者指出我一个地方漏掉了导入组件的代码,一开始我以为真漏了,后来想起来应该是特意没把整个组件复制上去,因为感觉没有太大必要,大家不要一复制代码就想没有错误地跑起来,不可能的,就是直接 clone 别人的仓库都会有各种各样的问题出现,出了问题百度就好啦。

3、有读者给我发了十几封邮件,好几次贴了上几千行的错误信息,提问句式是:现在的问题是 XXXXX,然后就没了,连请我帮忙看看这样的话都没有,我好南啊(给我气笑了.jpg)

不过会说话的读者也越来越多啦,我脸皮薄,很多时候还是不忍心拒绝你们的,这两周也为了回答问题花了不少时间。更有一些认真的读者发现了项目的 BUG 并指出了改正方法,我感到十分欣慰,希望老天爷多给我分配一些这样的读者。随着项目越来越复杂,难免会出现各种纰漏,欢迎大家从各个方面指出不足,也可以直接在 GitHub 上提 Issue 或 PR,咱们一起把这个东西做好。

一、后端实现

实现动态加载菜单功能的第一步,是完成根据当前用户查询出可访问菜单信息的接口。

1.表设计

基于之前讲过的 RBAC 原则,我们应该设计一张角色表,用角色去对应菜单。同时,为了建立用户与角色、角色与菜单之间的关系,又需要两张中间表。加上之前的用户表,一共需要五张表,各表字段如下图所示:

这里我为后台管理专属的表加上了 admin 前缀。起名是一件十分重要的事情,好的名字是保证代码质量的前提,你们不要学我起的这么随意,很多公司都有自己的起名原则,可以学习一下。(推荐 《阿里巴巴 Java 开发手册》)

另外不同于之前 book 和 category 的做法,这里没有用到外键。一般决定用不用外键需要看系统对数据一致性和效率的要求哪个更突出,但是我觉得数据一致性问题都可以通过代码解决,用外键又麻烦又别扭。

这里我简单介绍下 admin_menu 表的各个字段:

字段 解释
id 唯一标识
path 与 Vue 路由中的 path 对应,即地址路径
name 与 Vue 路由中的 name 属性对应
name_zh 中文名称,用于渲染导航栏(菜单)界面
icon_cls element 图标类名,用于渲染菜单名称前的小图标
component 组件名,用于解析路由对应的组件
parent_id 父节点 id,用于存储导航栏层级关系

表中的数据你可以自己设计,为了方便测试,记得多注册一个账号以配置不同的角色,并为角色配置相应的菜单。嫌麻烦的话就直接执行我的 sql 文件:

https://github.com/Antabot/White-Jotter/blob/master/wj/wj.sql

里面有三个账号,admin、test 和 editor,密码都是 123,admin 的角色是系统管理员,editor 是内容管理员,test 是空的。

2.pojo

因为我们使用了 JPA 做 ORM,创建 POJO 时需要注意以下几点:

P.S.其实过去我们创建的 POJO 应该再具体一点,称之为 PO(persistant object,持久对象)或者 Entity(实体),并使用 DTO(Data Transfer Object)与客户端进行交互。教程做了一些简化,源码添加了这些分类。

  • windows 下默认不区分 mysql 字段大小写,而 linux 区分,所以数据库字段不推荐大小写混用(最好都小写),而 Java 属性一般采用小驼峰法命名,JPA 会自动将小驼峰命名转换为下划线命名,比如 nameZh 自动转换为 name_zh
  • 数据库中不存在对应字段的属性,需要用 @Transient 注记标注出来

我们需要创建 AdminUserRoleAdminRoleAdminRoleMenuAdminMenu 四个 PO,比较特殊的是 AdminMenu,这里我贴出来代码:

package com.gm.wj.pojo;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;import javax.persistence.*;
import java.util.List;@Entity
@Table(name = "admin_menu")
@JsonIgnoreProperties({"handler", "hibernateLazyInitializer"})
public class AdminMenu {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)@Column(name = "id")int id;String path;String name;String nameZh;String iconCls;String component;int parentId;@TransientList<AdminMenu> children;// getter and setter...

与数据库中不同的是这个 Children 属性,用于存储子节点。

3.菜单查询接口(树结构查询)

根据用户查询出对应菜单的步骤是:

  • 利用 shiro 获取当前登录用户的 id
  • 根据用户 id 查询出该用户对应所有角色的 id
  • 根据这些角色的 id,查询出所有可访问的菜单项
  • 根据 parentId 把子菜单放进父菜单对象中,整理返回有正确层级关系的菜单数据

为了实现这个接口,我们需要新增AdminUserRoleDAOAdminRoleMenuDAOAdminMenuDAO 三个数据库访问对象并编写 Service 对象,也可以顺道把 AdminRole 一套给写了,不过现在还用不上。

AdminMenuService中需要实现一个根据当前用户查询出所有菜单项的方法:

public List<AdminMenu> getMenusByCurrentUser() {// 从数据库中获取当前用户String username = SecurityUtils.getSubject().getPrincipal().toString();User user = userService.findByUsername(username);// 获得当前用户对应的所有角色的 id 列表List<Integer> rids = adminUserRoleService.listAllByUid(user.getId()).stream().map(AdminUserRole::getRid).collect(Collectors.toList());// 查询出这些角色对应的所有菜单项List<Integer> menuIds = adminRoleMenuService.findAllByRid(rids).stream().map(AdminRoleMenu::getMid).collect(Collectors.toList());List<AdminMenu> menus = adminMenuDAO.findAllById(menuIds).stream().distinct().collect(Collectors.toList());// 处理菜单项的结构handleMenus(menus);return menus;
}

这个方法的主体部分对应上面的前三步。这里我们使用了 stream 来简化列表的处理,包括使用 map() 提取集合中的某一属性,通过 distinct() 对查询出的菜单项进行了去重操作,避免多角色情况下有冗余的菜单等。

接下来需要把查询出来的菜单数据列表整合成具有层级关系的菜单树,也就是编写 handleMenus() 方法。

这里我多说一嘴,由于导航菜单一般不会特别长,所以我们采用这种一次性取出的方式。上述过程中我们会在遍历列表的同时查询数据库,这样的多次交互在前台需要尽量避免,最好先一次性查询出全量数据以减轻服务器负担。但后台一般是给管理人员使用的,没有那么大的流量,所以不用担心。

如果数据量特别大,那就应该考虑按节点动态加载。即通过监听节点的展开事件向后端发送节点 id 作为参数,查询出所有的子节点,并在前端动态渲染。这种方式的实现等以后用到了再具体讲。

整合查询出的菜单数据的思路如下:

  • 遍历菜单项,根据每一项的 id 查询该项出所有的子项,并放进 children 属性
  • 剔除掉所有子项,只保留第一层的父项。比如 c 是 b 的子项,b 是 a 的子项,我们最后只要保留 a 就行,因为 a 包含了 b 和 c

整合方法如下:

public void handleMenus(List<AdminMenu> menus) {for (AdminMenu menu : menus) {List<AdminMenu> children = getAllByParentId(menu.getId());menu.setChildren(children);}Iterator<AdminMenu> iterator = menus.iterator();while (iterator.hasNext()) {AdminMenu menu = iterator.next();if (menu.getParentId() != 0) {iterator.remove();}}
}

先说明一下,查询树结构的方法有很多,我见过有按层级查询的,也有直接根据实际情况写死了的。我使用的这种方式好处就是无论几层都能正确地查询出来,虽然执行效率会低一些,但反正是后台用的,问题不大。

有的同学可能对这个方法有疑问,似乎遍历一次应该只有两个层级才对。此外,menus 明明已经查询出来了,在遍历中每次仍然调用查询方法,是不是会频繁访问数据库,并创建额外的对象,增加不必要的开销?虽然是后台,这么写也太离谱了吧?

(感谢 @m0_46435907 同学经过认真分析提供了这个问题的解答思路)

这里可以放心,JPA 为我们提供了持久化上下文(Persistence Context,是一个实体的集合),用于确保相同的持久化对象只有一个实例,且在存在相应实例时不会再次访问数据库(详细内容见 「JPA Persistence Context」)。因此,我们查询到的 children 列表中的每一个 AdminMenu 对象实例都复用了 Menus 列表中的 AdminMenu 对象。

同时,在 Java 里对象都是引用类型,假设我们把 b 放进了 a 的 children 里,又把 c 放进了 b 的 children 里,那么 c 就被放进了 a 的 children 的 children 里。因此,经过一次遍历,就能得到正确的层级关系。

而下面的 remove 方法,实际上是把对象名指向了 null,而对象本身仍然存在。所以虽然我们无法再通过 b、c 获取到原来的对象,但 a 里面的信息是不会变的。

为什么删除子项时用 iterator.remove() 而不用 List 的 remove 方法呢?是因为使用 List 遍历时,如果删除了某一个元素,后面的元素会补上来,也就是说后面元素的索引和列表长度都会发生改变。而循环仍然继续,循环的次数仍是最初的列表长度,这样既会漏掉一些元素,又会出现下标溢出,运行时表现就是会报 ConcurrentModificationException。而 iterator.remove() 进行了一些封装,会把当前索引和循环次数减 1,从而避免了这个问题。

JDK 8 以上版本可以使用 lambda 表达式:

public void handleMenus(List<AdminMenu> menus) {menus.forEach(m -> {List<AdminMenu> children = getAllByParentId(m.getId());m.setChildren(children);});menus.removeIf(m -> m.getParentId() != 0);
}

MenuController 中根据请求调用查询逻辑,代码如下:

 @GetMapping("/api/menu")public List<AdminMenu> menu() {return adminMenuService.getMenusByCurrentUser();}

完成后可以测试下 menu 接口。在此之前要确保系统处于登录状态哈,要不可查询不到信息。

二、前端实现

前端要做的事情就是处理后端传来的数据,并传递给路由和导航菜单,以实现动态渲染。

1.后台页面设计

我目前大概做了下面几个组件:

主要是实现了页面基础设计,并方便测试动态加载,目前还没有任何功能。效果大概是这个样子:

可以参考 GitHub 上的源码,也可以自己设计一下。开发完组件别忘了添加后台首页的路由哈,别的菜单对应的路由可以动态加载,这个不预先写好就进不去页面了。

2.数据处理

之前我们设计 AdminMenu 表,实际上包含了前端路由(router)与导航菜单需要的信息,从后台传来的数据,需要被整理成路由能够识别的格式。导航菜单倒是无所谓,赋给相应的属性就行。

进行格式转换的方法如下:

const formatRoutes = (routes) => {let fmtRoutes = []routes.forEach(route => {if (route.children) {route.children = formatRoutes(route.children)}let fmtRoute = {path: route.path,component: resolve => {require(['./components/admin/' + route.component + '.vue'], resolve)},name: route.name,nameZh: route.nameZh,iconCls: route.iconCls,children: route.children}fmtRoutes.push(fmtRoute)})return fmtRoutes
}

这里传入的参数 routes 代表我们从后端获取的菜单列表。遍历这个列表,首先判断一条菜单项是否含子项,如果含则进行递归处理。

下面的语句就是把路由的属性与菜单项的属性对应起来,其它的都好说,主要是 component 这个属性是一个对象,因此需要根据名称做出解析(即获取对象引用)。同时我们需要把组件导入进来,因此可以利用 Vue 的异步组件加载机制(也叫懒加载),在解析的同时完成导入。

我们数据库中存储的是组件相对 @components/admin 的路径,所以解析时要根据 js 文件的位置加上相应的前缀。

3.添加路由与渲染菜单

首先我们要思考一下什么时候需要去请求接口并渲染菜单。如果访问每个页面都加载一次,有点太浪费了。如果只在后台主页面渲染时加载一次,那么就不能在子页面中进行刷新操作。因此我们可以继续利用路由全局守卫,在用户已登录且访问以 /admin 开头的路径时请求菜单信息,完整的代码如下

router.beforeEach((to, from, next) => {if (store.state.user.username && to.path.startsWith('/admin')) {initAdminMenu(router, store)}// 已登录状态下访问 login 页面直接跳转到后台首页if (store.state.username && to.path.startsWith('/login')) {next({path: 'admin/dashboard'})}if (to.meta.requireAuth) {if (store.state.user.username) {axios.get('/authentication').then(resp => {if (resp) next()})} else {next({path: 'login',query: {redirect: to.fullPath}})}} else {next()}}
)

为了保证用户确实登录,仍旧需要向后台发送一个验证请求。

initAdminMenu 用于执行请求,调用格式化方法并向路由表中添加信息,代码如下:

const initAdminMenu = (router, store) => {if (store.state.adminMenus.length > 0) {return}axios.get('/menu').then(resp => {if (resp && resp.status === 200) {var fmtRoutes = formatRoutes(resp.data)router.addRoutes(fmtRoutes)store.commit('initAdminMenu', fmtRoutes)}})
}

首先判断一下 store 里有没有菜单数据,如果有说明是正常跳转,无需重新加载。(第一次进入或进行刷新时需要重新加载)

记得在 store.state 里添加变量 adminMenu: [],同时在 mutations 里添加如下方法:

 initAdminMenu (state, menus) {state.adminMenus = menus}

这个 menus 就是上面的 fmtRoutes。当然也可以把数据放进 localStorage,记得登出时清空就好了。

最后,我们来编写一下菜单组件 AdminMenu.vue

<template><div><el-menu:default-active="'/admin/users'"class="el-menu-admin"routermode="vertical"background-color="#545c64"text-color="#fff"active-text-color="#ffd04b"><div style="height: 80px;"></div><template v-for="(item,i) in adminMenus"><!--index 没有用但是必需字段且为 string --><el-submenu :key="i" :index="i + ''" style="text-align: left"><span slot="title" style="font-size: 17px;"><i :class="item.iconCls"></i>{{item.nameZh}}</span><el-menu-item v-for="child in item.children" :key="child.path" :index="child.path"><i :class="child.icon"></i>{{ child.nameZh }}</el-menu-item></el-submenu></template></el-menu></div>
</template><script>export default {name: 'AdminMenu',computed: {adminMenus () {return this.$store.state.adminMenus}}}
</script>

这里我们利用 element 的导航栏组件,进行两层循环,渲染出我们需要的菜单。<el-submenu> 代表一个有子菜单的菜单项,<el-menu-item> 则代表单独的菜单项。这么命名似乎有点毛病,又似乎没毛病。。。

如果有三个层级,就是 <el-submenu><el-submenu> 再套 <el-menu-item> ,以此类推。

终于大功告成了。我们来试试用 admin 账户登录,就是上面的效果,菜单是全的:

可以点击用户信息菜单,跳转到相应的路由并加载组件:

使用 editor 账号登录,则只显示内容管理

下一步

这个页面做的比较着急,接下来计划按之前的设计完善各个模块,包括:

  • 开发用户角色、角色菜单分配组件
  • 迁移图书管理功能
  • 完成其它模块的基础界面
  • 实现功能级权限并开发分配组件

下篇文章的重点是功能级权限的实现,其它方面会顺带提到,但不会说的太细,因为都是讲过的知识点。

总算写完了,但感觉这篇文章还有些地方需要润色一下,大家有看不懂的地方可以尽情提,但像为什么粘了代码跑不起来这种问题我就直接忽略啦。

另外这篇的内容参考了「 _江南一点雨 」,也就是松哥的实现思路,我最早就是跟着松哥的项目学的 Vue,咱们这个项目里前端很多的代码都是模仿松哥的「微人事」 项目写的,现在微人事已经 11.6k star 了,大家可以去学习一下,比我做的要完善许多。

上一篇:Vue + Spring Boot 项目实战(十四):用户认证方案与完善的访问拦截

下一篇:Vue + Spring Boot 项目实战(十六):功能级访问控制的实现

Vue + Spring Boot 项目实战(十五):动态加载后台菜单相关推荐

  1. Vue + Spring Boot 项目实战(五):数据库的引入

    文章目录 一.引入数据库 1.安装数据库 2. 安装mysql 3. MySQL客户端 4. .使用 Navicat 创建数据库与表 二.使用数据库验证登录 1.项目相关配置 2.登录控制器 2.1. ...

  2. Vue + Spring Boot 项目实战(六):前端路由与登录拦截器

    本篇目录 前言 一.前端路由 二.使用 History 模式 三.后端登录拦截器 1.LoginController 2.LoginInterceptor 3.WebConfigurer 4.效果检验 ...

  3. Vue + Spring Boot 项目实战(四):数据库的引入

    这一篇的主要内容是引入数据库并实现通过数据库验证用户名与密码. 本篇目录 一.引入数据库 1.安装数据库 2.使用 Navicat 创建数据库与表 二.使用数据库验证登录 1.项目相关配置 2.登录控 ...

  4. Vue + Spring Boot 项目实战(二十一):缓存的应用

    重要链接: 「系列文章目录」 「项目源码(GitHub)」 本篇目录 前言 一.缓存:工程思想的产物 二.Web 中的缓存 1.缓存的工作模式 2.缓存的常见问题 三.缓存应用实战 1.Redis 与 ...

  5. Vue + Spring Boot 项目实战(九):核心功能的前端实现

    本篇目录 前言 一.代码部分 1.EditForm.vue(新增) 2.SearchBar.vue(新增) 3.Books.vue(修改) 4.LibraryIndex.vue(修改) 5.SideM ...

  6. Vue + Spring Boot 项目实战(十七):后台角色、权限与菜单分配

    重要链接: 「系列文章目录」 「项目源码(GitHub)」 本篇目录 前言 一.角色.权限分配 1.用户信息表与行数据获取 2.角色分配 3.权限分配 二.菜单分配 下一步 前言 有感于公司旁边的兰州 ...

  7. Vue + Spring Boot 项目实战(七):导航栏与图书页面设计

    本篇目录 前言 一.导航栏的实现 1.路由配置 2.使用 NavMenu 组件 二.图书管理页面 1.LibraryIndex.vue 2.SideMenu.vue 3.Books.vue 前言 之前 ...

  8. Vue + Spring Boot 项目实战(三):前后端结合测试(登录页面开发)

    前面我们已经完成了前端项目 DEMO 的构建,这一篇文章主要目的如下: 一.打通前后端之间的联系,为接下来的开发打下基础 二.登录页面的开发(无数据库情况下) 本篇目录 前言:关于开发环境 一.后端项 ...

  9. Vue + Spring Boot 项目实战(七):前端路由与登录拦截器

    文章目录 前言 一.前端路由 二.使用 History 模式 三.后端登录拦截器 3.1. LoginController 3.2. LoginInterceptor 3.3. WebConfigur ...

最新文章

  1. eclipse运行android项目出现The connection to adb is down, and a severe error has occured.的问题
  2. boost::intrusive::member_value_traits用法的测试程序
  3. 【mysq】远程访问权限(允许远程连接)
  4. C和指针之字符串编程练习1
  5. 首届(2017)中国·呼和浩特创新创业创意大赛·华东分站赛在乌镇成功举办
  6. 好的安排小明(南阳19)(DFS)
  7. POJ 1080 Human Gene Functions(DP:LCS)
  8. java-信息安全(十六)-双向认证
  9. 【深度强化学习】交叉熵方法
  10. PredRNN++: Towards A Resolution of the Deep-in-Time Dilemma in Spatiotemporal Predictive Learning 翻译
  11. 2022年初级会计职称考试会计实务练习题及答案
  12. 1-8 (4). RabbitMQ高级特性-消费端ACK
  13. 【微信小程序】全局数据共享
  14. Ubuntu20.02安装TPLink WDN7200H无线网卡
  15. 保姆级教程:顶会论文写作指南
  16. php操作主从mysql_PHP 操作MySQL数据库
  17. python 输入一个数,判断是不是水仙花数
  18. 2ASK的调制解调,编码解码,还有它的误码率,功率谱(语音信号的)
  19. 武磊进球,我连夜分析了武球王2019赛季数据
  20. matplotlib绘图

热门文章

  1. 堆内存(7)——内存释放入口函数_lib_free
  2. 技术总监是干什么的?
  3. Docker基础学习
  4. php类中遍历中的rewind方法,PHP rewind( )用法及代码示例
  5. 想要的资源百度搜不到?6个只有老师傅才知道的网站,悄悄领走
  6. CentOS8安装GNOME3桌面并设置开机启动图形界面
  7. 海康威视IPCamera图像捕获方法:捕获实时流,将实时流解码成YV12,然后转换成RGB
  8. web 页面的提交方式
  9. .net笔试题(二)
  10. 格式工厂转码错误原因0x000000001 怎么办