在企业级后台管理系统中,用户的权限通常是基于角色来进行区分的,不同角色的用户需要访问不同的路由、菜单和功能按钮。为了提升应用的灵活性和性能,需要通过接口动态获取路由和菜单数据,实现按需加载。通过这种方式,前端不需要预先加载所有的路由和菜单,减少了首屏加载时间,并且实现了更加灵活的权限管理,使得用户只能看到和操作自己有权限的内容。这种方案不仅优化了性能,还提高了系统的安全性和可维护性。
1.路由权限控制
后端接口返回的数据格式如下,其中parent_id代表上一级菜单的id,type属性区分菜单和按钮。
[{"id": "3001","parent_id": "0","path": "/system","name": "system","redirect": "/system/user","title": "系统管理","icon": "Tools","component": "/views/MxLayout","order": 0,"status": 1,"type": "menu","roles": null,"is_init": 1},
{"id": "3002","parent_id": "0","path": "/attachment","name": "attachment","redirect": "/attachment/photo","title": "附件管理","icon": "Files","component": "/views/MxLayout","order": 1,"status": 1,"type": "menu","roles": null,"is_init": 1},
{"id": "3003","parent_id": "3001","path": "/system/user","name": "MxUser","redirect": "","title": "用户管理","icon": "User","component": "/views/MxUser","order": 0,"status": 1,"type": "menu","roles": null,"is_init": 1},
{"id": "3004","parent_id": "3001","path": "/system/role","name": "MxRole","redirect": "","title": "角色管理","icon": "UserFilled","component": "/views/MxRole","order": 0,"status": 1,"type": "menu","roles": null,"is_init": 1},
{"id": "3005","parent_id": "3001","path": "/system/menu","name": "MxMenu","redirect": "","title": "菜单管理","icon": "Menu","component": "/views/MxMenu","order": 0,"status": 1,"type": "menu","roles": null,"is_init": 1},
{"id": "4001","parent_id": "3003","path": null,"name": "post_/user","redirect": null,"title": "添加用户","icon": null,"component": null,"order": null,"status": 1,"type": "button","roles": null,"is_init": 1},
{"id": "4002","parent_id": "3003","path": null,"name": "put_/user","redirect": null,"title": "编辑用户","icon": null,"component": null,"order": null,"status": 1,"type": "button","roles": null,"is_init": 1},
{"id": "4003","parent_id": "3003","path": null,"name": "delete_/user","redirect": null,"title": "删除用户","icon": null,"component": null,"order": null,"status": 1,"type": "button","roles": null,"is_init": 1}]
首先需要从接口返回中分离出菜单和按钮的数据,将菜单的扁平数组格式化为树形数组,并形成路由树。
// 将扁平数组格式化为树形数组
export const buildTree = (data: any) => {
const myMap = new Map()
const result: any[] = []
// 先按 id 存入 map,便于后续构建树形结构
for (const route of data) {
myMap.set(route.id, route)
}
for (const route of data) {
if (route.parent_id === '0') {
result.push(route)
} else {
const parent = myMap.get(route.parent_id)
if (parent) {
if (!parent.children) {
parent.children = []
}
parent.children.push(route)
}
}
}
// 对根节点 result 按 order 排序
result.sort((a: any, b: any) => a.order - b.order)
// 对每个父节点的 children 数组按 order 字段排序
const sortChildren = (node: any) => {
if (node.children) {
node.children.sort((a: any, b: any) => a.order - b.order)
node.children.forEach(sortChildren) // 递归对子节点进行排序
}
}
// 对根节点及其所有子节点进行排序
result.forEach(sortChildren)
return result
}
// 基于树形结构生成前端路由配置
export const treeToRoutes = (tree: any[]): any[] => {
const modules = import.meta.glob('../**/**.vue') // 动态导入vue文件
const result: any[] = []
// 遍历树形结构,生成符合前端路由配置的对象
const generateRoute = (node: any): any => {
const route: any = {
path: node.path,
name: node.name,
component: modules[`..${node.component}.vue`], // 重点
redirect: node.redirect || '',
meta: {
title: node.title || '',
icon: node.icon || ''
}
}
// 如果有子节点,递归生成子路由
if (node.children && node.children.length > 0) {
route.children = []
for (let i = 0; i < node.children.length; i++) {
const childRoute = generateRoute(node.children[i])
route.children.push(childRoute)
}
if (route.children.length === 0) {
delete route.children
}
}
return route
}
// 遍历树形结构的根节点,生成路由
for (const node of tree) {
result.push(generateRoute(node))
}
return result
}
const userInfo = ref<any>({})
const { data = {}} = await reqUserInfo() // 调用接口 返回可访问的资源信息
// 根据类型分离菜单和按钮
const filterMenus = (type: string) => allMenus.filter((menu: any) => menu.type === type)
userInfo.value.menus = filterMenus('menu')
userInfo.value.buttons = filterMenus('button')
// 调用上面的函数生成路由树
userInfo.value.routesTree = treeToRoutes(buildTree(cloneDeep(userInfo.value.menus)))
然后使用addRoute函数异步添加路由。
userInfo.value.routesTree.forEach((route: any) => {
router.addRoute(route)
})
// 在添加完异步路由后添加404路由
router.addRoute({
// 任意路由
path: '/:pathMatch(.*)*',
redirect: '/404',
name: 'Any',
meta: {
title: '任意路由'
}
})
最后需要调整前置路由守卫,目的是确保等待所有的异步路由加载完毕后再进入页面,否则会出现刷新白屏的情况。
const allowPaths = ['/login', '/login/auth']
router.beforeEach(async (to, _from, next) => {
nprogress.start()
const userStore = useUserStore(pinia)
if (userStore.token) { // 用户已登录
if (allowPaths.includes(to.path)) {
next({ path: '/' })
} else {
if (!userStore.userInfo) { // 刷新后仓库丢失用户信息 需要重新获取
await userStore.getUserInfo()
// next()
next({ ...to }) // 解决刷新白屏问题 等待组件渲染完毕再放行
} else {
next()
}
}
} else { // 用户未登录
if (allowPaths.includes(to.path)) {
next()
} else {
next({ path: '/login', query: { redirect: encodeURIComponent(to.fullPath) }})
}
}
})
2.菜单权限控制
基于elment-plus菜单组件封装递归菜单组件。
<template>
<template v-for="item in params" :key="item.name">
<el-sub-menu :index="item.path" v-if="item.children && item.children.length>0"> <!-- 有子级菜单 -->
<template #title>
<el-icon>
<component :is="item.meta?.icon"></component>
</el-icon>
<span>{{ item.meta?.title }}</span>
</template>
<MenuItem :params="item.children"></MenuItem> <!-- 递归调用组件自身,注意组件名 -->
</el-sub-menu>
<el-menu-item :index="item.path" v-else> <!-- 无子级菜单 -->
<template #title>
<el-icon>
<component :is="item.meta?.icon"></component>
</el-icon>
<span>{{ item.meta?.title }}</span>
</template>
</el-menu-item>
</template>
</template>
<script lang="ts" setup>
import type { RouteRecordRaw } from 'vue-router'
defineOptions({
name: 'MenuItem'
})
const { params = [] } = defineProps<{
params:RouteRecordRaw[]
}>()
</script>
父级组件调用
<template>
<el-container class="mx-aside">
<el-scrollbar>
<el-menu router :default-active="$route.path" active-text-color="#ffd04b" background-color="#324157"
class="el-menu-vertical-demo" text-color="#fff">
<el-menu-item index="/home">
<el-icon class="icon">
<HomeFilled />
</el-icon>
<template #title>
<span>首页</span>
</template>
</el-menu-item>
<MenuItem :params="userStore.userInfo.routesTree"></MenuItem>
</el-menu>
</el-scrollbar>
</el-container>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import MenuItem from './MxMenu.vue'
const $route = useRoute()
const userStore = useUserStore()
</script>
3.按钮权限控制
按钮权限主要通过自定义指令,将按钮标识名与按钮权限数组做对比实现。
import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'
import { useConfigStore } from '@/stores/config'
// 判断按钮权限 无权限删除元素
const permission: Directive = {
mounted (el: HTMLElement, { value = '' }) {
if (value === '') {
return
}
const userStore = useUserStore()
const configStore = useConfigStore()
const buttonStyle = configStore.config?.web.mxButtonStyle // 无权限时按钮的显示状态
if (buttonStyle === 1) return // 显示
const index = userStore.userInfo.buttons.findIndex((button: any) => button.name === value)
if (index === -1) {
if (buttonStyle === 2) { // 隐藏
el.remove()
} else if (buttonStyle === 3) { // 禁用
el.classList.add('is-disabled')
el.setAttribute('disabled', 'true') // 设置 disabled 属性
}
}
}
}
export default permission
自定义指令使用示例
<el-button type="danger" @click="deleteData" icon="Delete" v-permission="'delete_/user'">删除</el-button>






