一、前言

后台管理系统的权限控制对于前端来说是经常用到的知识点,也比较重要,最近梳理一下写成文章,方便以后查阅。
项目中实现菜单的动态权限控制使用到了两种技术,一种是Vue Router,另一种是vue3官方推荐使用的专属状态管理库Pinia。

二、权限由 前端还是后端 来控制?

正式开始之前我们先讨论下权限由 前端还是后端 来控制?网上百度很多资料都是路由表由后端根据用户的权限动态生成的,我们项目中未采取这种方式的原因如下:

  1. 项目后期不断迭代前端会异常痛苦,前端新开发一个页面还要让后端配一下路由和权限,很不方便,不能达到真正的前后端分离。
  2. 其次,拿业务来说,虽然后端的确也是有权限验证的,但他的验证其实针对业务来划分的;比如运营主管可以编辑新增商品,而普通运营只能查看商品列表,但对于前端来说,不管是运营主管还是普通运营都是有权限进入商品列表页的。所以前端和后端权限的划分是不太一致的。
  3. 还有一点是就vue2.2.0之前异步挂载路由是很麻烦的一件事!不过好在官方也出了新的api,虽然本意是来解决ssr的痛点的。

所以我们项目解决方法就是,前端会有一份路由表,他表示了每一个路由可访问的权限。当用户登录之后,获取后端用户权限的路由表 ,再去和前端路由表比对,生成当前用户权限可访问的路由表,通过router.addRoute动态挂载到router上。但这些控制都只是页面级的,说白了前端再怎么做权限控制都不是绝对安全的,后端的权限验证是逃不掉的。

对于后端来说,会去验证前端每一个涉及请求的操作,通过用户token验证其是否有该操作权限。若没有操作权限抛出一个对应状态码,前端对该状态码做出相应提示操作。

三、权限控制的两大部分

首先权限控制分为两大部分。

1. 接口访问的权限控制
2. 页面的权限控制
1)侧边栏菜单中的页面是否能被访问
2)页面中的按钮(增删改查)的权限控制是否显示

下面就着重了解下前端如何对这两部分进行权限控制。

四、接口访问的权限控制

接口权限就是对用户的校验。正常来说,在用户登录时服务器需要给前台返回一个token,以后调用后端接口时候,后端要求哪个接口传token,前端就在哪个接口传参时加上用户token,然后服务端获取到这个token后进行比对,如果通过则可以访问接口,正常返回数据。

现有的做法是在登录成功后,后端返回一个token(该token是一个能唯一标识用户身份的一个key),之后我们将token存储到sessionStorage中用的是web-storage-cache
存储,这样下次打开页面或者刷新页面的时候能记住用户的登录状态,然后请求时带着token,代码如下:

//commonQuery.ts//commonQuery() 是一些接口要求的公共参数export const tokenQuery = () => {return Object.assign(commonQuery(), { token: wsCache.get('token') })}
/* eslint-disable camelcase */import request from '@/request/http.ts'import { tokenQuery } from '@/request/commonQuery.ts'const testApi = {GETADSCATEGORYLIST() {return request({method: 'post',data: Object.assign({method: 'xxx'},tokenQuery())})}}export default testApi

ps:为了保证安全性,项目所有token 有效期都是session,就是当浏览器关闭就丢失了。重新打开浏览器都需要重新登录验证,后端也会在固定时间重新刷新token,让用户重新登录一次,确保后台用户不会因为电脑遗失或者其他原因被人随意使用账号。

五、页面的权限控制

前面已经说到,页面权限控制又分为两种:

  • 侧边栏菜单中的页面是否能被访问
  • 页面中的按钮(增删改查)的权限控制是否显示
这些权限一般是在前台固定页面进行配置的,保存后记录到数据库中。

按钮权限暂且不提,页面访问权限在实现中又可以分为两种方式:

  • 显示所有菜单,当用户访问不在自己权限内的菜单时,提示权限不足。
  • 只显示用户能访问权限内的菜单,如果用户通过URL进行强制访问,则会直接进入404。

既然展现出来的不能点击,不如直接不显示,所以还是方法二用户体验更好。

具体实现流程

1. main.ts中创建vue实例的时候将vue-router挂载,但这个时候vue-router挂载一些登录或者不用权限的公用页面。
2. 当用户登录后,后端返回用户权限下的路由表和前端写的异步路由表作比较,生成最终用户可访问的路由表。
3. 调用router.addRoute添加用户可访问的路由;先加载动态路由,再加载静态路由。
4. 使用Pinia存储路由表,根据存储可访问的路由渲染侧边栏组件。

