vue-element-admin是如何实现动态角色分配的

320 阅读7分钟

今天我来向你介绍一下 vue-element-admin 如何实现动态角色分配。vue-element-admin 是一个后台前端解决方案,基于 vueelement-ui 实现。它使用了最新的前端技术栈,含有动态路由、权限验证等企业级管理后台项目的基本功能元素,你可以在文中点击 下载 进行使用。

本节课,我将会带你一起梳理登录逻辑过程中的静态路由与动态路由,熟悉角色分配与管理,对 vue-element-admin 源码进行修改,以实现动态角色分配。

那么,现在我们先来看一下最终实现效果图。下图是创建一个老师角色,并授补打卡权限的操作。随后我们要给指定用户授予老师权限,例如:给光城授予老师权限。

我们此时可以通知该用户已经拥有补打卡权限了,当该用户登录后台便可以看到如下图所示的页面权限。

请注意,现在截图打开的是默认首页,这个页面是个 demo,补打卡页面如图中箭头指示,这里没有点开。

也就是说,最后,你在完成学习后,应该可以在后台动态地创建任意角色,并让其只看到授权的页面。

接下来,我们一起学习下这个开源项目。

vue-element-admin 的登录逻辑与路由逻辑

关于 vue-element-admin 实现动态角色分配,我们需要先梳理出 vue-element-admin 的登录逻辑与路由逻辑,只有明白这两项内容,才可以彻底改造 vue-element-admin 在动态路由方面的不足,以实现动态角色分配。

因此,我们需要先回答下面这两个问题。

登录逻辑

我先来带你看看用户登录。

登录页面在 views/login/index.vue 文件中,这时候可以看到页面布局使用了 element-ui 相应的组件。

  • el-form,包含 username 与 password。model 为 loginForm,rules 为 loginRules。
  • el-tooltip,当用户使用 password 时,控制一些操作,例如:用户大写时,会提示已开启大写。
  • el-input,输入框,里面含有主要属性,例如:
// 键盘按键时绑定 checkCapslock 事件
@keyup.native="checkCapslock"
// 监听键盘 enter 按下后的事件
@keyup.enter.native="handleLogin"
  • el-button,按钮,点击时会调用 handleLogin 方法,并触发 loading 效果。

上述组件中,handleLogin 来实现登录动作,其他控制账户、密码输入等动作,鉴于我们比较关心登录逻辑,我们来直接看 handleLogin 做了什么?

handleLogin 中首先调用了 el-form 的 validate 方法对 rules 进行验证,如果验证通过,调用 vuex 的 user/login action 进行登录验证,当验证通过会重定向到 redirect 路由,如果 redirect 路由不存在,则直接重定向到 / 路由。

这张图是对于上述逻辑的直观体现:

你也可以直接看这段代码:

handleLogin() {
  this.$refs.loginForm.validate((valid) => {
    if (valid) {
      this.loading = true
      this.$store
        .dispatch('user/login', this.loginForm)
        .then(() => {
          this.$router.push({
            path: this.redirect || '/',
            query: this.otherQuery
          })
          this.loading = false
        })
        .catch(() => {
          this.loading = false
        })
    } else {
      return false
    }
  })
}

不过,你以为到这里就结束了吗?还没,上述只是完整的登录流程,里面却包含了例如 action 登录验证等非常复杂的东西,接下来我们来看看它的实现。

user/login 对应的 action 源码在 store/modules/user.js 文件中。

在这里请求逻辑为:调用 login api,传递用户名与密码进行 post 请求,请求成功从 response 中取 token,将 token 保存到 Cookie 中,返回。如果请求失败,调用 reject 方法。

具体流程如图所示:

const actions = {
  // user login
  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ username: username.trim(), password: password }).then(response => {
        const { data } = response
        commit('SET_TOKEN', data.token)
        setToken(data.token)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },
  // other op
  // ... ... ...
}

login api 在 api/user.js 文件中,这里使用 request 方法,它是对 axios 库的封装,默认情况下,从 GitHub 中下载的源码采用的是 mock 数据,存在于 mock/user.js 中。

// login api
export function login(data) {
  return request({
    url: '/user/login',
    method: 'post',
    data
  })
}

后端返回的 response 格式如下:

{
  "code":0,
  "msg":"登录成功",
  "data":{
    "token":"your token"
  }
}

至此,登录的一部分流程完毕。为啥是一部分呢,因为还有路由。

总结一下, handleLogin 中首先调用了 el-form 的 validate 方法对 rules 进行验证,如果验证通过,调用 vuex 的 user/login action 进行登录验证,当验证通过会重定向到 redirect 路由,如果 redirect 路由不存在,则直接重定向到 / 路由。

action 中会调用 login api,传递用户名与密码进行 post 请求,请求成功从 response 中取 token,将 token 保存到 Cookie 中,返回。如果请求失败,调用 reject 方法。

动态路由逻辑

让不同的用户角色看不同的页面效果,便是我们的目标,也就是达成动态角色分配的目的。而不同页面便是路由,也就是说让不同用户配置不同的路由便实现了动态角色分配。因此,下面我们来讲讲路由。

