Solon + EasyQuery + ElementPlus 实现后台管理系统之 07-动态菜单与动态路由
AI 摘要
文章基于 Solon + EasyQuery + ElementPlus 实现后台管理系统的动态菜单与动态路由。后端根据角色返回启用且排序的权限列表,前端将其转为树形菜单并渲染侧边栏;同时把菜单数据转换为路由规则,登录或刷新时通过路由守卫动态 addRoute,解决刷新失效问题,并把权限数据存入 Pinia 仓库,避免重复请求。
动态菜单
后端 API
核心注意事项:
- 后端 API 中,根据当前登录用户对应的角色,获取角色关联的权限列表。
- 如果当前登录用户对应的角色 ID 为 1 即最高级默认角色,获取所有权限列表。
- 查询权限列表时,只查询启用的权限,不查询隐藏的权限,且根据排序 ID 进行排序,越小越靠前。
- 查询权限列表,可以借助 SaToken Session 进行存取,减少查询压力。
- 角色关联的权限,在数据库中以字符串 1,2,3,4 的形式存在,可以将其格式化成数组,使用 SQL IN 语句进行查询。
编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),提供获取当前登录用户权限列表方法:
/**
* 权限认证控制器
* @visduo
*
* @Valid:请求参数校验
* 参考文档:https://solon.noear.org/article/49
*/
@Valid
@Mapping("/passport")
@Controller
public class PassportController {
// ...
/**
* 获取当前登录用户权限列表
* @visduo
*
* @return 当前登录用户权限列表
*/
@Get
@Mapping("/currentPerms")
public ResponseResult currentPerms() {
// 从会话中获取权限列表
List<PermsEntity> permsList = (List<PermsEntity>) StpUtil.getSession().get("perms");
if (permsList != null) {
// 权限列表不为空,返回权限列表
return ResponseResult.success("查询成功", permsList);
}
// 获取当前登录用户对象及对应角色
UsersEntity usersEntity = (UsersEntity) StpUtil.getSession().get("currentUser");
RoleEntity roleEntity = usersEntity.getRole();
if(roleEntity.getId() == 1) {
// 默认角色返回所有权限
permsList = easyEntityQuery.queryable(PermsEntity.class)
.where(p -> {
// 只查询启用的权限,不查询隐藏的权限
p.status().eq(1);
})
.orderBy(p -> {
// 根据排序ID进行排序,越小越靠前
p.sortId().asc();
})
.toList();
} else {
// 非默认角色返回对应角色权限
// 数据库中存储的角色权限列表为1,2,3,4格式,将其格式化成数组
String[] permsIds = roleEntity.getPerms().split(",");
// 将字符串数组转换成整数数组
Integer[] permsIdsInt = Arrays.stream(permsIds).map(Integer::parseInt).toArray(Integer[]::new);
permsList = easyEntityQuery.queryable(PermsEntity.class)
.where(p -> {
// 只查询启用的权限,不查询隐藏的权限
p.status().eq(1);
// 只查询角色授权的权限列表
p.id().in(permsIdsInt);
})
.orderBy(p -> {
// 根据排序ID进行排序,越小越靠前
p.sortId().asc();
})
.toList();
}
// 将权限列表存入会话
StpUtil.getSession().set("perms", permsList);
return ResponseResult.success("查询成功", permsList);
}
}前端组件
核心注意事项:
- 前端发送请求获取获取当前登录用户权限列表,并将权限列表转换成树形结构数组。
- 转换成树形结构数组时,不需要转换操作类型的权限,因为操作类型的权限不会展示在侧边栏菜单中,因此可以优化树形结构转换工具类方法,过滤操作类型的权限。
- 使用菜单组件渲染侧边栏多级菜单,注意路径拼接等问题。
编写树形结构工具类(plugins/TreeUtil.js),优化树形结构转换方法:
/**
* 将扁平结构的权限数据数组,转换为支持无限级嵌套的树形结构数组
* @param {Array} flatList 扁平结构的数据数组,每个元素需要包含id和parentId字段
* @package {filter} 是否过滤操作类型的权限,默认值为false
* @returns {Array} 转换后的树形结构数组,每个节点包含原节点的所有字段及children字段
*/
export function toTreeList(flatList, filter = false) {
// ...
// 构建树形结构
flatList.forEach(item => {
const node = nodeMap.get(item.id);
if (filter && node.type == 2) {
// 过滤操作类型的权限
return;
}
// ...
});
return result;
}
export default {
toTreeList
};
编写布局组件(views/Layouts.vue),完善左侧菜单栏:
<script setup>
// ...
// 模拟菜单数据
const menuData = ref([])
// 发送请求,获取登录用户权限列表
axios.get('/passport/currentPerms').then((res) => {
// 将扁平数据转换为树形结构
menuData.value = toTreeList(res.data, true)
})
// ...
</script>
<template>
<el-container class="layout-container">
<!-- 左侧菜单栏 -->
<el-aside width="200px">
<!--
router="true":启用该模式会在激活导航时以index作为path进行路由跳转
参考文档:https://element-plus.org/zh-CN/component/menu#menu-attributes
-->
<el-menu default-active="1" class="el-menu-vertical" :router="true" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b">
<el-menu-item index="/admin/index">
<span>SAdmin后台管理系统</span>
</el-menu-item>
<template v-for="item in menuData" :key="item.id">
<el-sub-menu v-if="item.children && item.children.length > 0" :index="item.id">
<template #title>
<span>{{ item.name }}</span>
</template>
<!--
index注意路径拼接前缀/admin/
-->
<el-menu-item v-for="child in item.children" :key="child.id" :index="'/admin/' + child.path">
{{ child.name }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="'/admin/' + item.path">
<template #title>{{ item.name }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- ... -->
</el-container>
</template>
<style>
/** ... **/
</style>
在浏览器中测试:
动态路由
动态路由概述
动态路由核心是根据用户权限动态生成路由规则,实现不同权限用户看到不同菜单、访问不同页面的目标。
动态菜单 ≠ 动态路由:
| 维度 | 动态菜单 | 动态路由 |
|---|---|---|
| 本质 | 控制侧边栏显示哪些菜单(视觉层) | 控制 URL 能访问哪些页面(访问层) |
| 实现方式 | 后端返回菜单列表,前端渲染侧边栏 | 后端返回路由规则,前端 addRoute 动态添加 |
| 核心作用 | 让用户看到能点的 | 让用户点了能访问,且防止手动输 URL,绕过权限访问 |
| 失效场景 | 手动输入 URL 可绕过菜单直接访问 | 无匹配路由彻底拦截 |
路由分类:
- 静态路由:无需权限的公共页面(如登录页、404 页),直接在路由表中定义。
- 动态路由:需权限管控的页面(如系统管理、用户管理),从后端获取用户权限菜单后,动态添加到路由表中。
前端组件
核心注意事项:
- 路由配置不再写死配置项,改为从后端获取授权的路由信息后,动态加载路由。
- 加载动态路由,可以编写工具类进行获取、转换、添加。
- 加载动态路由时,目录、操作类型的权限一般不需要添加到路由配置中,因为目录、操作类型的权限没有路由路径和路由组件。
- 加载动态路由时,需要移除原有的、遗留的旧的路由配置信息,防止动态路由表污染。
工具类提示词:
编写权限工具类src/plugins/PermsUtil,工具类中包含方法作用为:
1、传入扁平权限数组,将所有的菜单转换为路由规则。
2、动态添加路由,请求/passport/currentPerms获取扁平结构的权限列表,调用上述方法将其转换为路由规则。编写权限工具类(plugins/PermsUtil.js),提供动态路由方法:
import router from '@/router/index.js';
/**
* 将扁平权限数组转换为路由规则
* @param {Array} flatPerms 扁平权限数组
* @returns {Array} 路由规则数组
*/
export function permsToRoutes(flatPerms) {
const routes = [];
// 直接遍历扁平权限数组,只处理菜单类型(type=1)的权限
// 目录、操作类型一般不需要处理,因为目录、操作类型的权限没有路由路径和路由组件
flatPerms.forEach(perm => {
// 只处理菜单类型的权限(type=1)
if (perm.type === 1) {
const route = {};
// 设置路由路径
if (perm.path) {
route.path = perm.path;
}
// 设置组件
if (perm.component) {
// 动态导入组件
route.component = () => import(`@/views/${perm.component}.vue`);
}
// 只有当路径和组件都存在时才添加路由
if (route.path && route.component) {
routes.push(route);
}
}
});
return routes;
}
/**
* 动态添加路由
* 请求 /passport/currentPerms 得到扁平结构的权限列表,调用 permsToRoutes 方法将其转换为路由规则
*/
export async function loadPermsRoutes() {
// 请求获取扁平结构的权限列表
const response = await axios.get('/passport/currentPerms');
// 将扁平权限结构转换为路由规则
const routes = permsToRoutes(response.data);
// 先移除原有的admin路由
router.removeRoute('admin')
// 添加新的 admin 路由
router.addRoute({
path: '/admin',
name: 'admin',
component: () => import('@/views/Layout.vue'),
children: [] // 设置空的子路由
})
// 动态添加路由到 admin 路由下(添加到 /admin 路由的 children 中)
routes.forEach(route => {
router.addRoute('admin', route);
});
}
export default {
permsToRoutes,
loadPermsRoutes
};
编写路由配置文件(router/index.js),完善路由信息:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
// 根路径跳转到登录路由
path: '',
redirect: '/login'
},
{
// 登录
path: '/login',
component: () => import('@/views/Login.vue'),
}
// 移除原有的后台路由,改为动态加载
// 登录路由为公共路由,不需要权限
],
})编写登录组件(views/Login.vue),完善登录方法:
// 登录处理函数
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate((valid) => {
if (valid) {
// 这里执行实际的登录逻辑
// 发送Axios请求
axios.post('/passport/login', qs.stringify(loginForm)).then(async (res) => {
ElMessage.success('登录成功')
// 将Token存储到localStorage中
// Axios响应拦截器处理,res即后端响应给前端的数据,不再是res.data.data
localStorage.setItem('authtoken', res.data)
// 加载动态路由,await 是异步方法,必须等待动态路由加载完成,再跳转页面
await loadPermsRoutes()
// 跳转到后台首页
router.push('/admin/index')
})
} else {
ElMessage.error('请正确填写表单信息')
return false
}
})
}在浏览器中测试:
路由丢失问题
动态路由目前存在问题:浏览器刷新后,路由丢失,无法访问对应的页面组件。
原因在于:动态路由是在登录成功后,调用 PermsUtil 工具类通过 addRoute 临时添加的,刷新页面会导致前端应用重新初始化,所有运行时添加的动态路由都会被清空。
解决思路:在路由守卫中,每次初始化后(包括刷新),自动检测 已登录但动态路由未加载,重新请求后端并添加动态路由。
路由守卫优化
核心注意事项:
- 在路由守卫中,如果访问登录页面,先清空一次动态加载的路由信息(即移除掉整个后台的 admin 路由),防止动态路由表污染。
- 如果 Token 存在(即已登录)且未加载过动态路由(不存在 admin 路由),先调用 PermsUtil 工具类加载动态路由。
- 等待路由加载完成,重新触发路由守卫,避免首次跳转动态路由出现 404 的情况。
编写路由配置文件(router/index.js),完善路由守卫:
// ...
// 前置守卫
router.beforeEach(async (to, from, next) => {
let authtoken = localStorage.getItem("authtoken")
// 通过to.path获取访问的路由地址,并进行判断
if (to.path === '/login' && authtoken) {
// 如果Token存在且访问了登录路由,跳转到首页页面
ElMessage.success('欢迎回来')
next({path: '/admin/index'})
} else if (to.path === '/login') {
// 先移除原有的admin路由
router.removeRoute('admin')
// 跳过登录路由
next()
} else if (!authtoken) {
// 如果Token不存在,即未登录,跳转到登录页面
ElMessage.error('请先登录')
next({path: '/login'})
}
// Token存在且未加载过动态路由,先加载动态路由
if (authtoken && !router.hasRoute('admin')) {
await loadPermsRoutes()
// 重新触发守卫,避免首次跳转动态路由404
next({ ...to, replace: true })
}
// 其他正常情况,继续访问
next()
})
export default router
动态菜单优化
核心注意事项:
- 路由守卫中已经调用 PermsUtil 工具类发送了一次请求获取当前登录用户权限列表,并将其转换为动态路由。
- 在 PermsUtil 工具类中,可以将当前登录用户权限列表转换为树形结构数组后,保存到仓库中,在布局组件中直接从仓库中取出渲染,不需要重复请求。
编写登录用户数据仓库(store/currentUser.js),存储当前登录用户权限列表:
import {ref, computed, reactive} from 'vue'
import { defineStore } from 'pinia'
export const useCurrentUserStore = defineStore('currentUser', () => {
// 登录用户信息实体
let currentUser = reactive({})
// 当前用户菜单列表
let currentMenu = reactive([])
// 设置登录用户信息实体
function setCurrentUser(currentUser) {
this.currentUser = currentUser
}
// 设置当前用户菜单列表
function setCurrentMenu(currentMenu) {
this.currentMenu = currentMenu
}
return { currentUser, setCurrentUser, currentMenu, setCurrentMenu }
})编写权限工具类(plugins/PermsUtil.js),优化动态路由方法:
// 将扁平权限结构转换为路由规则
const routes = permsToRoutes(response.data);
// 将用户权限菜单存储到仓库中
useCurrentUserStore().setCurrentMenu(toTreeList(response.data, true))编写布局组件(views/Layouts.vue),优化左侧菜单栏数据的获取:
// 模拟菜单数据
const menuData = useCurrentUserStore().currentMenu
如无特殊说明,网站中的系列文章均为作者「@多仔」原创编辑,版权归作者「@多仔」所有,资源引用部分已注明来源,AIGC 创作部分已注明标识,拒绝未经授权的任何个人或组织以任何形式转载、复制、修改、发布或用于商业目的。
文章中可能会存在些许错别字内容描述不完整、表述不准确、排版布局异常等问题,文章中提及的软件、依赖、框架等程序可能随其版本更新迭代而产生变化,文章中的相关代码片段、例图、文本等内容仅供参考。
如若转载,请注明出处:https://www.duox.dev/post/126.html
文章中可能会存在些许错别字内容描述不完整、表述不准确、排版布局异常等问题,文章中提及的软件、依赖、框架等程序可能随其版本更新迭代而产生变化,文章中的相关代码片段、例图、文本等内容仅供参考。
如若转载,请注明出处:https://www.duox.dev/post/126.html