1) router 创建路由表

创建路由表实际没什么难度,照着vue-router官方文档给的示例直接写就行。但是因为有部分页面是不需要访问权限的,所以需要将登录、404等页面写到公共路由中,而将其他需要权限的页面写到一个文件中,这样可以有效的减轻后续的维护压力。

比如下面的例子,router目录下,新建public.ts和主路由文件index.ts

// public.ts //不需要权限的公共路由表import { RouteRecordRaw } from 'vue-router'import Layout from '@/layout/index.vue'const PublicRouter: Array<RouteRecordRaw> = [{name: 'login',path: '/login',component: () => import('@/views/user-manager/login/LoginPage.vue'),meta: {title: '登录',icon: 'dashboard-two',hidden: true}}]export default PublicRouter
// index.tsimport { createRouter, createWebHistory, RouteRecordRaw, createWebHashHistory } from 'vue-router'import {AppRouteRecordRaw} from './types'import publicRouter from './public.ts'//不需要权限的公共路由表const asyncFiles = require.context('./permissionModules', true, /\.ts$/)//前端权限路由表数组;遍历文件夹中的指定文件,然后自动导入,let permissionModules: Array<AppRouteRecordRaw> = []asyncFiles.keys().forEach((key) => {permissionModules = permissionModules.concat(asyncFiles(key).default)})//根据sort数组排序permissionModules.sort((a:AppRouteRecordRaw, b:AppRouteRecordRaw) => {return a.meta.sort - b.meta.sort})//创建路由对象,实例化vue的时候只挂载公共路由const router = createRouter({history: createWebHashHistory(),routes: publicRouter})//导出异步路由export const asyncRoutes: Array<AppRouteRecordRaw> = [...permissionModules] //导出路由对象,在main.ts中引用export default router 
//main.tsimport App from './App.vue'import router from './router'import { createApp } from 'vue'const app = createApp(App) //实例化vue的时候只挂载公共路由app.use(router)app.mount('#app')

permissionModules文件夹,可参考下。

这里有个知识点路由拆分,也是前端工程化,能帮助我们大大提升效率,更好维护代码,让代码更简洁。

使用require.context实现前端工程自动化,自动导入模块

如果把所有的路由信息都写在一个文件就会显得非常臃肿,同时也不便于观看维护;
vue中路由的信息都是依赖于一个数组,所以可以将这个数组拆开,分到其他文件夹下的ts文件中,通过export导出这些子数组变量,最后将这些子数组变量合并成一个大的数组。

require.context(directory,useSubdirectories,regExp)
directory:表示检索的目录
useSubdirectories:表示是否检索子文件夹
regExp:匹配文件的正则表达式,一般是文件名
例如 require.context(‘./permissionModules’, true, /.ts$/) //表示代码遍历当前目录下的permissionModules文件夹的所有.ts结尾的文件,并且遍历子目录。

2) src/store/permission.ts

之前我们就说过登录后会获取到用户权限路由表,然后用的 Pinia 状态管理,Pinia官方文档 有详细介绍,可以去看,下面请看代码:

// login.tsfunction saveInfo(data:ReturnInfo) { // 处理用户登录信息 setStorePermissionMenu(data.menus)//存储权限菜单和按钮 setStoreToken(data.token)//存储token GenerateRoutes().then((res:RouteRecordRaw[]) => {// 比对生成用户权限可访问的路由表 res.forEach( async(route:RouteRecordRaw) => { await router.addRoute(route) //动态添加可访问路由表 }) setStoreIsAddRouters() //设置动态添加路由完毕 }) router.push({ path: '/' })//登录成功之后重定向到首页}
// store目录下的permission.tsimport { defineStore } from 'pinia'import type { RouteRecordRaw } from 'vue-router'import { deepClone } from '@/utils/tool'import { asyncRoutes } from '@/router/index.ts'import publicRouter from '@/router/public.ts'import * as storage from '@/utils/token.ts'export const usePermissionStore = defineStore({id: 'permission ', // id必填,且需要唯一state: () => {return {token: storage.getToken() || '',isAddRouters: false, // 是否已动态添加路由,刷新即重置,不存在sessionStorage中permissionMenu: storage.getPermissionMenu() || [] as any[], // 接口获取的路由列表addRouters: [] as RouteRecordRaw[], // 需要动态添加的路由permissionBtn: storage.getPermissionBtns() || [] as string[],menuData: storage.getMenuData() || [] as any[] // 左侧菜单,如果展示菜单与路由列表不一致,请自行处理}},actions: {GenerateRoutes(): Promise<unknown> {return new Promise((resolve) => {// 路由权限控制let routerMap: RouteRecordRaw[] = []console.log(asyncRoutes)routerMap = this.generateFn(deepClone(asyncRoutes, ['component']))// 先加载动态路由,再加载静态路由// 404页面放在最后加载;否则后面的所有页面都会被拦截到404this.addRouters = routerMap.concat([{path: '/:path(.*)*',redirect: '/error',name: '404',meta: {hidden: true,breadcrumb: false}}])this.menuData = deepClone(publicRouter, ['component']).concat(routerMap)storage.setMenuData(this.menuData) //存储侧边栏菜单resolve(this.addRouters)})},//循环后端返回的用户权限路由表和前端写好的异步路由表,根据path路径是否相等进行匹配// 这部分是关键点generateFn(originRoutes: RouteRecordRaw[]): RouteRecordRaw[] {const res: RouteRecordRaw[] = []originRoutes.forEach(route => { // 循环所有前端写的需要权限的路由let data: any = nullfor (let i = 0; i < this.permissionMenu.length; i++) { // 循环后台返回的一维权限菜单,进行匹配if (this.permissionMenu[i].path === route.path) {data = Object.assign({}, route)break}}if (data && route.children && route.children.length > 0) {data.children = this.generateFn(route.children)}if (data) {res.push(data as RouteRecordRaw)}})return res},// 处理并存储用户权限路由和权限按钮setStorePermissionMenu(permissionMenu: any[]) {this.permissionMenu = []this.permissionBtn = []filterMenu(permissionMenu, 0, this.permissionMenu, this.permissionBtn)storage.setPermissionBtns(this.permissionBtn)storage.setPermissionMenu(this.permissionMenu)},setStoreIsAddRouters() {this.isAddRouters = true},setStoreToken(token: string) {this.token = tokenstorage.setToken(token)}}})//处理后端返回的数据,变成我们想要的结构function filterMenu(resMenu: any, count: number, permissionMenu: any[], permissionBtn: string[]) {resMenu.forEach((item: any) => {if (count === 2) {permissionBtn.push(item.view_url)} else {const obj = {path: item.view_url,title: item.name,hidden: item.isShow !== '1',// sort: item.sort}permissionMenu.push(obj)}if (item.list) {const level = count + 1filterMenu(item.list, level, permissionMenu, permissionBtn)}})}

上面的代码说白了就是干了一件事,通过后端接口返回的用户权限和之前前端写的异步路由的每一个页面所需要的权限做匹配,最后返回一个该用户能够访问路由有哪些。

注意:
这里有一个需要非常注意的点就是404页面一定要最后加载,否则后面的所有页面都会被拦截到404。

3) permission.ts 使用 路由拦截器

//permission.tsrouter.beforeEach((to: RouteLocationNormalized, from:RouteLocationNormalized, next: any) => {// Start progress barNProgress.start()const usePermission = usePermissionStore()const { token, isAddRouters } = storeToRefs(usePermission)const { GenerateRoutes, setStoreIsAddRouters } = usePermission// Determine whether the user has logged inif (token.value !== '') { // 已经登陆if (to.path === '/login') {next({ path: '/' })NProgress.done()} else {// 如果动态添加路由完毕if (isAddRouters.value === true) {next()NProgress.done()} else {//没有动态添加路由,则动态添加路由GenerateRoutes().then((res:RouteRecordRaw[]) => { res.forEach(async(route:RouteRecordRaw) => {await router.addRoute(route) })const redirectPath = (from.query.redirect || to.path) as stringconst redirect = decodeURIComponent(redirectPath)const nextData = to.path === redirect " />{ ...to, replace: true } : { path: redirect }setStoreIsAddRouters() //设置动态添加路由完毕next(nextData)NProgress.done()}).catch((err: any) => {console.error(err)})}}} else { // 未登陆if (whiteList.indexOf(to.path) !== -1) { //免登录白名单,直接进入next()NProgress.done()} else {// 否则全部重定向到登录页next({path: '/login',query: {redirect: to.path}})NProgress.done()}}})

