基于vue3的权限管理系统脚手架搭建项目实战(二):登录与路由权限控制
此篇为基于vue3的权限管理系统脚手架搭建项目实战的第二章。
前一章为:基于vue3的权限管理系统脚手架搭建项目实战(一):项目准备
现在我们对用户完整的登录流程进行完善。
首先,对用户使用系统的行为方式进行思考分析,并对要使用的一些工具进行总结:
(1)用户想要使用网站的功能,必须有两个操作:打开页面和发送请求。
(2)打开页面的操作,可以通过导航守卫拦截,开发人员可以在此封装行为逻辑,这里由vue-router
处理页面路由和权限。
(3)发送请求和返回响应也可以封装行为逻辑。第一步的登录需要将用户信息提交到后台进行校验处理,因此各种API访问操作要使用常用工具axios
发送请求,为便于重用代码,axios需要重新封装。
(4)在前后端分离项目中,为追求效率,可使用mockjs
进行模拟数据的测试。
(5)当请求发送成功时,需要保留登录状态,登录状态将在整个项目中使用,用户的菜单等权限资源需要全局共用,这里使用pinia
工具进行处理。
(6)任何操作都需要在已登录状态下完成,这样才能保证用户数据或网站数据的安全。如果登录状态失效,则需要更新状态并退出系统路由到登录页面,用户需要重新登录,才可以重新执行相应操作。
一、完善登录流程
(一)设计登录页面
首先要有一个包含表单的登录页面。
//login.vue
<template><!--省略html部分代码-->
</template>
页面布局不太重要,所以这里省略掉。
登录页面必须发送一个包含账号密码的表单给服务器进行验证,该登录逻辑本身不在登录页面完成。
而是委托给状态管理进行处理,因为状态管理需要记录从后端服务返回的用户信息。
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { reactive, ref } from 'vue'const userStore = useUserStore();
const loading = ref(false)
// 验证码开关
const captchaEnabled = ref(false);
// 登录表单
const loginForm = ref({username: "",password: "",rememberMe: false,code: "",uuid: "",
});function handleLogin() {proxy.$refs.loginRef.validate((valid) => {if (valid) {loading.value = true;// 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码if (loginForm.value.rememberMe) {localStorage.setItem("username", loginForm.value.username);localStorage.setItem("password", encrypt(loginForm.value.password));localStorage.setItem("rememberMe", loginForm.value.rememberMe);} else {// 否则移除localStorage.removeItem("username");localStorage.removeItem("password");localStorage.removeItem("rememberMe");}// 调用状态管理的action的登录方法userStore.login(loginForm.value).then(() => {router.push({ path: redirect.value || "/" });}).catch(() => {loading.value = false;// 重新获取验证码if (captchaEnabled.value) {getCode();}});}});
}
</script>
(二)用户登录状态
前面提到用户登录的请求实际上委托给状态管理来发起了,因为需要记录用户登录信息状态。
在状态管理的stores目录中新建user.ts文件,由该文件管理用户的登录状态,代码如下:
//stores/user.ts
import { login, logout, getInfo,loginFromOa } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
import defAva from '@/assets/images/profile.jpg'
import { defineStore } from 'pinia'export interface LoginForm {username: stringpassword: stringcode: stringuuid: string
}const useUserStore = defineStore('user',{// 定义状态state: () => ({token: getToken(),name: '', //用户名avatar: '', // 头像roles: Array(), // 角色数组permissions: [] // 权限数组}),// 定义 actions,有同步和异步两种类型actions: {// 登录login(userInfo: LoginForm) {const username = userInfo.username.trim()const password = userInfo.passwordconst code = userInfo.codeconst uuid = userInfo.uuidreturn new Promise((resolve, reject) => {login(username, password, code, uuid).then((res:any) => {setToken(res.token)this.token = res.tokenresolve(null)}).catch(error => {reject(error)})})},// 获取用户信息getInfo() {return new Promise((resolve, reject) => {getInfo().then((res:any) => {const user = res.user// @ts-ignoreconst avatar = (user.avatar == "" || user.avatar == null) ? defAva : import.meta.env.VITE_APP_BASE_API + user.avatar;if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组this.roles = res.rolesthis.permissions = res.permissions} else {this.roles = ['ROLE_DEFAULT']}this.name = user.userNamethis.avatar = avatar;resolve(res)}).catch(error => {reject(error)})})},// 退出系统logOut() {return new Promise((resolve, reject) => {logout().then(() => {this.token = ''this.roles = []this.permissions = []removeToken()resolve(null)}).catch(error => {reject(error)})})}}})export default useUserStore
代码解释:
State: 包含token
(用户令牌)、name
(用户名)、avatar
(用户头像)、roles
(用户角色数组)和permissions
(权限数组)五个状态变量。
Actions: 包含三个异步方法:
login
: 处理用户登录逻辑,接收LoginForm对象作为参数,调用login API进行登录,并在成功时设置用户令牌。getInfo
: 获取当前用户的信息,包括用户名、角色、权限和头像等,并更新store中的对应状态。logOut
: 处理用户登出逻辑,清除用户的相关状态信息并调用logout API通知服务器用户已登出。
此时,我们就完成了从登录页面发起登录请求的功能,并将返回的用户信息状态存储在全局状态管理pinia中。
(三) 添加导航守卫
接着,我们现在需要对已登录状态用户和未登录用户的可访问页面进行权限控制,这通过导航守卫来实现。
路由对象router给我们提供了beforeEach方法,可以在每次路由之前进行一些相关处理,也叫导航守卫。
1.逻辑分析
导航守卫的思路逻辑:
(1)导航守卫和请求拦截器的优先级
用户不管是直接去登录页面、浏览器中填入页面地址,或者是刷新页面,首先第一个动作就是访问页面。
导航守卫是在用户每次访问页面之前进行拦截处理逻辑,而请求拦截器是在用户每次访问接口之前进行拦截处理。
由于用户先访问页面,才可能再去访问接口方法(比如获取用户信息),所以导航守卫比请求拦截优先级更高。
(2)关于白名单页面的处理
一般来说大多数页面必须登录才能访问,但是还有些页面不需要登录就应该允许访问,比如登录页、404页、注册页等,这些页面属于白名单页面。
但也不是所有情况的白面单页面就一定能允许跳转过去。
若用户进入白名单页面,则分两种情况:
- 跳转的非登录页面,则直接路由到目标页面。
- 跳转的登录页面,这时获取登录状态,若登录有效,则禁止进行登录并路由到后台首页;若登录状态无效,则跳转到登录页面。
基于上述认识,因此导航守卫应该先判断用户是否登录,而不是先判断用户访问的路径是否在白名单。
条件 | 处理方式 |
---|---|
已登录用户访问 /login | 拦截并重定向至首页,即使它是白名单路径 |
已登录 + 在白名单(非 login) | 允许访问 |
未登录 + 在白名单 | 直接放行 |
未登录 + 不在白名单 | 跳转登录页 |
另外,可能考虑到性能问题。因为每次路由跳转都会触发这个守卫,如果大部分情况下用户已经登录,那么先判断是否有token,可以更快地进入主要的处理逻辑,而不需要每次都先检查路径是否在白名单。
(3)非白名单页面的处理:
若已登录用户进入非白名单页面,也不一定就能允许访问,还需要判断菜单权限获取动态菜单。
这里用户的登录状态靠getToken来实现,该方法是从cookie中是否有值来判断,属于前端验证。
但是cookie中的token可能被伪造或过期,因此系统还应该引导用户去访问后端服务接口,来验证token是否有效。
判断用户是否有权限到目标页面;若会话过期等登录无效状态,则跳到登录页面要求登录。
(4)登录后的处理:
登录成功后,除了保存用户的身份信息到状态管理库之外,还需要设置用户的认证令牌(token)。通常,这个token会在后续请求中用于验证用户的身份。
获取动态菜单的步骤可以放在用户登录成功后的回调中执行,并根据获取到的菜单数据动态地添加路由规则。这样可以确保只有经过授权的用户才能访问相应的资源。
在整个过程中,应该对可能出现的异常情况进行处理,比如网络错误、API响应失败等。对于这些情况,可以给出适当的错误提示,并提供重新尝试或其他解决方案的选项。
2.代码实现
创建permssion.ts文件,添加导航守卫。
import router from './router'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate'
import { isRelogin } from '@/utils/request'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'
import * as minimatch from "minimatch";NProgress.configure({ showSpinner: false });const whiteList = ['/login', '/auth-redirect', '/bind', '/register'];
const whiteListPatterns = whiteList.map((pattern) => new minimatch.Minimatch(pattern)
);router.beforeEach((to, from, next) => {// 开始加载进度条NProgress.start()// 已登录的情况(getToken() 存在)if (getToken()) {// 设置页面标题let title = typeof to.meta.title === 'function' ? to.meta.title(to) : to.meta.titletitle && useSettingsStore().setTitle(title)to.meta.title && useSettingsStore().setTitle(to.meta.title)/* has token*/if (to.path === '/login') {next({ path: '/' })NProgress.done()}else if (whiteListPatterns.some((pattern) => pattern.match(to.path))) {// 在免登录白名单,直接进入next()}else {if (useUserStore().roles.length === 0) {isRelogin.show = true// 判断当前用户是否已拉取完user_info信息useUserStore().getInfo().then(() => {isRelogin.show = falseusePermissionStore().generateRoutes().then(accessRoutes => {// 根据roles权限生成可访问的路由表accessRoutes.forEach(route => {if (!isHttp(route.path)) {router.addRoute(route) // 动态添加可访问路由表}})next({ ...to, replace: true }) // hack方法 确保addRoutes已完成})}).catch(err => {useUserStore().logOut().then(() => {ElMessage.error(err)next({ path: '/' })})})} else {next()}}} else {// 没有tokenif (whiteListPatterns.some((pattern) => pattern.match(to.path))) {// 在免登录白名单,直接进入next()} else {next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页NProgress.done()}}
})
// 每次路由跳转完成后关闭进度条。
router.afterEach(() => {NProgress.done()
})
3.解释说明
以正常登录操作为例:
- (1)首先用户未登录,进入登录页面,经过导航守卫验证,由于登录页面属于白名单页面,放行
- (2)用户提交登录表单,会先访问api接口,这时通过请求拦截器过滤,由于/login的接口方法属于公共访问接口,放行
- (3)经后台服务验证成功后,返回的信息由全局状态管理记录token值,并将token值保存到cookie中,完成后再次重定向跳转到首页
- (4)跳转到首页后,又会经过导航守卫验证,由于这时cookie中含有token值,被认为用户已登录,系统引导用户去后端服务获取用户信息,获取的用户名、头像、角色数组、权限数组被记录到全局状态管理中
- (5)获取用户信息后,继续根据roles角色数组生成可访问的路由表,即动态菜单渲染到页面顶部和侧边栏中
二、页面路由
前面提到用户获取信息后,根据roles角色数组生成动态的可访问路由,接下来详细了解动态加载菜单的实现。
(一)静态路由
静态路由代表那些不需要动态判断权限的路由,如登录页、404、等通用页面,在@/router/index.js
配置对应的公共路由。
1.代码实现
在router目录下创建的index.js中包含静态(公共)路由
import { createWebHistory, createRouter } from 'vue-router'
/* Layout */
import Layout from '@/layout'// 静态路由(公共路由)
export const constantRoutes = [{path: '/redirect',component: Layout,hidden: true,children: [{path: '/redirect/:path(.*)',component: () => import('@/views/redirect/index.vue')}]},{path: '/login',component: () => import('@/views/login'),hidden: true},{path: '/register',component: () => import('@/views/register'),hidden: true},{path: "/:pathMatch(.*)*",component: () => import('@/views/error/404'),hidden: true},{path: '/401',component: () => import('@/views/error/401'),hidden: true},{path: '',component: Layout,redirect: '/index',children: [{path: '/index',component: () => import('@/views/index'),name: 'Index',meta: { title: '首页', icon: 'dashboard', affix: true }}]},{path: '/user',component: Layout,hidden: true,redirect: 'noredirect',children: [{path: 'profile',component: () => import('@/views/system/user/profile/index'),name: 'Profile',meta: { title: '个人中心', icon: 'user' }}]},{path: "/websocket",component: () => import('@/views/websocket'),hidden: true},
]// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [{path: '/system/user-auth',component: Layout,hidden: true,permissions: ['system:user:edit'],children: [{path: 'role/:userId(\\d+)',component: () => import('@/views/system/user/authRole'),name: 'AuthRole',meta: { title: '分配角色', activeMenu: '/system/user' }}]},{path: '/system/role-auth',component: Layout,hidden: true,permissions: ['system:role:edit'],children: [{path: 'user/:roleId(\\d+)',component: () => import('@/views/system/role/authUser'),name: 'AuthUser',meta: { title: '分配用户', activeMenu: '/system/role' }}]},{path: '/system/dict-data',component: Layout,hidden: true,permissions: ['system:dict:list'],children: [{path: 'index/:dictId(\\d+)',component: () => import('@/views/system/dict/data'),name: 'Data',meta: { title: '字典数据', activeMenu: '/system/dict' }}]},{path: '/monitor/job-log',component: Layout,hidden: true,permissions: ['monitor:job:list'],children: [{path: 'index/:jobId(\\d+)',component: () => import('@/views/monitor/job/log'),name: 'JobLog',meta: { title: '调度日志', activeMenu: '/monitor/job' }}]},{path: '/tool/gen-edit',component: Layout,hidden: true,permissions: ['tool:gen:edit'],children: [{path: 'index/:tableId(\\d+)',component: () => import('@/views/tool/gen/editTable'),name: 'GenEdit',meta: { title: '修改生成配置', activeMenu: '/tool/gen' }}]}
]const router = createRouter({history: createWebHistory(import.meta.env.VITE_BASE_ROUTER),routes: constantRoutes,scrollBehavior(to, from, savedPosition) {if (savedPosition) {return savedPosition} else {return { top: 0 }}},
});export default router;
(1)公共路由定义
export const constantRoutes = [{path: '/redirect',component: Layout,hidden: true,children: [ ... ]},{path: '/login',component: () => import('@/views/login'),hidden: true},...
]
关键字段说明:
(2)权限控制的动态路由(dynamicRoutes)
export const dynamicRoutes = [{path: '/system/user-auth',component: Layout,hidden: true,permissions: ['system:user:edit'],children: [ ... ]},...
]
示例解析:
{path: '/system/user-auth',component: Layout,permissions: ['system:user:edit'],children: [{path: 'role/:userId(\\d+)',component: () => import('@/views/system/user/authRole'),name: 'AuthRole',meta: { title: '分配角色', activeMenu: '/system/user' }}]
}
用户访问 /system/user-auth/role/123
时:
- 需要权限
system:user:edit
; - 会加载
authRole.vue
页面; - 在菜单中高亮
/system/user(activeMenu)
。
(3)初始化路由器
const router = createRouter({history: createWebHistory(import.meta.env.VITE_BASE_ROUTER),routes: constantRoutes,scrollBehavior(to, from, savedPosition) {if (savedPosition) {return savedPosition} else {return { top: 0 }}},
});
关键配置说明:
2.路由配置项解释
// 当设置 true 的时候该路由不会在侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1
hidden: true // (默认 false)//当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
redirect: 'noRedirect'// 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面
// 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面
// 若你想不管路由下面的 children 声明的个数都显示你的根路由
// 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由
alwaysShow: truename: 'router-name' // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
query: '{"id": 1, "name": "ry"}' // 访问路由的默认传递参数
roles: ['admin', 'common'] // 访问路由的角色权限
permissions: ['a:a:a', 'b:b:b'] // 访问路由的菜单权限meta: {title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字icon: 'svg-name' // 设置该路由的图标,支持 svg-class,也支持 el-icon-x element-ui 的 iconnoCache: true // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)breadcrumb: false // 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)affix: true // 如果设置为true,它则会固定在tags-view中(默认 false)// 当路由设置了该属性,则会高亮相对应的侧边栏。// 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list// 点击文章进入文章详情页,这时候路由为/article/1,但你想在侧边栏高亮文章列表的路由,就可以进行如下设置activeMenu: '/article/list'
}
(1)控制侧边栏显示hidden
hidden:true
当设置 true 的时候该路由不会再侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1
(2)控制面包屑是否可被点击redirect
redirect: 'noRedirect'
当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
(3)路由名称name
name:'router-name'
设定路由的名字,一定要填写不然使用<keep-alive>
时会出现各种问题
(4)访问路由的默认传递参数query
query: '{"id": 1, "name": "ry"}'
访问路由的默认传递参数
(5)访问路由的角色权限roles
roles: ['admin', 'common']
访问路由的角色权限,是一个角色的数组
(6)访问路由的菜单权限roles
permissions: ['a:a:a', 'b:b:b']
访问路由的菜单权限,是权限字符串的数组
(7)元数据配置项meta
meta : {noCache: true // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字,可以是route对象里面的内容:(route)=>route.query.xxicon: 'svg-name' // 设置该路由的图标,对应路径src/assets/icons/svgbreadcrumb: false // 如果设置为false,则不会在breadcrumb面包屑中显示activeMenu: '/system/user' // 当路由设置了该属性,则会高亮相对应的侧边栏。group:"group" // 当路由设置了该属性,则相同组的路由共用一个tab标签,可以是route对象里面的内容:(route)=>route.query.xxtransition:"fade-transform" // 设置该路由的切换动画,默认淡出,不需要动画可以填none,动画需要自己在transition.scss中定义
}
(二)动态路由
由于状态管理在刷新时,不会在store保存用户身份信息,会去重新获取用户身份信息时,同时因为基于RBAC模型的权限控制。这时用户重新获取所有角色所能访问的动态菜单资源。
1.动态菜单逻辑分析
(1)使用动态路由的必要性
在页面中用户是通过router跳转的,如果后台导航目录运营人员新增菜单后,前端人员得在路由表中手动添加上,这样导航才能点击才能对应上页面,比较麻烦,因此在中大型项目采用的都是添加动态路由的方式解决的。
用户在未登录前,可以访问静态路由列表constantRouterMap
。
登录成功后,需要去后台获取权限允许的菜单添加到路由列表router中。
(2)动态路由与状态管理
在初始化菜单中,首先判断store中的数据是否存在,如果存在,则说明这次跳转是正常的跳转,而不是用户按F5键或者直接在地址栏输入某个地址进入的,这时直接返回,不必执行菜单初始化。
若store中不存在菜单数据,则需要初始化菜单数据,通过服务端查询菜单的api方法获得菜单的JSON数据之后,首先通过router.addRoute(element)
方法将服务器返回的JSON转为router需要的格式,这里主要是转component,因为服务端返回的component是一个字符串,而router中需要的却是一个组件,因此我们根据服务端返回的component动态加载需要的组件即可。
(3)获取菜单数据
首先,后台给的菜单数据应该具有树形结构,数据格式准备成功之后,一方面将数据存到store中,提供给菜单栏使用,才能方便渲染到导航菜单栏<a-menu>
中。
另一方面,利用路由中的addRoutes方法将之动态添加到路由中,数据需要经过处理成route对象,包括path、name、component等数据,添加到routes数组中。
(4)过滤菜单权限
首先判断动态菜单是否已经存在,如果存在就不再重复加载损耗性能,否则调用后台接口加载数据库存储菜单数据,加载成功之后通过router.addRoutes方法将菜单数据动态添加到路由器并同时保存菜单数据及加载状态以备后用。
导航菜单加载成功之后,调用后台接口查找用户权限数据并保存起来,供权限判断时读取。
(5)处理404路由
当没有匹配到任何路径时候应该前往404路由,且必须最后再添加404路由。因为若该路由定义存在,则在动态路由未添加完成之前,访问的系统内页路由将找不到匹配路由,会直接匹配该路由定义而跳转404页面,无法达到正确跳转,所以将其抽出来,待权限路由添加完成后,再手动添加404路由。
2.动态路由实现
import auth from '@/plugins/auth'
import router, { constantRoutes, dynamicRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'
import ParentView from '@/components/ParentView/index.vue'
import InnerLink from '@/layout/components/InnerLink/index.vue'
import { defineStore } from 'pinia'// 匹配views里面所有的.vue文件
const modules = import.meta.glob('./../../views/**/*.vue')const usePermissionStore = defineStore('permission',{state: () => ({routes: Array<any>(),addRoutes: Array<any>(),defaultRoutes: Array<any>(),topbarRouters: Array<any>(),sidebarRouters: Array<any>()}),actions: {setRoutes(routes:Array<any>) {this.addRoutes = routesthis.routes = constantRoutes.concat(routes)},setDefaultRoutes(routes:Array<any>) {this.defaultRoutes = constantRoutes.concat(routes)},setTopbarRoutes(routes:Array<any>) {this.topbarRouters = routes},setSidebarRouters(routes:Array<any>) {this.sidebarRouters = routes},generateRoutes() {return new Promise(resolve => {// 向后端请求路由数据getRouters().then(res => {const sdata = JSON.parse(JSON.stringify(res.data))const rdata = JSON.parse(JSON.stringify(res.data))const defaultData = JSON.parse(JSON.stringify(res.data))const sidebarRoutes = filterAsyncRouter(sdata)const rewriteRoutes = filterAsyncRouter(rdata, false, true)const defaultRoutes = filterAsyncRouter(defaultData)const asyncRoutes = filterDynamicRoutes(dynamicRoutes)asyncRoutes.forEach(route => { router.addRoute(route) })this.setRoutes(rewriteRoutes)this.setSidebarRouters(constantRoutes.concat(sidebarRoutes))this.setDefaultRoutes(sidebarRoutes)this.setTopbarRoutes(defaultRoutes)resolve(rewriteRoutes)})})}}})// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap:Array<any>, lastRouter = false, type = false) {return asyncRouterMap.filter(route => {if (type && route.children) {route.children = filterChildren(route.children)}if (route.component) {// Layout ParentView 组件特殊处理if (route.component === 'Layout') {route.component = Layout // 布局组件} else if (route.component === 'ParentView') {route.component = ParentView // 嵌套容器} else if (route.component === 'InnerLink') {route.component = InnerLink } else {route.component = loadView(route.component) // 动态加载页面组件}}if (route.children != null && route.children && route.children.length) {route.children = filterAsyncRouter(route.children, route, type)} else {delete route['children']delete route['redirect']}return true})
}function filterChildren(childrenMap:Array<any>, lastRouter:false|any = false) {var children:Array<any> = []childrenMap.forEach((el, index) => {if (el.children && el.children.length) {if (el.component === 'ParentView' && !lastRouter) {// 处理 ParentView 下的子路由路径el.children.forEach((c:any) => {c.path = el.path + '/' + c.path // 合并父路径if (c.children && c.children.length) {children = children.concat(filterChildren(c.children, c))return}children.push(c)})return}}if (lastRouter) {el.path = lastRouter.path + '/' + el.path // 合并父路径}children = children.concat(el)})return children
}// 动态路由遍历,验证是否具备权限
export function filterDynamicRoutes(routes:Array<any>) {const res:Array<any> = []routes.forEach(route => {// 优先级:permissions > roles,满足任意条件即可通过。if (route.permissions) {if (auth.hasPermiOr(route.permissions)) {res.push(route)}} else if (route.roles) {if (auth.hasRoleOr(route.roles)) {res.push(route)}}})return res
}export const loadView = (view:string) => {let res;for (const path in modules) {const dir = path.split('views/')[1].split('.vue')[0];if (dir === view) {res = () => modules[path]();}}return res;
}export default usePermissionStore
3.代码详细解读
(1)菜单接口返回的数据
访问菜单表返回的数据是个树形结构的数组,只能获取用户自身权限能访问菜单的数据。
其中,系统管理员admin用户获取所有菜单的数据。
注意只返回目录和菜单,不返回按钮的数据,以下是返回数据示例:
[{"name": "System","path": "/system","hidden": false,"redirect": "noRedirect","component": "Layout","alwaysShow": true,"meta": {"title": "系统管理","icon": "system","noCache": false,"link": null},"children": [{"name": "User","path": "user","hidden": false,"component": "system/user/index","meta": {"title": "用户管理","icon": "user","noCache": false,"link": null}},{"name": "Role","path": "role","hidden": false,"component": "system/role/index","meta": {"title": "角色管理","icon": "peoples","noCache": false,"link": null}},{"name": "Menu","path": "menu","hidden": false,"component": "system/menu/index","meta": {"title": "菜单管理","icon": "tree-table","noCache": false,"link": null}},{"name": "Dept","path": "dept","hidden": false,"component": "system/dept/index","meta": {"title": "部门管理","icon": "tree","noCache": false,"link": null}},{"name": "Post","path": "post","hidden": false,"component": "system/post/index","meta": {"title": "岗位管理","icon": "post","noCache": false,"link": null}},{"name": "Dict","path": "dict","hidden": false,"component": "system/dict/index","meta": {"title": "字典管理","icon": "dict","noCache": false,"link": null}},{"name": "Config","path": "config","hidden": false,"component": "system/config/index","meta": {"title": "参数设置","icon": "edit","noCache": false,"link": null}},{"name": "Notice","path": "notice","hidden": false,"component": "system/notice/index","meta": {"title": "通知公告","icon": "message","noCache": false,"link": null}},{"name": "Log","path": "log","hidden": false,"redirect": "noRedirect","component": "ParentView","alwaysShow": true,"meta": {"title": "日志管理","icon": "log","noCache": false,"link": null},"children": [{"name": "Operlog","path": "operlog","hidden": false,"component": "monitor/operlog/index","meta": {"title": "操作日志","icon": "form","noCache": false,"link": null}},{"name": "Logininfor","path": "logininfor","hidden": false,"component": "monitor/logininfor/index","meta": {"title": "登录日志","icon": "logininfor","noCache": false,"link": null}}]}]},{"name": "Monitor","path": "/monitor","hidden": false,"redirect": "noRedirect","component": "Layout","alwaysShow": true,"meta": {"title": "系统监控","icon": "monitor","noCache": false,"link": null},"children": [{"name": "Online","path": "online","hidden": false,"component": "monitor/online/index","meta": {"title": "在线用户","icon": "online","noCache": false,"link": null}},{"name": "Job","path": "job","hidden": false,"component": "monitor/job/index","meta": {"title": "定时任务","icon": "job","noCache": false,"link": null}},{"name": "Druid","path": "druid","hidden": false,"component": "monitor/druid/index","meta": {"title": "数据监控","icon": "druid","noCache": false,"link": null}},{"name": "Server","path": "server","hidden": false,"component": "monitor/server/index","meta": {"title": "服务监控","icon": "server","noCache": false,"link": null}},{"name": "Cache","path": "cache","hidden": false,"component": "monitor/cache/index","meta": {"title": "缓存监控","icon": "redis","noCache": false,"link": null}},{"name": "CacheList","path": "cacheList","hidden": false,"component": "monitor/cache/list","meta": {"title": "缓存列表","icon": "redis-list","noCache": false,"link": null}}]},{"name": "Tool","path": "/tool","hidden": false,"redirect": "noRedirect","component": "Layout","alwaysShow": true,"meta": {"title": "系统工具","icon": "tool","noCache": false,"link": null},"children": [{"name": "Build","path": "build","hidden": false,"component": "tool/build/index","meta": {"title": "表单构建","icon": "build","noCache": false,"link": null}},{"name": "Gen","path": "gen","hidden": false,"component": "tool/gen/index","meta": {"title": "代码生成","icon": "code","noCache": false,"link": null}},{"name": "Swagger","path": "swagger","hidden": false,"component": "tool/swagger/index","meta": {"title": "系统接口","icon": "swagger","noCache": false,"link": null}}]}
]
(2)路由与状态管理
上面给的数据返回示例就是以下代码中res.data
的值,该值需要深拷贝,避免操作同一份数据。
const usePermissionStore = defineStore('permission', {state: () => ({routes: [], // 所有路由(静态 + 动态)addRoutes: [], // 动态添加的路由defaultRoutes: [], // 默认路由(用于侧边栏)topbarRouters: [], // 顶部导航栏路由sidebarRouters: [] // 侧边栏路由}),actions: {// 设置路由setRoutes(routes) {this.addRoutes = routes;this.routes = constantRoutes.concat(routes);},// 设置默认路由setDefaultRoutes(routes) {this.defaultRoutes = constantRoutes.concat(routes);},// 设置顶部导航路由setTopbarRoutes(routes) {this.topbarRouters = routes;},// 设置侧边栏路由setSidebarRouters(routes) {this.sidebarRouters = routes;},// 生成动态路由的核心方法generateRoutes() {return new Promise(resolve => {getRouters().then(res => {const sdata = JSON.parse(JSON.stringify(res.data)); // 深拷贝const rdata = JSON.parse(JSON.stringify(res.data));const defaultData = JSON.parse(JSON.stringify(res.data));// 处理路由数据const sidebarRoutes = filterAsyncRouter(sdata); // 侧边栏路由const rewriteRoutes = filterAsyncRouter(rdata, false, true); // 重写路径的路由const defaultRoutes = filterAsyncRouter(defaultData); // 默认路由const asyncRoutes = filterDynamicRoutes(dynamicRoutes); // 过滤后的动态路由// 动态添加路由asyncRoutes.forEach(route => { router.addRoute(route) });// 更新状态this.setRoutes(rewriteRoutes);this.setSidebarRouters(constantRoutes.concat(sidebarRoutes));this.setDefaultRoutes(sidebarRoutes);this.setTopbarRoutes(defaultRoutes);resolve(rewriteRoutes); // 返回生成的路由})})}}
})
generateRoutes() 流程:
- 调用
getRouters()
:从后端获取用户权限路由数据(通常是 JSON 结构)。 - 深拷贝数据:避免直接操作原始数据。
- 处理路由数据:
filterAsyncRouter
:将后端返回的路由数据转换为 Vue Router 可识别的格式。filterDynamicRoutes
:根据用户权限过滤动态路由。
- 动态添加路由:
router.addRoute(route)
将路由添加到 Vue Router。 - 更新 Pinia 状态:存储不同类型的路由(侧边栏、顶部导航等)。
(3)路由数据处理component
在filterAsyncRouter
函数里,主要是处理路由组件,比如将字符串的组件名转换为实际的组件对象,例如Layout、ParentView和InnerLink。
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {return asyncRouterMap.filter(route => {// 处理 component 字段if (route.component) {if (route.component === 'Layout') route.component = Layout;else if (route.component === 'ParentView') route.component = ParentView;else if (route.component === 'InnerLink') route.component = InnerLink;else route.component = loadView(route.component);}// 处理子路由if (route.children && route.children.length) {route.children = filterAsyncRouter(route.children, route, type);} else {delete route.children;delete route.redirect;}return true;});
}
作用:
- 将后端返回的 component 字符串(如 “Layout”)转换为真实的组件(如 Layout.vue)。
- 递归处理嵌套子路由。
- 删除无子路由的 children 和 redirect 属性。
(4)路由数据处理路径path
filterChildren函数处理子路由的路径,为了生成正确的嵌套路径。
这里有一些路径拼接的逻辑,特别是当父路由是ParentView时,子路由的路径会被合并到父路径下,这样处理可能是为了支持多级嵌套路由的显示。
function filterChildren(childrenMap, lastRouter = false) {const children = [];childrenMap.forEach(el => {if (el.children && el.children.length) {if (el.component === 'ParentView' && !lastRouter) {el.children.forEach(c => {c.path = el.path + '/' + c.path;if (c.children && c.children.length) {children = children.concat(filterChildren(c.children, c));return;}children.push(c);});return;}}if (lastRouter) {el.path = lastRouter.path + '/' + el.path;}children = children.concat(el);});return children;
}
作用:
- 拼接子路由路径:当父路由是
ParentView
时,子路由路径需要拼接父路径(如/user/add
→/parent/user/add
)。 - 处理嵌套层级,确保路径正确。
(5)路由遍历验证权限
filterDynamicRoutes
函数负责过滤动态路由,根据用户权限(permissions或roles)来决定是否包含该路由。
这里使用了auth.hasPermiOr
和auth.hasRoleOr
方法,应该是检查用户是否有相应的权限或角色,从而动态生成可访问的路由列表。
export function filterDynamicRoutes(routes) {const res = [];routes.forEach(route => {// 优先级:permissions > roles,满足任意条件即可通过。if (route.permissions) {if (auth.hasPermiOr(route.permissions)) res.push(route);} else if (route.roles) {if (auth.hasRoleOr(route.roles)) res.push(route);}});return res;
}
作用:
- 权限过滤:根据用户权限(roles 或 permissions)过滤路由。
- 仅保留用户有权限访问的路由。
(6)加载组件loadView
在loadView
函数中,通过遍历预先导入的views模块,找到与路由组件路径匹配的组件,并返回对应的异步加载函数。这样可以在路由被访问时才加载对应的组件,减少初始加载时间。
export const loadView = (view) => {let res;for (const path in modules) {const dir = path.split('views/')[1].split('.vue')[0];if (dir === view) {res = () => modules[path]();}}return res;
}
作用:
- 动态加载组件:通过 import.meta.glob 预先加载所有 views/ 下的 .vue 文件。
- 根据 view 名称匹配对应的组件路径并返回异步加载函数。
(7)流程图
用户登录成功↓
调用 usePermissionStore().generateRoutes()↓
1. 从后端获取路由数据
2. 深拷贝路由数据
3. 处理路由组件(Layout/ParentView/InnerLink)
4. 过滤无权限的路由(filterDynamicRoutes)
5. 动态添加路由(router.addRoute(route))
6. 更新 Pinia 状态(侧边栏/顶部导航等)↓
用户访问路由时,根据权限动态加载页面
三、权限判断
1.代码实现
// 引入用户状态管理模块(Pinia Store),用于获取用户权限和角色
import useUserStore from "@/store/modules/user";/*** 校验用户是否拥有指定权限* @param permission 要校验的权限字符串,如 'system:user:list'* @returns {boolean} 是否拥有该权限*/
function authPermission(permission: string): boolean {// 定义超级权限通配符(拥有所有权限)const all_permission = "*:*:*";// 从用户 store 中获取当前用户拥有的所有权限列表const permissions = useUserStore().permissions;// 如果传入的权限不为空,则进行判断if (permission && permission.length > 0) {return permissions.some((v) => {// 判断是否有超级权限,或者包含该权限return all_permission === v || v === permission;});} else {// 权限为空则返回 falsereturn false;}
}/*** 校验用户是否属于指定角色* @param role 要校验的角色名称,如 'admin' 或 'developer'* @returns {boolean} 是否属于该角色*/
function authRole(role: string): boolean {// 定义超级管理员角色(拥有所有权限)const super_admin = "admin";// 从用户 store 中获取当前用户拥有的所有角色const roles = useUserStore().roles;// 如果传入的角色不为空,则进行判断if (role && role.length > 0) {return roles.some((v) => {// 判断是否是超级管理员,或者包含该角色return super_admin === v || v === role;});} else {// 角色为空则返回 falsereturn false;}
}/*** 权限/角色验证工具对象* 提供多种权限和角色判断方法,适用于页面、菜单、按钮等场景*/
export default {/*** 验证用户是否具备某权限* @param permission 权限字符串,例如:'system:user:list'* @returns {boolean}*/hasPermi(permission: string): boolean {return authPermission(permission);},/*** 验证用户是否含有指定权限中的任意一个* 常用于“至少有其中一个权限”即可访问的场景* @param permissions 权限数组,例如:['system:user:list', 'system:role:list']* @returns {boolean}*/hasPermiOr(permissions: Array<string>): boolean {return permissions.some((item) => {return authPermission(item);});},/*** 验证用户是否拥有指定的所有权限* 常用于“必须全部满足”的严格权限控制场景* @param permissions 权限数组,例如:['system:user:list', 'system:user:edit']* @returns {boolean}*/hasPermiAnd(permissions: Array<string>): boolean {return permissions.every((item) => {return authPermission(item);});},/*** 验证用户是否属于某角色* @param role 角色名,例如:'admin' 或 'developer'* @returns {boolean}*/hasRole(role: string): boolean {return authRole(role);},/*** 验证用户是否属于指定角色中的任意一个* 常用于“多个角色任选其一”的场景* @param roles 角色数组,例如:['admin', 'manager']* @returns {boolean}*/hasRoleOr(roles: Array<string>): boolean {return roles.some((item) => {return authRole(item);});},/*** 验证用户是否属于指定的所有角色* 常用于“必须同时属于多个角色”的场景* @param roles 角色数组,例如:['admin', 'developer']* @returns {boolean}*/hasRoleAnd(roles: Array<string>): boolean {return roles.every((item) => {return authRole(item);});},
};
3.代码详解
(1)判断当前用户是否拥有指定权限。
/*** 校验用户是否拥有指定权限* @param permission 要校验的权限字符串,如 'system:user:list'* @returns {boolean} 是否拥有该权限*/
function authPermission(permission: string): boolean {// 定义超级权限通配符(拥有所有权限)const all_permission = "*:*:*";// 从用户 store 中获取当前用户拥有的所有权限列表const permissions = useUserStore().permissions;// 如果传入的权限不为空,则进行判断if (permission && permission.length > 0) {return permissions.some((v) => {// 判断是否有超级权限,或者包含该权限return all_permission === v || v === permission;});} else {// 权限为空则返回 falsereturn false;}
}
功能说明:
- 支持通配符权限:如果用户有
"*:*:*"
权限,则表示拥有系统所有权限。 - 支持精确匹配:例如
system:user:list
。
示例:
useUserStore().permissions = ['system:user:list', 'system:role:edit'];
authPermission('system:user:list'); // true
authPermission('system:user:add'); // false
(2)判断当前用户是否拥有指定角色。
/*** 校验用户是否属于指定角色* @param role 要校验的角色名称,如 'admin' 或 'developer'* @returns {boolean} 是否属于该角色*/
function authRole(role: string): boolean {// 定义超级管理员角色(拥有所有权限)const super_admin = "admin";// 从用户 store 中获取当前用户拥有的所有角色const roles = useUserStore().roles;// 如果传入的角色不为空,则进行判断if (role && role.length > 0) {return roles.some((v) => {// 判断是否是超级管理员,或者包含该角色return super_admin === v || v === role;});} else {// 角色为空则返回 falsereturn false;}
}
功能说明:
- 支持超级管理员:如果用户是 admin 角色,拥有所有权限。
- 支持普通角色匹配:如 developer、test 等。
示例:
useUserStore().roles = ['developer', 'admin'];
authRole('developer'); // true
authRole('manager'); // true(因为 admin 是超级管理员)
(3)对外暴露的方法(API)
这些方法供其他组件/路由使用,进行权限或角色的判断。
/*** 权限/角色验证工具对象* 提供多种权限和角色判断方法,适用于页面、菜单、按钮等场景*/
export default {/*** 验证用户是否具备某权限* @param permission 权限字符串,例如:'system:user:list'* @returns {boolean}*/hasPermi(permission: string): boolean {return authPermission(permission);},/*** 验证用户是否含有指定权限中的任意一个* 常用于“至少有其中一个权限”即可访问的场景* @param permissions 权限数组,例如:['system:user:list', 'system:role:list']* @returns {boolean}*/hasPermiOr(permissions: Array<string>): boolean {return permissions.some((item) => {return authPermission(item);});},/*** 验证用户是否拥有指定的所有权限* 常用于“必须全部满足”的严格权限控制场景* @param permissions 权限数组,例如:['system:user:list', 'system:user:edit']* @returns {boolean}*/hasPermiAnd(permissions: Array<string>): boolean {return permissions.every((item) => {return authPermission(item);});},/*** 验证用户是否属于某角色* @param role 角色名,例如:'admin' 或 'developer'* @returns {boolean}*/hasRole(role: string): boolean {return authRole(role);},/*** 验证用户是否属于指定角色中的任意一个* 常用于“多个角色任选其一”的场景* @param roles 角色数组,例如:['admin', 'manager']* @returns {boolean}*/hasRoleOr(roles: Array<string>): boolean {return roles.some((item) => {return authRole(item);});},/*** 验证用户是否属于指定的所有角色* 常用于“必须同时属于多个角色”的场景* @param roles 角色数组,例如:['admin', 'developer']* @returns {boolean}*/hasRoleAnd(roles: Array<string>): boolean {return roles.every((item) => {return authRole(item);});},
};
4.典型使用场景
在菜单渲染时过滤菜单项:
<template><a-menu mode="inline"><menu-item v-for="item in menus" :key="item.path" v-if="checkPermission(item)">{{ item.title }}</menu-item></a-menu>
</template><script setup>
import auth from '@/utils/auth';const checkPermission = (menu) => {if (menu.permissions) {return auth.hasPermiOr(menu.permissions);}return true;
};
</script>
在路由守卫中控制访问:
router.beforeEach((to, from, next) => {const requiredPermission = to.meta.permission;if (requiredPermission && !auth.hasPermi(requiredPermission)) {next('/401');} else {next();}
});
控制按钮显示:
<template><button v-if="auth.hasPermi('system:user:add')">新增用户</button>
</template>