Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

  1. 权限控制的方案选择。

做后台项目区别于做其它的项目,权限验证与安全性是非常重要的,可以说是一个后台项目一开始就必须考虑和搭建的基础核心功能。在后台管理系统中,实现权限控制可以采用多种方案:

权限方案类型描述
基于角色的访问控制(Role-Based Access Control,RBAC)是一种广泛采用的权限控制方案。系统中定义了不同的角色,每个角色具有一组权限,而用户被分配到一个或多个角色。通过控制用户角色的分配,可以实现对用户访问系统中不同功能和资源的权限控制。
基于权限的访问控制(Permission-Based Access Control)这种方案将权限直接分配给用户,而不是通过角色来管理。每个用户都有自己的权限列表,控制用户对系统中各项功能和资源的访问。
基于资源的访问控制(Resource-Based Access Control,RBAC)这种方案将权限控制与资源本身关联起来。系统中的每个资源都有自己的访问权限,用户通过被授予资源的访问权限来控制其对资源的操作。
层次结构权限控制(Hierarchical Access Control)这种方案基于资源和操作的层次结构来进行权限控制。系统中的资源和操作被组织成层次结构,用户被授予访问某个层次及其子层次的权限。
基于规则的访问控制(Rule-Based Access Control)这种方案使用预定义的规则来确定用户对系统中功能和资源的访问权限。规则可以基于用户属性、环境条件或其他因素进行定义。

这里我选择了基于角色的访问控制(Role-Based Access Control,RBAC) 这是因为 RBAC 提供了一种灵活且易于管理的方式来控制用户对系统功能和资源的访问,也是目前最主流的前端权限方案选择。

在 RBAC 中,系统中的功能和资源被组织成角色,而用户则被分配到不同的角色。每个角色都有一组权限,定义了该角色可以执行的操作和访问的资源。通过给用户分配适当的角色,可以实现对用户的权限控制。

RBAC 的好处之一是它简化了权限管理的复杂性。管理员只需管理角色和分配角色给用户,而不需要为每个用户单独定义权限。当需要对用户的权限进行修改时,只需调整其角色的权限即可。

此外,RBAC 还支持灵活的权限组合,允许创建具有不同权限组合的角色,以适应不同用户的需求。它也便于扩展,可以随着系统的发展和需求的变化而调整和添加角色。

2.RBAC 下的权限字段设计与管理模型

1. 用户权限授权

是对用户身份认证的细化。可简单理解为访问控制,在用户身份认证通过后,系统对用户访问菜单或按钮进行控制。也就是说,该用户有身份进入系统了,但他不一定能访问系统里的所有菜单或按钮,而他只能访问管理员给他分配的权限菜单或按钮。 主要包括:

  • Permission(权限标识、权限字符串):针对系统访问资源的权限标识,如:用户添加、用户修改、用户删除。

  • Role (角色):可以理解为权限组,也就是说角色下可以访问和点击哪些菜单、访问哪些权限标识。

权限标识或权限字符串校验规则:

  • 权限字符串:指定权限串必须和菜单中的权限标识匹配才可访问

  • 权限字符串命名规范为:模块:功能:操作,例如:sys:user:edit

  • 使用冒号分隔,对授权资源进行分类,如 sys:user:edit 代表 系统模块:用户功能:编辑操作

  • 设定的功能指定的权限字符串与当前用户的权限字符串进行匹配,若匹配成功说明当前用户有该功能权限

  • 还可以使用简单的通配符,如 sys:user:*,建议省略为 sys:user(分离前端不能使用星号写法)

  • 举例 1 sys:user 将于 sys:usersys:user: 开头的所有权限字符串匹配成功

  • 举例 2 sys 将于 syssys: 开头的所有权限字符串匹配成功 这种命名格式的好处有:

  1. 可读性和可理解性:使用模块、功能和操作的格式可以直观地表达权限的含义。每个部分都有明确的作用,模块表示特定的模块或子系统,功能表示模块内的某个功能或页面,操作表示对功能进行的具体操作。通过这种格式,权限名称可以更容易地被开发人员、管理员和其他人员理解和解释。

  2. 可扩展性和灵活性: 通过使用模块、功能和操作的格式,可以轻松地扩展和管理权限。每个模块、功能和操作都可以被单独定义和控制。当系统需要增加新的功能或操作时,可以根据需要添加新的权限字符串,而不需要修改现有的权限规则和代码。

  3. 细粒度的权限控制: 这种格式支持细粒度的权限控制,可以针对特定的功能和操作进行权限管理。通过将权限名称拆分为模块、功能和操作,可以精确地定义哪些用户或角色具有访问或操作特定功能的权限。

  4. 避免权限冲突: 使用模块、功能和操作的格式可以避免权限之间的冲突。不同模块、功能和操作的权限名称是唯一的,这样可以避免同名权限之间的混淆和冲突。

2. 权限管理模型

关键数据模型如下:

  • 用户:登录账号、密码、角色

  • 角色:角色名称、角色权限字符、对应菜单、对应菜单下的权限

  • 菜单:菜单名称、菜单 URL、菜单类型

  • 用户角色关系:用户编码、角色编码

  • 角色菜单关系:角色编码、菜单编码

关系图如下:

【用户】  <---多对多--->  【角色】  <---多对多--->  【菜单/权限】
  1. 实现思路与步骤

前端权限一般分为路由级权限和按钮级权限,这里我们先实现页面路由级的权限功能,按钮级的会在后面讲到。大致的思路如下图:

上图是用户从登录-->路由守卫-->权限验证-->构建路由表-->跳转目标页面的一个简单的正向流程,可以看到,权限验证构建路由表这两步是发生在路由守卫这一步里,而实际上在开发设计阶段,我们还要做的准备有:

  1. 与后端确认权限字段及类型

  2. 确定路由表信息是由前端还是后端生成

为了方便理解,我们就按照这个正向流程来逐步实现,期间需要用到或者提前考虑设计到的内容,包括以上需要准备的两点,我会补充在步骤当中。

1. 登录

登录成功后,获取到 token, 将 token 存到本地的 sessionStorage 里。

async login({ commit }, userInfo) {
    const { data } = await login(userInfo)
    const accessToken = 'Bearer ' + data.access_token
    if (accessToken) {
      sessionStorage.getItem(tokenTableName)
    } else {
      message.error(`登录接口异常,未正确返回${tokenName}...`)
    }
  }

接着,进行路由跳转到首页

import { useRouter } from 'vue-router';
const router = useRouter(); 
router.push('/');

如果考虑到页面 token 失效后,重新登陆后返回原先的路由地址,则需要在路由守卫中添加redirect字段用来存储当前的路由地址

next({ path: '/login', query: { redirect: to.fullPath }, replace: true })

然后在登录页中,监听路由对象中的这个值,如果有值,那么在刚刚路由跳转时,就跳转到该路由地址,而非/首页,另外,还需要考虑到 403,404 页面。所以,添加了这些逻辑后,部分代码如下:

//login.vue<script setup>import { ref, watch, useRouter } from 'vue';import { useRouter } from 'vue-router';const redirect = ref('/');const router = useRouter();watch(  () => router.currentRoute.value.query.redirect,  (redirectValue) => {    redirect.value = redirectValue || '/';  },  { immediate: true });function handleRoute() {        return redirect.value === '/404' || redirect.value === '/403'          ? '/'          : redirect.value      },       async handleSubmit() {        loading.value = true        try {          await login(form.value)          loading.value = false          router.push(handleRoute());        } catch {         loading.value = false        }      },      </script>

2. 路由守卫与权限校验

当进行路由跳转时,会进入到路由守卫中,在路由守卫中:

  1. 路由守卫会首先判断有没有 token,如果没有,先判断要去的路由地址是否包含在路由白名单内,如果要去的路由地址也不在路由白名单内,就会让用户跳转到登录页重新登陆。

  2. 如果有 token,会先判断跳转的目标地址是否是登录页,如果是,则重新跳转到默认首页

  3. 此时,我们就需要对用户权限进行校验,首先,判断当前用户是否拥有角色信息, 如果没有,就要获取用户的角色信息。

3. 获取角色信息与权限信息

调取用户信息接口获取用户角色信息和权限信息, 代码如下:

//store/user // 获取用户信息  GetInfo({ commit, dispatch }) {    return new Promise((resolve, reject) => {      getUserInfo()        .then(async (response) => {          const result = response.data          if (result.roles && result.roles.length > 0) {            // 验证返回的roles是否是一个非空数组            commit('SET_ROLES', result.roles)            //permissions就是对应的用户权限信息            commit('SET_PERMISSION', result.permissions)          } else {            // 如果当前用户没有角色,则赋值一个默认角色            commit('SET_ROLES', ['ROLE_DEFAULT'])           }          resolve(result)          // GetInfo一旦失败就说明这个token不是过期就是丢失了,直接走catch并让调用方跳转路由          if (!response.success) {            reject(response.errorMsg)          }        })        .catch((error) => {          reject(error)        })    })  },

根据之前上文提到的权限管理模型,从之间的关系是多对多,因此,role(角色)permssions(权限)的类型应为数组,其中的权限 permssion 这个字段的格式规范也在上文提及。在与后端约定好后,后端返回的信息如图:

4. 谁来生成动态路由表?

在成功获取到用户信息,拿到用户角色和对应权限后,此时,就需要根据权限生成对应的路由表了,到这里,我们需要思考一个问题:**路由表是由后端提供还是由前端提供?**A: 前端根据权限生成路由表 B: 后端生成路由表给前端

没错!答案是 C:路由表可以由后端提供或由前端提供。

两者的优劣分别是:

1、后端提供路由表:

优点

安全性高:后端负责验证和控制权限,可以确保只有授权的用户能够访问特定的路由和功能。

隐藏敏感信息:后端可以根据用户的角色和权限隐藏不应该被访问的敏感路由和数据。

适用于复杂的权限规则:后端可以使用更复杂的逻辑和规则来处理权限控制,例如基于用户角色、用户组、权限等的复杂控制逻辑。

缺点:

前后端耦合度高:由于路由表由后端提供,前端开发人员可能需要与后端开发人员密切协作,增加了协调和沟通的成本。

前端依赖后端:前端应用程序可能需要等待后端提供路由表后才能进行开发和测试,增加了开发的时间和依赖性。

2、前端提供路由表:

优点:

前后端解耦:前端可以独立开发和维护路由表,减少了与后端的依赖性和协调成本。更好的用户体验:

前端可以根据用户的角色和权限动态展示路由和功能,提供更灵活和个性化的用户体验。

缺点:

安全性较低:前端提供的路由表容易受到篡改和绕过,安全性相对较低。隐藏敏感信息的难度增加:

前端无法直接隐藏敏感路由和数据,需要依赖后端接口的授权验证来保护敏感信息。

最终,考虑到后台管理系统的安全性优先级最高,我选择了由后端存储,并生成返回路由表信息。 确定好了之后,你就会遇到一个坑:后端提供的路由表无法直接在前端路由中添加使用 这是因为,后端存储的路由表的结构只一个 JSON 对象。怎么解决,我们放到后面讲。

5. 公共路由和动态路由

我们现在把整个路由分为两个部分,分别是公共路由动态路由(私有路由)

公共路由

公共路由顾名思义就是无论当前用户是什么角色,都会出现的路由部分。一般这样的路由分别有:首页驾驶舱、登录注册页、403 页、404 页。  **这部分路由是在前端项目中提前写死的。**以我的项目为例,我在 src/config 下创建一个publicRouter.js文件,然后里面放入基础路由信息:

//基础公共路由export const constantRouterMap = [{ path: '/', name: '', component: () => import('@/layout'), redirect: '/homePage', children: [   {     path: '/homePage',     name: 'homePage',     component: () => import('@/views/homePage/index.vue'),   }, ],},{ path: '/login', component: () => import('@/views/login'),},{ path: '/403', name: '403', component: () => import('@/views/403'),},{ path: '/:pathMatch(.*)*', component: () => import('@/views/404'),}]

这里面都是路由懒加载的写法,这个没啥好说的。需要注意的是在 vue3 中,vue-router 的版本是 4.x 以上,在跳转 404 页面时,如果你的写法是

{ path: "/404", name: "notFound", component:  () => import('@/views/404')}

这样写会提示报错,这是因为 vue-router4.x 的版本官方推荐引入了这种新写法,配置一个通配符路由,匹配所有未被其他具体路由匹配的路径。其中 :pathMatch 是参数名,而 (.*)* 是参数的匹配模式,它使用正则表达式来匹配任意路径。

动态路由 (私有路由)

上文提到的后端返回给前端当前用户的路由表信息,其实就是私有路由,这部分的路由是动态的。那么我们只需要把公共路由+动态路由=当前角色用户完整路由表。然鹅,后端返回给前端的动态路由是无法直接添加到当前页面路由中的,这是因为在浏览器的前后端请求当中,信息的返回体都是以 JSON 字符串的数据交换格式进行传输,而 JSON 字符串是不支持函数的。为什么要提到函数形式呢?因为当我们在路由表使用懒加载的写法时,component的值是一个异步函数。也只有异步函数才能实现对路由的异步懒加载。import()会返回一个 promise,这个 promise 最终会加载对应的组件模块。在格式上表现为一个 func 函数,因此,我们无法将 compnent 的值传给后端存储但是办法总比困难多,我们可以将对应的路径字符串传给后端存储,然后再通过后端返回的路径字符串转换成这种箭头函数的写法。 除了这个需要转换以外,我们还要考虑一些问题:

  1. 比如,转换后的路由结构要符合 router 中的路由格式,否则会在调用 router.addRoutes 时报错,添加失败。

  2. 添加一些其他自定义属性(例如添加导航栏菜单图标、某个路由的显隐、路由缓存、跳转外链接、...),以满足特定需求。 以我项目中的的路由示例,直接上代码看:

{    "path": "/",  //path,路由的路径,即访问该路由时的 URL 路径。    "name": "homePage",//name,路由的名称,用于在代码中标识路由。通常用于编程式导航。    "hidden": false,//hidden,是否隐藏路由,在Vue Router 4.0 中被废弃。    "component": "Layout"//路由对应的组件,可以是通过懒加载方式导入的异步组件,或直接引入的同步组件。    "meta": {//meta,路由元信息,可以用于存储一些额外的信息,比如页面标题、权限等        "title": "首页",   //路由标题        "icon": "icon-Home", //路由图标        "target": "", //是否跳转新页签        "permission": "homePage",//权限        "keepAlive": false//是否缓存    },    "redirect": "/homePage",//重定向    "fullPath": "/",//完整的url路径,会带上?后面的参数    "children": [//子路由        {            "path": "/homePage",            "name": "homePage",            "meta": {                "title": "首页",                "icon": "",                "target": "",                "permission": "homePage",                "keepAlive": false            },            "fullPath": "/homePage"        }    ]}

考虑完这些之后,我们就开始拿着后端返回的路由表信息动手转换吧! 先看看后端返回给前端的路由表格式内容

这里你只需要注意 component 这个字段的值就行了。 可能有人会问:“那我给后端传的路由表是什么格式的,也是这样吗?” 答案是:当然不是!RBAC 权限方案中,用户可以自己配置权限,也就是会有对应的用户管理,角色管理,菜单(权限)管理这三个基本的模块,给后端传的是对应模块的修改配置内容。前端是不需要关心传给后端什么格式。 有点啰嗦了,总之,就是你指着上文这三张图片,让后端给你生成出来就完事了。后端要是说办不到,那就是后端的问题。

6.RBAC 的权限配置项

上文提到了三个基本的配置模块,分别是用户管理角色管理菜单(权限)管理, 按照先后顺序,我们首先要新建一个权限,在页面级的权限中,权限是跟每个页面路由一一对应的,所以当我们需要新建一个权限的时候,也就是在新建一个页面路由。而为了方便知道所有页面权限之间的层级关系,最终的展现形式就是一个树形表格结构

可以看到,在页面路由(权限)配置项里,单个路由的各个都可以进行修改配置,这就让整个项目的路由表的可维护性和灵活度都极大的提高。需要注意的是路由地址组件路径这两个配置项,路由地址可以随意命名,但是同层级下,必须唯一。 而组件路径里面的值就是相对 src 目录的相对路径。比如我新建一个部门管理页面权限,一般在前端路由中的懒加载写法是这样:

component: () => import('@/views/login'),

但在这个配置项中,只需要填入 login 这个字段就行了。原因是因为前面的这些字段会在后面对路由的格式化中进行处理。虽然后续步骤会讲到这一步,但这里我还是先贴出来,方便你理解:

component:       constantRouterComponents[item.component || item.key] ||       (() =>         import(`@/views/${item.component}`).catch(() =>           import('@/views/404')         )),

那有人就会问,如果我乱填一个不存在的组件路径地址怎么办?正常情况下这样会导致整个动态路由在构建过程中报错,从而导致白屏。所以,上述代码里的.catch会捕获这个错误,然后跳转到 404 页面,再从 404 页面跳转到首页。就像这样:

当我们配置好权限后,会将新增的路由配置信息发送给后端,后端根据这些配置信息,会在对应的路由表中添加这个路由,并生成新的路由表返回给前端。 那当我们有一个权限之后,该怎么配置给对应的用户呢?还记得我上文提及的 RBAC 的权限管理模型嘛:

就是按照关系图中,我们将权限分配给角色,所以,就要有角色管理这个模块来进行分配:

用户管理也是依葫芦画瓢,跟角色管理如出一辙,碍于篇幅,就不再过多赘述。每一个配置项都可以根据你的业务场景和需要,进行增减。

7. 获取动态路由(私有路由)并转换

言归正传,接下来,这一步是整个前端权限中最重要的一步。在 src/router 下,我们新建一个 generatorRouters.js 文件。里面的内容是:

//getCurrentUserNav获取动态路由的接口import { getCurrentUserNav } from '@/api/user'//validURL是一个正则判断方法,用来校验当前字符串是否符合url外链格式import { validURL } from '@/utils/validate'// 前端路由表const constantRouterComponents = {  // 基础页面 layout 必须引入  Layout: () => import('@/layout'),  403: () => import('@/views/403'),  404: () => import('@/views/404'),}/** * 动态生成菜单 * @param token * @returns {Promise<Router>} */export const generatorDynamicRouter = (token) => {  return new Promise((resolve, reject) => {    getCurrentUserNav()      .then((res) => {       //接收后端返回的路由表,结构是个数组        let menuNav = res.data        //将路由表存到本地临时缓存中        //这一步是为了刷新的时候不需要再调接口,增加用户体验的,没这方面需求可以不用写        sessionStorage.setItem('generateRoutes', JSON.stringify(menuNav))        //转化路由格式        const routers = generator(menuNav)        resolve(routers)      })      .catch((err) => {        reject(err)      })  })}/** * 格式化树形结构数据 生成 vue-router 层级路由表 * * @param routerMap //当前路由表route * @param parent//当前路由表route的父级component * @returns {*} */export const generator = (routerMap, parent) => {  return routerMap.map((item) => {    const { title, show, hideChildren, hiddenHeaderContent, icon, hidden } =      item.meta || {}    if (item.component) {      // Layout ParentView 组件特殊处理      //这里是对父组件/布局组件的处理,因为有可能出现嵌套布局和多种布局的情况,这个时候需要进行处理。      if (item.component === 'Layout') {        item.component = 'Layout'      } else if (item.component === 'ParentView') {        item.component = 'BasicLayout'        item.path = '/' + item.path      }    }    if (item.isFrame === 0) {      item.target = '_blank'    }    const currentRouter = {      // 如果路由设置了 path,则作为默认 path,否则 路由地址 动态拼接生成如 /dashboard/workplace      path: item.path || `${(parent && parent.path) || ''}/${item.key}`,      // 路由名称,建议唯一      // name: item.name || item.key || '',      name: item.name || item.key || '',      // 该路由对应页面的 组件 :方案1      // 该路由对应页面的 组件 :方案2 (动态加载)      //这里就是将component的字符串值转换成懒加载的异步函数      //同时,如果当前路径并没有对应的组件,catch捕获报错,然后跳转到404页,这一步很重要,否则      component:        constantRouterComponents[item.component || item.key] ||        (() =>          import(`@/views/${item.component}`).catch(() =>            import('@/views/404')          )),      hidden: item.hidden,      // redirect: '/' + item.path || `${parent && parent.path || ''}/${item.key}`,      // meta: 页面标题, 菜单图标, 页面权限(供指令权限用,可去掉)      meta: {        title: title,        icon: icon,        hiddenHeaderContent: hiddenHeaderContent,        target: validURL(item.path) ? '_blank' : '',        permission: item.name,        keepAlive: false,        hidden: hidden,      },      //适配本框架的跳转路径    }    // 是否设置了隐藏菜单    if (show === false) {      currentRouter.hidden = true    }    // 修正路径,而antdv-pro的pro-layout要求每个路径需为全路径    //这一步的修正路径也是因人而异,正常情况下按照我这样拼接就没问题了    if (!constantRouterComponents[item.component || item.key]) {      if (parent && parent.path && parent.path !== '/') {        currentRouter.path = `${parent.path}/${item.path}`      }    }    // 是否设置了隐藏子菜单    if (hideChildren) {      currentRouter.hideChildrenInMenu = true    }    // 重定向    item.redirect && (currentRouter.redirect = item.redirect)    //添加fullPath    currentRouter.fullPath = currentRouter.path    // 是否有子菜单,并递归处理    if (item.children && item.children.length > 0) {      currentRouter.children = generator(item.children, currentRouter)    }    return currentRouter  })}
generatorDynamicRouter 方法(promise 嵌套)

上面的代码有点多,不要觉得麻烦,其实就两个方法,第一个方法generatorDynamicRouter会调取后端接口,获取后端返回的私有路由表信息,返回的是个promise对象,因为需要调接口请求后端数据,并且其他方法依赖这个方法的接口返回值,所以是个异步函数。之所以这么写的另外一个原因,是因为在它之前还有个 promise 异步方法包着它。 我们都知道:在嵌套的 Promise 中,内部的 Promise 会先执行,然后再执行外部的 Promise。这是由于 JavaScript 的异步执行机制所决定的。 所以这种套娃式的目的只有一个——就是确保外层的异步方法在调用内部异步方法时,能够保证拿到内部异步方法请求成功之后的返回值(也就是将异步方法变成同步方法,这也是 Promise 主要的作用之一,面试常问的)。

而最外层的异步方法,是放在路由守卫当中直接调用的,由于我们是动态异步加载路由,那么执行的方法肯定也是异步。具体的原因是因为:如果动态加载路由的方法不是异步的,那么在路由守卫中使用它将导致它立即执行并返回一个 Promise,而不是在路由导航过程中延迟加载。这样会使得在路由守卫中的逻辑无法按预期执行,因为在组件加载完成之前,它依赖的组件可能还没有被加载,导致出现错误或不一致的状态。

从后端获取路由配置往往涉及到网络请求和异步操作,不能保证立即返回所有路由信息。当你从后端获取到路由配置后,需要将其添加到 Vue Router 中,以便在前端应用程序中进行动态路由。在这个过程中,你需要使用异步操作来确保在获取到路由配置后再添加到路由中。

generator 方法(递归转化)

言归正传,generatorDynamicRouter方法调取后端接口,拿到后端返回路由表信息后,会紧接着调用第二个generator方法,并将路由表信息当做参数传入。而generator是一个递归方法。这个也很好理解,因为路由信息里都会包含 children 子路由,需要层层递归,generator内部会对每一个路由进行逐层转化,每一步都在代码中标有注释,我就不再过多赘述了。我们只需要知道:**generator的最终目的就是将后端返回的路由表结构转化成符合前端路由格式的路由 **(听起来有点绕)。 附上格式化好之后的路由信息: 再对比下格式化之前的样子: 可以看到 component 从字符串变成了函数,path 路径也得到了拼接补充。

8. 存储格式化好的动态路由及导航栏菜单信息

上文提到在 generatorDynamicRouter 异步方法之外还有一层异步方法,其实我是放在了 vuex 的 store 下某个模块中的 actions 里(默认大家都会用 vuex)。具体代码如下

const actions = {  GenerateRoutes({ commit }) {    let sidebarList = []    return new Promise((resolve) => {      generatorDynamicRouter().then((routers) => {        //sidebarList是侧边栏渲染数据        sidebarList = routers        //routers就是我们要存储到vuex中的动态路由        commit('set_routers', routers)        //侧边栏数据存到vuex中        commit('set_sidebarList', sidebarList)        resolve()      })    })  },}

9. 添加动态路由

好了,到这一步,我们就拿到了准备好的公共路由私有路由。这个时候,我们再回到路由守卫,回顾一下之前的流程:

  1. 路由守卫先判断 token

  2. 没有 token 去登录拿 token(或者是直接去了白名单里)

  3. 拿到 token 后判断有没有角色信息,没有角色信息就去请求角色信息(调接口)

  4. 拿到角色信息后,再去获取对应角色信息的路由表(调接口)

  5. 将后端返回的路由表信息转换成前端能添加的路由表信息格式

  6. 添加路由

  7. 跳转对应路由页面

现在,我们来到了第 6 步,添加路由,在添加路由之前,先看下路由守卫: 路由守卫的代码如下,在 src/config 目录下新建文件permissions.js

import router from '@/router'import store from '@/store'import getPageTitle from '@/utils/pageTitle'import getInfoRouter from '@/router/getInfoRouter'import { loginInterception, recordRoute, routesWhiteList } from '@/config'router.beforeEach(async (to, from, next) => {  let hasToken = store.getters['user/accessToken']  if (hasToken) {    if (to.path === '/login') {      next({ path: '/' })    } else {      //权限校验      if (store.getters['user/roles'].length === 0) {        try {            //获取用户信息,包括角色信息和权限信息           await store.dispatch('user/GetInfo')            //获取动态路由表信息,并对路由表进行格式转化,然后存到vuex中           await store.dispatch('async-router/GenerateRoutes')            //从vuex中拿到动态路由表数据           let accessRoutes = store.getters['async-router/addRouters']            //循环添加路由           accessRoutes.forEach((route) => {            //isHttp方法用来判断是否是外链,如果是外链,就不添加到当前路由表中            if (!isHttp(route.path)) {              router.addRoute(route) // 动态添加可访问路由表            }          })          //刷新跳转          next({ ...to, replace: true })        } catch (e) {          //登出          await store.dispatch('user/Logout')          //记录登出前的路由地址          next({ path: '/login', query: { redirect: to.fullPath } })        }      } else {        next()      }    }  } else {    //判断是否在白名单中    if (routesWhiteList.indexOf(to.path) !== -1) {      next()    } else {      //记录跳转到登录页之前的路由地址      next({ path: '/login', query: { redirect: to.fullPath }, replace: true })    }  }})router.afterEach((to) => {  //浏览器页签标题  document.title = getPageTitle(to.meta.title)})

在 vue-router4.x 版本中,往当前的路由router对象中动态添加路由时,需要使用官方提供的addRoute方法,不能直接对 router 对象进行修改,这是因为 vue-router 必须是要 vue 在实例化之前就挂载上去的。addRoute方法传入的参数是单个路由对象, 所以写法上我们需要对动态路由数组进行 for 循环,在内部调用addRoute方法,从而达到成功添加多个动态路由的目的。这里附上官方 API 地址链接:router.vuejs.org/zh/api/inte… 添加完路由之后,调用 next({ ...to, replace: true }), 就会成功跳转到目标路由地址了。

10. 登出

当用户要退出时,也就是登出,我们只需要调用登出接口,并在接口成功返回后,重置 vuex 中的用户信息(token, 角色 roles, 权限 permission),同时清空本地缓存的数据就行了。附上代码:

/**   * @description 退出登录   * @param {*} { dispatch }   */  Logout({ commit, dispatch, state }) {    return new Promise((resolve, reject) => {      logout()        .then(async () => {          await dispatch('resetAll')          //清空导航tab页签          this.dispatch('tagsBar/delAllVisitedRoutes', { root: true })          resolve()        })        .catch((error) => {          reject(error)        })        .finally(() => {})    })  }, /**   * @description 重置accessToken、roles、permission等   * @param {*} { commit, dispatch }   */  async resetAll({ dispatch, commit }) {    await dispatch('setAccessToken', '')    commit('SET_ROLES', [])    commit('SET_PERMISSION', [])    sessionStorage.clear()  },

到这一步,整个前端权限的流程就算是走完了。

  1. 按钮级权限

前端按钮级权限,是指在前端界面中,根据用户的权限不同,对不同的按钮进行权限控制。这样做的目的是为了确保系统的安全性和数据的保密性,使得不同用户只能执行其有权执行的操作,从而避免潜在的安全风险。 上面这段定义是我 copy 过来的,其中这句对不同的按钮进行权限控制并不完全对,除了按钮,比如页面中的某个字段,某个 div,某个组件要求根据当前用户的权限进行控制时,都可以称为按钮级权限。 像这种按钮级权限的设计方案很多,在 vue 中,本人目前知道的主流方式有两种:

  • 条件渲染(Conditional Rendering): 这是一种简单有效的方法,通过在前端代码中根据用户的权限信息来决定是否渲染特定的按钮或组件。比如,你可以使用条件语句(如v-ifngIf等)来判断用户是否有权限,从而决定是否渲染按钮。

  • 指令 / 组件封装: 使用一些前端框架(如 Vue、React、Angular)提供的自定义指令或组件,可以封装权限控制逻辑。你可以创建一个自定义指令或组件,接受用户权限作为输入,然后根据权限来决定是否显示按钮。 一开始我用的是指令封装的方式,但是随着业务需要,条件渲染这种方式我也用了。 在开始看具体的实现方法之前,不知道你有没有留意到上文的路由守卫中,只是根据用户的角色字段role来校验当前用户的权限,还有一个字段permssions(权限)其实一直没有派上用场,但是无论是在权限的配置项中,还是在后端的返回字段里,都有它的存在:

1. 指令 / 组件封装形式的按钮级权限

现在它的作用来了,先看指令的具体实现方式,我们在 src/directive 目录下新建一个hasPermi.js文件,代码如下:

/** * v-hasPermi 操作权限处理 */import store from '@/store'export default {   //`el`(element):这是指令所绑定的元素,是一个普通的DOM元素。在自定义指令的钩子函数中,//你可以通过操作`el`来实现对元素的各种修改,比如添加、删除、修改样式等。/** `binding`:这是一个包含指令信息的对象。它包括了以下属性:   `name`:指令的名称,不包括`v-`前缀。   `value`:指令的绑定值,即传递给指令的参数。在你的示例中,`value`存储了操作权限的标识符数组。   `oldValue`:先前的绑定值,仅在组件更新时可用。   `expression`:指令的表达式,如`v-my-directive="someValue"`中的`someValue`。   `arg`:指令的参数,如`v-my-directive:arg="value"`中的`arg`。   `modifiers`:一个包含修饰符的对象,如`v-my-directive.modifier`。   `vnode`:渲染该组件的虚拟节点。 */  mounted(el, binding, vnode) {      const { value } = binding    //all_permission代表所有权限,一般超级管理员需要用这个    const all_permission = '*:*:*'    //获取后端返回的权限字段列表    const permissions = store.getters && store.getters['user/permissions']    //校验当前用户的权限列表是否存在    if (value && value instanceof Array && value.length > 0) {       //传入的参数,需要校验的权限字段值      const permissionFlag = value      //判断当前权限列表中,有无该传入的权限字段值      const hasPermissions = permissions.some((permission) => {        return (          all_permission === permission || permissionFlag.includes(permission)        )      })            if (!hasPermissions) {      // 如果用户没有权限,移除当前元素的父节点(即移除当前元素)        el.parentNode && el.parentNode.removeChild(el)      }    } else {      throw new Error(`请设置操作权限标签值`)    }  },}

这段代码主要是在页面元素的挂载阶段(mounted钩子)检查用户权限,如果用户没有操作权限,那么就移除对应的页面元素。具体的代码说明我都放在里注释里。 写好了这个方法后,需要将该指令挂载到全局当中,这个时候需要进入main.js文件,在 vue3 中,给 vue 对象挂载自定义指令的方式如下:

import { createApp } from 'vue'import App from './App'import directive from '@/directive' // directiveconst app = createApp(App)app.mount('#app')// 添加到全局directive(app)

然后,在你需要进行按钮级权限校验的地方添加上这段指令,比如:

<a-button   type="primary"   @click="openModal('add')"   v-hasPermi="['system:user:add']"  >  新增  </a-button>

上面这段代码里的system:user:add意思是系统目录下用户管理模块的新增按钮权限(如果看不懂就翻到文章最开始介绍 RBAC 的权限字段设计那里),当后端返回的当前用户数据中的 permissions 字段里,没有system:user:add这个权限字段时,这个按钮就不会显示出来。

2. 条件渲染形式的按钮级权限

自定义指令实现的按钮级权限并不能满足我的业务需要,由于 vue 的限制,我们的自定义指令只能用在组件的template模块内,也就是只能放在 dom 标签上。但是有的时候,我可能需要对某个字段的逻辑,或者根据角色的不同,显示不同的表格列,这是数据上的处理,这个时候自定义指令就毫无用武之地。所以就需要用到条件渲染的形式。 条件渲染的形式,说白了,就是封装一个全局方法,方法返回值是true或者是false,然后再加一个v-if,但是我其实用不到这个v-if, 我只需要这个判断权限的返回结果就足够了,核心逻辑跟自定义指令如出一辙。就不再过多赘述,直接上代码你就懂了:

/** * checkPermi 操作权限处理 */import store from '@/store'export default function checkPermi(value) {  const all_permission = '*:*:*'  const permissions = store.getters && store.getters['user/permissions']  const permissionFlag = value  const hasPermissions = permissions.some((permission) => {    return all_permission === permission || permissionFlag.includes(permission)  })  return hasPermissions}

跟上面的自定义指令不能说完全一样,只能说是一模一样。然后,我们就需要把这个方法挂载到全局,这里的挂载方式是vue3的:

import { createApp } from 'vue'import App from './App'const app = createApp(App)app.config.globalProperties.$checkPermi = checkPermiapp.mount('#app')

然后在你需要用的地方引入:

...<script setup>  import {    ref,    reactive,    onMounted,    watch,    toRefs,    nextTick,    getCurrentInstance,  } from 'vue'    const { $checkPermi } =    getCurrentInstance().appContext.config.globalProperties    if ($checkPermi(['ProductionManagement:CustomOrderManagement:pro'])) {     //有这个按钮级权限时的处理    }else{     //没有这个按钮级权限时的处理    }</script>

怎么样,是不是很简单?

  1. 遇到的坑

这里面有几个坑需要注意下:

  1. 第一个坑就是在vue3支持的 vue-router4.0 版本之前,也就是vue2中,动态添加路由的方式支持的是addRoutes,它的参数是格式是一个数组。而到了 4.0 后,addRoutes这个方法被废弃,取而代之的是addRoute,它的参数则是一个路由对象。这两个方法无论是在传参类型还是添加相同 path 时的覆盖逻辑都不相同。

  2. 第二个坑就是无论是用控制台还是打断点的方式,或者是用 Vue.js devtools 的谷歌插件,都无法在路由守卫中获取到添加完成后的最新 router 对象,也就是说你会在调试 addRoute 加载后的动态路由表时,发现与之前为添加路由时的路由表是一样的,从而无法判断是否动态路由添加成功(就很蛋疼)

  3. 第三个坑是 next({...to, replace: true}) 这个方法,当添加好动态路由后,如果不这么写 next,会白屏。**在addRoute()之后第一次访问被添加的路由会白屏,这是因为刚刚addRoute()就立刻访问被添加的路由,然而此时addRoute()没有执行结束,因而找不到刚刚被添加的路由导致白屏。因此需要从新访问一次路由才行。**具体原因见文章链接:https://blog.csdn.net/qq_41912398/article/details/109231418

  4. 退出后,新的用户会访问到原本不属于该用户的路由,必须刷新页面才会正常。后来通过排查,导致这个问题的原因是因为当用户登出时,当前路由 router 对象还保留着上一个用户的动态路由,因次,还需要在登出的时候,对路由 router 对象进行初始化。我们在 src/router 目录下的index.js文件里写一个初始化方法:

//初始化路由export function resetRouter() {//获取当前路有对象  let routers = router.getRoutes()//剔除动态路由  routers.map((it) => {    if (!['login', '403', ':pathMatch(.*)*'].includes(it.name)) {      router.removeRoute(it.name)    }  })}

然后在登出的地方引用这个方法并执行

import { resetRouter } from '@/router/index.js' async logout() {        resetRouter()        await store.dispatch('user/Logout')        this.$router.push('/login')      },
  1. 还有一个关于 vuex 的问题,从之前的代码里不难看出,我们将用户信息,角色、权限、动态路由等都存在了 vuex 中。由于 vuex 的特性,用户会在刷新页面后,vuex 里的数据会被清空,这个时候就会导致页面直接跳转到登录页。这肯定是不能接受的,因此,我们需要对 vuex 里的数据在刷新的时候做持久化处理。逻辑也很简单,监听页面刷新事件,beforeunload这个方法恰好就能做到,在页面刷新的时候,将 vuex 中的数据缓存到浏览器本地临时缓存中,然后再在页面初始化的时候,从本地临时缓存中取出存入 vuex 对象中。 附上代码:(项目根目录下的的 App.vue 文件中, 这里是 vue2 的写法)
created() {      //在页面加载时读取sessionStorage里的状态信息      sessionStorage.getItem('userMsg') &&        this.$store.replaceState(          Object.assign(            this.$store.state,            JSON.parse(sessionStorage.getItem('userMsg'))          )        )      //在页面刷新时将vuex里的信息保存到sessionStorage里      window.addEventListener('beforeunload', () => {        sessionStorage.setItem('userMsg', JSON.stringify(this.$store.state))      })    },

这里再附上 src/router 目录下的index.js代码,方便大家理解路由这块:

import { createWebHistory, createRouter, useRouter } from 'vue-router'import { constantRouterMap } from '@/config/router.config.js'import { generator } from '@/router/generatorRouters'const router = createRouter({  history: createWebHistory(),  routes: constantRouterMap,  scrollBehavior(to, from, savedPosition) {    if (savedPosition) {      return savedPosition    } else {      return { top: 0 }    }  },})//这里是我为了刷新的时候不调取获取路由信息的接口,做的本地缓存,因为刷新一次就要获取一遍用户信息和//动态路由表信息太影响用户体验了,尤其是在网速差的情况下。没这方面需求的可以不用管let asyncRouterMap = []function handleLocalRouter() {  if (sessionStorage.getItem('generateRoutes')) {    asyncRouterMap = asyncRouterMap.concat(      JSON.parse(sessionStorage.getItem('generateRoutes'))    )    const generatedRoutes = generator(asyncRouterMap)    generatedRoutes.push({      path: '/:pathMatch(.*)*',      redirect: '/404',      hidden: true,    })    generatedRoutes.forEach((route) => {      router.addRoute(route)    })  }}//初始化路由export function resetRouter() {  let routers = router.getRoutes()  routers.map((it) => {    if (!['login', '403', ':pathMatch(.*)*'].includes(it.name)) {      router.removeRoute(it.name)    }  })}handleLocalRouter()export default router
  1. 结束与优化

好,至此,整个 RBAC 的前端权限方案设计到实现就已经宣告完成,其实还有很多需要优化的地方,比如把用户路由表信息缓存到本地临时缓存中,这样用户每次刷新页面的时候就不会因为 vuex 的特性需要再去请求一边接口。(但是老实说,我觉得这个优化方案还是不够好,因为将路由表存在本地缓存中就已经背离了安全性这一块,但是在网络较差的情况下,刷新就要调取角色信息接口,调取获取私有路由的接口,再到构建新的完整路由这个过程期间,页面会处于白屏状态,非常影响用户体验。因此,只能取此下策。到底要不要缓存,看个人的想法吧。) 还比如

await store.dispatch('user/GetInfo')           await store.dispatch('async-router/GenerateRoutes')           let accessRoutes = store.getters['async-router/addRouters']           accessRoutes.forEach((route) => {            //用来判断是否是外链            if (!isHttp(route.path)) {              router.addRoute(route) // 动态添加可访问路由表            }          })

这段获取用户信息 -> 构建路由 -> 添加动态路由的代码可以抽离出来,单独封装。 还可以根据当前环境是否是development来判断是否是开发环境,从而准备另外一套前端开发场景下使用的完整路由表,这样,就可以在不依赖后端的情况下,进行前端开发。 另外还有侧边导航栏的处理,这个我没讲,一方面是相对简单,我们获取到格式化之后的动态路由数据的同时,其实也就是获取到了导航栏的菜单信息。 另一方面,每个人的项目页面布局方式不一样,导航栏的设计也会不一样。在此基础上,针对性的对数据进行修饰和改造即可。

作者:守夜人 x

https://juejin.cn/post/7259210874446692411、