乐趣区

基于Vue实现后台系统权限控制

需求分析

基础需求

项目的基础需求是:

  • 系统管理员 拥有最大权限,管理所有企业;
  • 企业管理员 拥有该企业所有权限,可下放权限给子账户;
  • 子账户 拥有限制权限,且可被随时更改。
  • 根据权限列表展示隐藏相对应菜单栏、按钮等。

我们在登录后,将获取到的用户权限保存到本地缓存中,以便每次方便获取权限,判断权限。

但由于“子账户拥有限制权限,且可被随时更改”这条需求,我们的项目变得稍稍棘手一点:

  • 权限变少 :当用户点击到 原本有权限但修改后没有权限 的相关请求,后端会给出相对应的状态码供前端判断,此时前端可以根据状态码做出相对应的反馈。
  • 权限变多:每个请求都是通的,后端无法反馈,前端也无从得知权限什么时候变多了。我们要求用户退出重新登录,获取最新的权限树。

进阶需求

然而,我们的用户特别懒,产品也特别惯着他们,他们要求——“界面刷新,就获取最新的权限列表”。

看来原来的计划得改了,获取到的用户权限保存到 vuex 的状态管理器 store 中,刷新的时候,重新异步获取一次权限,再判断权限。

路由结构

根据项目需求,路由结构如下:

routes: [
    {
      path: '/login',
      component: Login,
      name: 'login'
    },
    {
      path: '/enterprise',
      component: Enterprise,
      name: 'Enterprise',
      children: getRouter(routerConfig)
    },
    {
      path: '*',
      redirect: {name: 'login'}
  ]

项目是多页面项目,在同一个域名下访问,系统管理员登录后以 /admin 为前缀,企业账户登录后以 /enterprise 为前缀,企业系统做权限控制,只列出企业系统部分路由大纲。

流程构思

从输入地址到页面展示,权限控制流程构思如下:

逻辑实现

为了将路由与权限控制分离,我将权限管理单独抽出来一个类。

因此核心逻辑大致可以分为:

  • 状态管理部分:处理权限树、存储权限
  • 权限管理类:判断是否登录、判断是否有权限
  • 权限指令:控制页面的展示

状态管理部分

从后端获取的权限树格式大致长这样:

{
    "data": [
  {
      "title": "A 模块",
      "name": "module_A",
      "children": [
        {
          "title": "A 模块 -1",
          "name": "module_A_1",
          "children": [
            {
              "title": "A 模块 -1- 新增",
              "name": "A_1_add",
              "children": []},
            {
              "title": "A 模块 -1- 编辑",
              "name": "A_1_edit",
              "children": []}
          ]
        }
      ]
  },
  {
    "title": "B 模块",
    "name": "module_B",
    "children": [
      {
        "title": "B 模块 - 删除",
        "name": "B_del",
        "children": []}
    ]
  }
]
}

我们需要将权限树扁平化为以下形式,方便判断。
这个列表存储为 store 中的 permissions,后期的判断权限都以它为依据。

{
  "module_A": {
      "allow": true,
    "redirect": 'module_A_1'
  },
  "module_A_1": {
      "allow": true,
    "redirect": ''},"A_1_add": {"allow": true,"redirect":''},
  "A_1_edit": {
      "allow": true,
    "redirect": ''},"module_B": {"allow": true,"redirect":''},
  "B_del": {
      "allow": true,
    "redirect": ''
  },
  // ..
}

要提一嘴的是,redirect 用于重定向,默认是没有的,但是对于有子层级的路由需要设置跳转。比如 A 模块下面还有 4 个子模块,但是用户只拥有 A 模块下的 2 个模块 A-1、A-2,redirect 设置为 2 个模块中的 A - 1 模块。

权限管理类

在权限管理之前,在 router 需要做额外的配置:

一个页面可能被两种权限获取,如编辑页和新增页,因此我们用数组包含权限。

{
    path: 'module_A',
    component: /***/,
    name: 'module_A',
    meta: {authority: ['module_A']
    },
    children: [
        {
          path: 'A-1',
          component: /***/,
          name: 'module_A_1',
          meta: {authority: ['module_A']
          }
            },
         {
          path: 'A-1/:id',  
          component: /***/,
          name: 'module_A_1-id',
          meta: {authority: ['A_1_add', 'A_1_edit'] /** 编辑页的 authority **/
          }
        }
   ]
}

权限管理类大致框架如下:

class AuthControl {constructor () {this.permissions = {} // 权限列表
    this.authMenus = []  // 菜单}
  
  // 登录页
  routerLogin = {name: 'login'}

  // 计算有权限的第一个页面
    get routerHome () {
      // ...
    return {name: key}
  }

  // 登录页路由守卫
  async loginGuard (to, from, next) {// ...}
    
  // 企业系统路由守卫
  async EnterpriseGuard (to, from, next) {// ...}

    // 异步获取权限
    async getPermissions () {}

    _loginCheck () {}

    _permissionCheck (to) {}}

router.js 调用这个类:

const control = new AuthControl()

router.beforeEach(async (to, from, next) => {if (to.matched[0].path === 'login') {await control.loginGuard(to, from, next)
  }
  if (to.matched[0].path === '/enterprise') {await control.EnterpriseGuard(to, from, next)
  }
  next()})

登录页路由守卫

登录的守卫的调用是判断登录页后进行的守卫。

基本逻辑就是:已登录 => 进入登录后的第一个页面;未登录 => 调用 next() 进入。

  async loginGuard (to, from, next) {const loginStatus = this._loginCheck()

    if (loginStatus) {await this.getPermissions()
      next(this.routerHome)
      return
    }
    
    next()}

企业系统路由守卫

将登录页守卫与企业系统守卫分开就是因为,企业系统守卫逻辑较复杂,如果所有情况都调用同一个守卫,这边考虑的会比较多,分开会清晰一些。
所以企业路由守卫是判断要进入的地址是企业内容相关页面。

逻辑大致分为 3 块:

  • 是否登录
  • 该页面是否拥有权限
  • 该页面是否需要重定向
async EnterpriseGuard (to, from, next) {
    // 是否登录
    const loginStatus = this._loginCheck()
    if (!loginStatus) {next(this.routerLogin)
      return
    }
    
    // 该页面是否拥有权限
    const permissionStatus = await this._permissionCheck(to)
    if (!permissionStatus) {next(this.routerHome)
      return
    }

      // 该页面是否需要重定向
    const authority = to.meta.authority[0]
    const redirect = this.permissions[authority].redirect
    if (redirect) {
      next({name: redirect})
      return
    }

    next()}

权限检查

对于 _loginCheck  各家方法不太一致,不赘述。

对于 _permissionCheck,主要根据前文在路由中的配置判断:

 _permissionCheck (to) {
    const {
      meta: {authority = []
      }
    } = to

    return this.getPermissions()
      .then(permissions => {const allow = authority.some(d => permissions[d])
        return allow
      })
      .catch((err) => {console.info(err)
        return false
      })
 }

权限指令

对于按钮权限,会有两种可能:

  1. 隐藏不可见 hidden
  2. 禁用不可点 disabled

我的初步想法是,对于无权限的按钮,设置属性的值 —— disabledhidden,这样可以不去修改 DOM。

const permission = {bind (el, binding) {const { effect = 'disabled', name: authority} = binding.value
    if (!Store.getters['permissions'][authority]) {el[effect] = true
    }
  }
}

/**
* hidden 另外需设置样式:[hidden] {
    visibility: hidden;
    display: none;
}
.el-button[hidden] + .el-button {margin-left: 0;}
.el-button[hidden] ~ .el-button {margin-left: 14px;}
*/
 <!-- 隐藏效果 -->
<el-button v-permission="{name:'A_1_add', effect:'hidden'}">
  新建
</el-button>

<!-- 置灰效果 -->
<el-button v-permission="{name:'A_1_edit', effect:'disabled'}">
  编辑
</el-button>

但是还是发现样式会有点影响,比如按钮外部包裹的 div 设置了 padding 值的情况,当按钮全部没有的时候,margin 值会撑起一段空白。

无奈,对于隐藏样式,还是采用移除节点的方式:

export const permission = {bind (el, binding) {const { effect = 'disabled', name: authority} = binding.value
    if (!Store.getters['permissions'][authority]) {if (effect === 'hidden') {el.parentNode.removeChild(el)
      }
      el[effect] = true
    }
  }
}

后记

事实上,我们的需求并没有结束。

由于系统管理员拥有最大权限,管理所有企业,他可以在系统管理员平台代理登录并跳转任意多个企业并拥有该企业的所有权限。但由于权限管理类单独抽象出来,处理起来也没有那么难。

比如 _loginCheck 方法,新增对系统管理员登录的兼容,routerHome 的计算,进入未代理过的企业则返回系统管理员的主页,等等。

退出移动版