最后在main.ts引入

//main.tsimport '@/permission'

注意:
这里有一个需要注意的点就是router.addRoute之后next()可能会失效,因为可能next()的时候路由并没有完全add完成。
所以我们在登录那里,添加动态路由表完毕之后设置isAddRouters = true,然后路由跳转之前先去判断isAddRouters = true,路由放行;否则再次动态添加路由。

关于router.addRoute()
新版Vue Router中用router.addRoute来替代原有的router.addRoutes来动态添加路由、子路由。
在之前通过后端动态返回前端路由一直很难做的,因为vue-router必须是要在vue实例之前就挂载上去的,不太方便动态改变。有了router.addRoute 我们就可以相对方便的做权限控制了。

4) 侧边栏

在前面的基础上,使用element-ui的el-menumenu-item,就能很方便实现动态显示侧边栏了。
只要我们从Pinia的store中拿到菜单数据menuData,v-for循环渲染数据就行了。

侧边栏点击高亮问题:element-ui官方给了el-menu default-active属性,我们只要

:default-active="$route.path"//将 default-active 指向当前路由就可以了。

到这里我们已经完成了对页面访问的权限控制,接下来我们来讲解一下操作按扭的权限部分。

3) 按钮级别权限控制

封装了一个全局自定义指令权限,能简单快速的实现按钮级别的权限判断。
关于全局自定义指令感兴趣的可参考我之前的文章 vue3全局自定义指令实现按钮权限控制

实现步骤:

  1. 我们登录成功之后,处理接口返回数据的时候,就存储了用户权限按钮数组permissionBtn,如下图:

  2. 进行封装,项目根目录下新建一个directives文件夹 =》permission.ts和index.ts
    主要思路就是用户没有这个按钮权限的话,隐藏按钮。

// permission.ts// 引入vue中定义的指令对应的类型定义import { Directive } from 'vue'export const permission: Directive = {// mounted是指令的一个生命周期mounted(el, binding) {// value 获取用户使用自定义指令绑定的内容const { value } = binding// 获取用户所有的权限按钮const permissionBtn = wsCache.get('permission')// 判断用户使用自定义指令,是否使用正确了if (value && value instanceof Array && value.length > 0) {const permissionFunc = value//判断传递进来的按钮权限,用户是否拥有//Array.some(), 数组中有一个结果是true返回true,剩下的元素不会再检测const hasPermission = permissionBtn.some((role: any) => {return permissionFunc.includes(role)}) // 当用户没有这个按钮权限时,设置隐藏这个按钮if (!hasPermission) {el.style.display = 'none'}} else {throw new Error('need roles! Like v-permission="[\'admin\',\'editor\']"')}}}// 注意,我们这里写的自定义指令,传递内容是一个数组,也就说,按钮权限可能是由// 多个因素决定的,如果你的业务场景只由一个因素决定,自定义指令也可以不传递一个数组,// 只传递一个字符串就可以了
// index.tsexport * from './permission'
  1. main.ts中注册为全局指令
import App from './App.vue'import { createApp, Directive } from 'vue'import * as directives from '@/directives' //权限判断指令const app = createApp(App)console.log(directives, 'directives') //打印发现是导出的自定义指令名,permissionObject.keys(directives).forEach(key => {//Object.keys() 返回一个数组,值是所有可遍历属性的key名app.directive(key, (directives as { [key: string ]: Directive })[key])//key是自定义指令名字;后面应该是自定义指令的值,值类型是string})
  1. 在页面中使用,控制按钮显示
<template><button v-permission="['auto.add']">新增</button><button v-permission="['auto.update']">编辑</button><button v-permission="['auto.delete']">删除</button></template>

六、遇到的问题

1. 路由刷新失效问题

目前解决方案是将处理好的权限路由,通过web-storage-cache保存到sessionStorage,因为它扩展了序列化方法,可以直接存储json对象,刷新不会重置。然后放到store里面。最后从store里面取出,调用router.addRoute()方法。
关于sessionStorage和web-storage-cache的差异

到此为止,由前端配置权限控制流程就差不多了,如果有疑问,或者文章有什么错误,欢迎留言评论。

学习过程中参考了:
vue中如何实现后台管理系统的权限控制
手摸手,带你用vue撸后台 系列二(登录权限篇)