第一步:在 main.js 中加载了全局路由守卫。

import './permission'

第二步:permission 定义了全局路由守卫。

对应文件为 src/permission.js。

router.beforeEach(async(to, from, next) => {
}
router.afterEach(() => {
})

在程序运行时,会去调用 beforeEach,我们来看看具体实现:

在该代码中首先开启加载进度条,获取页面标题,拿到刚才的登录 token,这个 getToken 函数是在 util/auth.js 文件中,直接是从 Cookie 中拿,进一步验证了上述登录逻辑的准确性。

有了 token 之后,就可以搞事了,具体分为有 token 与无 token。

  • 有 token
    • 访问 /login,重定向到/;
    • 访问 /login?redirect=/xxx:重定向到 /xxx
    • 访问 /login 以外的路由:直接访问 /xxx
  • 无 token:
    • 访问 /login:直接访问 /login;
    • 访问 /login 以外的路由,例如访问 /dashboard,实际访问路径为 /login?redirect=%2Fdashboard,登录后会直接重定向 /dashboard

在访问页面同时,还会去拿到用户角色,如果该用户有相应角色就直接登录,否则会去拿用户信息,调用的是 user/getInfo action,根据角色拿到该角色对应的动态路由,调用的是 permission/generateRoutes action。

const whiteList = ['/login', '/auth-redirect'] // no redirect whitelist
router.beforeEach(async (to, from, next) => {
  // start progress bar
  NProgress.start()
  // set page title
  document.title = getPageTitle(to.meta.title)
  // determine whether the user has logged in
  const hasToken = getToken()
  if (hasToken) {
    if (to.path === '/login') {
      // if is logged in, redirect to the home page
      next({ path: '/' })
      NProgress.done() // hack: https://github.com/PanJiaChen/vue-element-admin/pull/2939
    } else {
      // determine whether the user has obtained his permission roles through getInfo
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        next()
      } else {
        try {
          // get user info
          // note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
          const { roles } = await store.dispatch('user/getInfo')
          // generate accessible routes map based on roles
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
          // dynamically add accessible routes
          router.addRoutes(accessRoutes)
          // hack method to ensure that addRoutes is complete
          // set the replace: true, so the navigation will not leave a history record
          next({ ...to, replace: true })
        } catch (error) {
          // remove token and go to login page to re-login
          await store.dispatch('user/resetToken')
          Message.error(error || 'Has Error')
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      }
    }
  } else {
    /* has no token*/
    if (whiteList.indexOf(to.path) !== -1) {
      // in the free login whitelist, go directly
      next()
    } else {
      // other pages that do not have permission to access are redirected to the login page.
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

接下来,我们来看上述的两个 action。

1)getInfo

位置在 store/modules/user.js 文件,对应代码如下,在 getInfo 中根据拿到的 token 去后台获取对应的用户信息,调用接口为 getInfo 接口,被定义在 src/api/user.js 文件中,同前面 login 获取 token 一样拿的是 mock 数据。

// get user info
getInfo({ commit, state }) {
  return new Promise((resolve, reject) => {
    getInfo(state.token).then(response => {
      const { data } = response
      if (!data) {
        reject('Verification failed, please Login again.')
      }
      const { roles, name, avatar, introduction } = data
      // roles must be a non-empty array
      if (!roles || roles.length <= 0) {
        reject('getInfo: roles must be a non-null array!')
      }
      commit('SET_ROLES', roles)
      commit('SET_NAME', name)
      commit('SET_AVATAR', avatar)
      commit('SET_INTRODUCTION', introduction)
      resolve(data)
    }).catch(error => {
      reject(error)
    })
  })
}

请求后端的 api 接口:

export function getInfo() {
  return request({
    url: '/user/info',
    method: 'get'
  })
}

后端返回数据格式:

{
	roles: ['admin'],
	introduction: 'I am a super administrator',
	avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
	name: 'Super Admin'
}

2)generateRoutes

generateRoutes 位于 store/modules/permission.js 文件里面,具体逻辑为:从@router 中读取 asyncRoutes 和 constantRoutes,获取用户角色 roles,根据 roles 是否包含 admin 进行处理,如果不含有 admin 要多做一次处理,随后统一保存过滤后的 asyncRoutes 到 vuex 中,并将其与 constantRoutes 进行合并。

import { asyncRoutes, constantRoutes } from '@/router'
generateRoutes({ commit }, roles) {
// 返回 Promise 对象
return new Promise(resolve => {
  let accessedRoutes
  if (roles.includes('admin')) {
    // 如果角色含 admin,直接将 asyncRoutes 全部返回
    accessedRoutes = asyncRoutes || []
  } else {
    // 如果角色中没有包含 admin,则调用 filterAsyncRoutes 过滤路由
    accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
  }
  // 将路由保存到 vuex 中
  commit('SET_ROUTES', accessedRoutes)
  resolve(accessedRoutes)
}
// SET_ROUTES源码
SET_ROUTES: (state, routes) => {
  // 将 routes 保存到 state 中的 addRoutes
  state.addRoutes = routes
  // 将 routes 集成到 src/router/index.js 的 constantRoutes 中
  state.routes = constantRoutes.concat(routes)
}

这里便引出了三个疑惑:

第一点:constantRoutes 与 asyncRoutes 区别?

第二点:角色配置在 Routes 中如何体现?

第三点:filterAsyncRoutes 做了什么事?

先来回答第一点,constantRoutes 与 asynRoutes 都定义在 src/router/index.js 文件中,这两个 Routes 语法上没太大区别,大概如下。

从使用上来说 asyncRoutes 定义的是特殊用户权限路由,例如:你要将某个页面设置为 editor 权限,那么可以写在 asyncRoutes 中,constantRoutes 中存储的一般是默认加载的路由,一般是针对所有用户的路由,诸如:404、401、login 等页面。

export const asyncRoutes = [
  {
    path: '/permission',
    component: Layout,
    redirect: '/permission',
    alwaysShow: true,
    name: '权限',
    meta: {
      title: '权限',
      icon: 'lock',
      roles: ['admin']
    },
    children: [
      {
        path: 'role',
        component: () => import('@/views/permission/role'),
        name: '角色权限',
        meta: {
          title: '角色权限',
          roles: ['admin']
        }
      }
    ]
	}
]

接着,在第一个问题回答中,我们知道可以配置某个路由为某个角色使用,用哪个参数呢?这便是回答第二个问题,在上述 meta 中有 roles 参数,是个数组,里面可以配置多个角色,将角色添加到对应的路由里面即可实现该路由被配置的角色列表可见。

最后,第三个问题,我们来看看图片展示的逻辑与源码实现:

首先是去遍历 routes,检查是否具有路由访问权限,如果是,检查路由是否含有 children,遍历每个 children,递归过滤,更新 tmp.children,最后,将路由存入 res 中。

export function filterAsyncRoutes(routes, roles) {
  const res = []
  // 遍历全部路由
  routes.forEach(route => {
    // 浅拷贝
    const tmp = { ...route }
    // 检查权限
    if (hasPermission(roles, tmp)) {
      // 是否具有children
      if (tmp.children) {
        // 递归调用
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      // 当路由具有访问权限时,将 tmp 保存到 res 中
      res.push(tmp)
    }
  })
  return res
}
// 检查权限方法
function hasPermission(roles, route) {
  // 检查是否含有meta与roles字段
  if (route.meta && route.meta.roles) {
    // 判断 route.meta.roles 中是否包含用户角色 roles 中的任何一个权限
    return roles.some(role => route.meta.roles.includes(role))
  } else {
    // 没有权限控制 所有用户可见
    return true
  }
}

动态角色分配问题的解决方式

通过前面的讲解中,我们了解了登录逻辑与动态路由逻辑。可是在 vue-element-ui 处理动态路由与静态路由时,数据是写死在 route/index.js 中的,总不能每次都来修改 asyncRoutes,然后发布打包上线,这也太麻烦了。

因此,我们需要使这一环节变成动态,从而能够在完成角色的创建的同时,给定相应的菜单(路由),最后能够给某个用户设置该角色。这个问题便是本节引入的动态角色分配问题的原因。

要解决这个问题,这里采用的方法是将数据存储到数据库中,这里以 JSON 数据库为例,返回数据格式:

{
	"path": "/fillcard",
	"redirect": "/fillcard",
	"name": "打卡管理",
	"meta": {
		"title": "补打卡",
		"icon": "form",
		"roles": ["admin"]
	},
	"children": [{
		"path": "fillcard",
		"name": "补打卡",
		"meta": {
			"title": "补打卡",
			"roles": ["admin"]
		},
		"component_str": "/fillcard/index"
	}],
	"to_roles": "admin",
	"description": "超级管理员,可以访问所有页面",
	"component_str": "Layout"
}

这里你需要注意的是,存储的时候 component 是以 str 存储,构建的时候则需要使用组件形式,所以在实现时记得写上转换函数即可。

有了这样的数据,我们要做的便是替换上述的 routes,可以看到在下面代码中添加了两行代码即可完成这项功能。

添加的第一行调用 getAsyncRoutes 接口,这里是通过 post 请求从后台拿到诸如上述格式的数据,添加的第二行便是转换函数,完成组件从 str 到 Component 对象的重建工作。

generateRoutes({ commit }, roles) {
  return new Promise(async resolve => {
    var asyncRoutes = getAsyncRoutes()  // 调用 getAsyncRoutes 接口
    let accessedRoutes
    if (roles.includes('admin')) {
      accessedRoutes = asyncRoutes || []
    } else {
      accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
    }
    accessedRoutes = filterAsyncRouter(accessedRoutes)  // 转换函数
    commit('SET_ROUTES', accessedRoutes)
    resolve(accessedRoutes)
  })
}

至此,我们便可以动态给予不同角色分配不同的路由,在登录的时候,不同用户看到的菜单也便不一样了,完美收工。

总结

咱们今天这节课先讲了 vue-element-admin 的基础理论与我们的最终目标,随后引出登录逻辑与动态路由逻辑,最终基于这两个逻辑以及现有 vue-element-admin 所存在的问题,我们引出动态角色分配解决方案。