提示
想看完整的 权限管理系统 实现,可以查看个人项目:vue3-ting-admin (opens new window)
# 一、RBAC 权限设计
# 1、基本概述
RBAC (Role-Based Access Control) 是一种基于角色的访问控制策略。
简单地说,一个用户拥有多个角色,一个角色拥有多个权限。管理员只需要管理角色的权限,而不必直接管理每个用户的权限
这样,就构造成 “用户 -> 角色 -> 权限” 的授权模型。
从模块角度来讲,可以分为以下 RBAC 模块:
系统管理
- 用户管理
- 部门管理 *
- 角色管理
- 菜单管理
# 2、数据库设计
在 RBAC 模型中,用户与角色之间、角色与权限之间,通常都是多对多的关系。
上述只是一个简单的数据库设计思路(具体的话会根据不同的业务变得复杂)。
# 3、数据交互
下面简单说一下流程:
方式 1(精准版):
- 前端发起用户登录请求 ——> 后端返回该用户的 token、userID、用户信息 等
- 前端可以通过 userID + token 获取角色信息请求 ——> 后端返回该用户的 详细信息(包含 roleID) 等
- 前端可以通过 roleID + token 获取菜单信息请求 ——> 后端返回该用户的 菜单树(扁平、树形) 等
方式 2(暴力版):
- 前端发起用户登录请求 ——> 后端返回该用户的 token、userID、roleID、用户信息、菜单树 等
# 4、动态路由方案
# ① 基于角色
- 后端:只提供角色 role 信息即可
- 前端:根据 role 匹配指定的 静态路由组合即可
为了防止产生大量的重复数据,通常采用在路由的 mate 字段上加角色数组(例如:vue-element-admin (opens new window))
基于角色的动态路由方案并不能实现真正意义上的动态,只是前端的固有设置罢了。
代码实现
- 后端
根据 userId 获取用户的详细信息
{
"code": 0,
"data": {
"id": 1,
"name": "admin",
"realname": null,
"cellphone": null,
"avatarUrl": "http://xxxx.com/users/1/avatar/a46eaecc31a843842c2bdf605b42ae8b",
"role": ["super-admin"], // 这里!!!
"token": "Bearer eyJhbGciOiJIUzI1…"
}
}
- 前端
菜单和路由的生成,由前端自己在 meta 字段定义所需数据即可:
title、icon 进行视图渲染,roles 进行路由匹配(具体实现可以参考:vue-element-admin (opens new window))
export default {
path: '/power',
component: Layout, // 保持Layout架子渲染
alwaysShow: true, // 当children只有一个时,显示根路由
meta: { title: '权限管理', icon: 'form', roles: ['super-admin', 'root'] },
children: [
{
path: 'userControl',
component: () => import('@/views/power/userControl'),
meta: { title: '用户管理', icon: 'user', roles: ['super-admin', 'root'] }
},
{
path: 'roleControl',
component: () => import('@/views/power/roleControl'),
meta: { title: '角色管理', icon: 'nested', roles: ['super-admin', 'root'] }
},
{
path: 'menuControl',
component: () => import('@/views/power/menuControl'),
meta: { title: '菜单管理', icon: 'example', roles: ['super-admin'] }
}
]
}
# ② 基于菜单
- 方案 1:后端同时提供 path 和 component,
前端需要将数据强行转换为路由结构,但麻烦是的 component: () => import()
组件的路径生成,并且组件名由后端提供,前端开发受阻。
代码实现(强制转换 ✖)
{
"rights": [
{
"id": 101,
"name": "商品中心",
"icon": "icon-goods",
"path": "goods",
"component": "goods",
"children": [
{
"id": 104,
"name": "商品类别",
"path": "category",
"component": "category",
"rights": ["view", "edit", "add", "delete"]
}
]
}
]
}
// 1、菜单对象转路由对象
const menuToRoute = (item, menu) => {
let route = {
name: menu.name, // 商品列表
path: `/${item.component}/${menu.component}` // "/goods/category"
component: () => import(`@/views/${item.component}/${menu.component}.vue`)
meta: {
rights: menu.rights // 利用路由元信息附加上权限信息(备用👏)
}
}
return route
}
// 2、动态绑定路由
// 根据用户权限,设置动态路由规则
export function initDynamicRoutes() {
console.log(router)
// 路由中定义的【基础路由】(数组✨)
const currentRoutes = router.options.routes
// 服务器返回的数据(json数据)
const rightList = $store.state.rightList
rightList.forEach((item) => {
// 获取其中children的【二级路由】(数组✨)
item.children.forEach((menu) => {
const temp1 = menuToRoute(item, menu) // 动态添加二级路由🚩
currentRoutes[0].children.push(temp1) // 统一放在默认的main路由下
})
})
// 正式重置路由
currentRoutes.forEach((item) => {
router.addRoutes(item)
})
}
- 方案 2:后端只提供 path
手动映射(path 映射 Route ✔)
{
"rights": [
{
"id": 101,
"name": "商品中心",
"icon": "icon-goods",
"path": "goods",
// "component": "goods",
"children": [
{
"id": 104,
"name": "商品类别",
"path": "category",
// "component": "category",
"rights": ["view", "edit", "add", "delete"]
}
]
}
]
}
// 优点:组件命名自由了
// 缺点:每一个都得手动映射
const goods = {
categories: { path: '/goods/categories', component: Categories }
// ……
}
const system = {
user: { path: '/system/user', component: User }
// ……
}
// 映射
const ruleMapping = {
categories: goods.categories
user: system.user
}
// 根据用户权限,设置动态路由规则
export function initDynamicRoutes() {
console.log(router)
// 路由中定义的【基础路由】(数组✨)
const currentRoutes = router.options.routes
// 服务器返回的数据(json数据)
const rightList = $store.state.rightList
rightList.forEach((item) => {
// 获取其中children的【二级路由】(数组✨)
item.children.forEach((menu) => {
const temp1 = ruleMapping[menu.path] // 动态添加二级路由🚩
temp1.meta = menu.rights // 利用路由元信息附加上权限信息(备用👏)
currentRoutes[0].children.push(temp1) // 统一放在默认的main路由下
})
})
// 正式重置路由
currentRoutes.forEach((item) => {
router.addRoutes(item)
})
}
自动映射(path 映射 Route ✔)
这个就是我采用的最终方案,下面的教程采用的就这个方案。
提前准备好静态路由,并提前和后端沟通好,保持前端本地的路由 path 值,和后端传过来的 path 属性值一致。
# 二、菜单数据
# 1、菜单树
菜单数据中必须有下面几个基础属性,必须和后端沟通好:
nav-aside 视图渲染 | main-contain 路由匹配 |
---|---|
标识 id | 路由地址 url |
图标 icon | |
名称 name |
下面给出菜单数据的示例结构:
如果不是树形结构,我们自己根据 parentId 结合递归转换为树结构即可。
菜单数据
{
"code": 0,
"data": [
{
"id": 4,
"icon": "setting",
"name": "系统管理",
"url": "/main/system",
"sort": 2,
"type": 1,
"createAt": "2022-08-14 19:50:32.000000",
"parentId": null,
"updateAt": "2022-08-14 19:50:32.000000",
"permission": null,
"children": [
{
"id": 5,
"icon": null,
"name": "用户管理",
"url": "/main/system/user",
"sort": 1,
"type": 2,
"createAt": "2022-08-14 19:51:41.000000",
"parentId": 4,
"updateAt": "2022-08-14 20:45:07.000000",
"permission": null,
"children": [
{
"id": 6,
"url": null,
"icon": null,
"name": "创建用户",
"sort": 1,
"type": 3,
"createAt": "2022-08-14 19:53:30.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:49:34.000000",
"permission": "system:user:create"
},
{
"id": 7,
"url": null,
"icon": null,
"name": "删除用户",
"sort": 2,
"type": 3,
"createAt": "2022-08-14 19:57:35.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:49:37.000000",
"permission": "system:user:delete"
},
{
"id": 8,
"url": null,
"icon": null,
"name": "修改用户",
"sort": 3,
"type": 3,
"createAt": "2022-08-14 19:58:40.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:49:48.000000",
"permission": "system:user:update"
},
{
"id": 9,
"url": null,
"icon": null,
"name": "查询用户",
"sort": 4,
"type": 3,
"createAt": "2022-08-14 20:03:53.000000",
"parentId": 5,
"updateAt": "2022-08-14 20:50:13.000000",
"permission": "system:user:query"
}
]
}
]
}
]
}
# 2、视图渲染 ✨
我们可以通过上面的菜单数据的 id、icon、name 属性,先渲染右侧菜单栏视图。
下列的菜单栏设计:必须有子菜单,且只有一个。(可以根据项目需求进一步优化)
菜单栏展示
<template>
<div class="nav-aside">
<div class="logo">
<img src="@/assets/imgs/title.jpg" alt="" />
<h3 class="title" v-if="!isCollapse">权限管理系统</h3>
</div>
<div class="menu">
<el-menu
:default-active="defaultMenuItemShow"
:collapse-transition="false"
:collapse="isCollapse"
>
<!-- 一级 -->
<template v-for="item in userRoleMenu" :key="item.id">
<el-sub-menu :index="item.id + ''">
<template #title>
<el-icon>
<!-- 动态图标组件 -->
<component :is="item.icon" />
</el-icon>
<span>{{ item.name }}</span>
</template>
<!-- 二级 -->
<template v-for="subItem in item.children" :key="subItem.id">
<el-menu-item :index="subItem.id + ''" @click="handleMenuItemClick(subItem)">
<el-icon>
<!-- 动态图标组件 -->
<component :is="subItem.icon" />
</el-icon>
<span>{{ subItem.name }}</span>
</el-menu-item>
</template>
</el-sub-menu>
</template>
</el-menu>
</div>
</div>
</template>
# 3、静态路由
不同的静态路由处理方式,可能会产生不同的动态路由方案
静态路由
- 基本路由
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/main'
},
{
path: '/login',
component: () => import('../view/login/login.vue')
},
{
path: '/main',
name: 'main',
component: () => import('../view/home/home.vue')
},
// 含有通配符的路由应该放在最后 !!!
{
path: '/:pathMatch(.*)',
component: () => import('../view/404/not-found.vue')
}
]
})
- 其他路由
放在合理的位置,然后后期根据用户的菜单树映射并注册指定路由即可
# 4、菜单-路由映射 ✨
接下来我们可以利用菜单数据的 url 属性 进行 vue-router 路由的动态注册 :
提示
将后端传来的 url(path)与路由的 path 进行匹配,前提是要与后端沟通好 path 路径的构成
制作 initDynamicRoutes 函数 🎈
- utils / map-menus.ts
import type { RouteRecordRaw } from 'vue-router'
// 批量收集本地路由
function loadLocalRoutes() {
const localRoutes: RouteRecordRaw[] = []
// - webpack中使用 require.context()、vite中使用import.meta.glob()
const modules: Record<string, any> = import.meta.glob('@/router/**/*.ts', {
eager: true
})
for (const key in modules) {
const module = modules[key]
if (key !== '../../router/index.ts') {
// console.log(module.default)
localRoutes.push(module.default)
}
}
return localRoutes
}
// 记录🤔第一个菜单
export let firstMenuItem: any = null
export function mapMenusToRoutes(userRoleMenu: any[]) {
// 1、本地的全部路由
const localRoutes = loadLocalRoutes()
// 2、根据菜单匹配的路由
const routes: RouteRecordRaw[] = []
for (const menu of userRoleMenu) {
for (const subItem of menu.children) {
const route = localRoutes.find((item) => item.path === subItem.url)
// 存储匹配的路由
if (route) {
// 追加父级菜单路由-重定向到第一个menuItem(供面包屑🤔使用)
if (!routes.find((item) => item.path === menu.url)) {
routes.push({ path: menu.url, redirect: route.path })
}
routes.push(route)
}
// 记录第一个菜单
if (!firstMenuItem && route) firstMenuItem = subItem
}
}
return routes
}
- utils / initDynamicRoutes.ts
import { mapMenusToRoutes } from './map-menus.ts'
import router from '@/router'
// ……
// 动态路由注册
export function initDynamicRoutes(userRoleMenu: any[]) {
// 根据菜单匹配的路由
const routes = mapMenusToRoutes(userRoleMenu)
// 对匹配的路由进行注册
routes.forEach((route) => router.addRoute('main', route))
}
此时,我们可以在需要生成动态路由的代码段中调用 initDynamicRoutes 函数为项目注册动态路由。比如:
- 用户登录时
- 页面刷新时
// @/store/login/login.ts
const useLoginStore = defineStore('login', {
action: {
// 1、用户登录时
async loginAccountAction(account: IAccount) {
// ……
// 根据菜单-动态生成路由
initDynamicRoutes(this.userRoleMenu)
//……
},
// 2、页面刷新时
dynamicRoutesCacheAction() {
const token = localCache.getCache(LOGIN_TOKEN)
const userInfo = localCache.getCache(LOGIN_USER_INFO)
const userRoleMenu = localCache.getCache(LOGIN_ROLE_MENU)
// 确保当前已经login
if (token && userInfo && userRoleMenu) {
// 使用缓存数据
this.token = token
this.userInfo = userInfo
this.userRoleMenu = userRoleMenu
// 根据缓存-动态复原路由
initDynamicRoutes(this.userRoleMenu)
}
}
})
# 5、页面刷新处理
页面刷新时,需要在合适的地方,路由缓存的菜单树重新生成 注册动态路由:
- main.ts
import { createApp } from 'vue'
import App from './App.vue'
import registerPinia from './global/register-pinia.ts'
import router from './router/index.ts'
const app = createApp(App)
app.use(registerPinia) // 重点:必须放在router注册之前
app.use(router)
app.mount('#app')
- @/global/register-pinia.ts
import type { App } from 'vue'
import pinia from '@/stores/index.ts'
import useloginStore from '@/stores/login/login.ts'
const registerPinia = (app: App<Element>) => {
app.use(pinia)
const loginStore = useloginStore()
loginStore.dynamicRoutesCacheAction() // 动态路由-防刷新处理
}
export default registerPinia
# 三、权限操作控制
# 1、基本介绍
页面级权限控制简单的说是按钮的权限控制,从根本上讲是进行增删改查请求的访问控制。
权限操作设计也是有一定的讲究的,本教程采用的是RuoYi-Vue3 (opens new window)的权限操作方案。
// 解析:当前用户在 /main/system/menu 路由组件页面中用户查询(query)权限
"permission": "system:menu:query"
按钮权限 数据
其实,关于权限操作控制的 permission
字段,后端在用户的菜单树中已经提供了:
{
"children": [
{
"id": 20,
"url": null,
"icon": null,
"name": "查询菜单",
"sort": 19,
"type": 3,
"parentId": 16,
"createAt": "2022-08-14 20:13:47.000000",
"updateAt": "2022-08-14 20:13:47.000000",
"permission": "system:menu:query" // 这里 !!!!
},
{
//
}
]
}
也有后端是这样传的,其想法就是在进行动态路由生成时,顺便将下列信息放到路由的 meta 中备用。
{
"meta": {
"title": "角色管理",
"rights": ["view", "edit", "add", "delete"]
}
}
# 2、权限数组
首先我们得根据 菜单树 映射出一个当前角色所拥有的 按钮权限数组:
- utils / map-menus.ts
// 将userRoleMenu树 映射为 permission数组
export function mapMenuToPermission(userRoleMenu: any) {
const permissions: string[] = []
function getPermission(menus) {
menus.forEach((item) => {
if (item.permission) {
permissions.push(item.permission)
} else {
getPermission(item.children ?? []) // 没有children时预处理
}
})
}
getPermission(userRoleMenu)
return permissions
}
# 3、权限控制
获取了权限数组后,我们可以可以利用该数组实现权限控制了,具体的实现流行有以下两种方案:
- 使用 vue 自定义指令(倾向于 按钮权限控制)
- 使用自己封装的 hook(倾向于 请求权限控制)
# ① 自定义指令 方案
- 全局 permissions 指令封装
// 详见个人项目:https://github.com/Lencamo/vue3-ting-admin/tree/main/src/directives
- v-permissions 指令使用示例
<template>
<div class="search-box" v-permissions="{ route, action: 'query' }">
<!-- -->
</div>
<!-- …… -->
<el-button
type="primary"
size="small"
@click="handleAddBtn()"
v-permissions="{ route, action: 'create', effect: 'disabled' }"
>新增用户</el-button
>
</template>
# ② 封装 hook 方案 👀
- @/hooks/usePermissions.ts
import useLoginStore from '@/store/login/login'
type actionType = 'create' | 'delete' | 'update' | 'query'
function usePermissions(route, action: actionType) {
// 用户操作权限 数组
const loginStore = useloginStore()
const { userRolePermission } = loginStore
// 当前路由path
const [, , parentName, currentName] = route.path.split('/')
return !!userRolePermission.find((item) =>
item.includes(`${parentName}:${currentName}:${action}`)
)
}
export default usePermissions
- 具体使用
<template>
<div class="search-box" v-if="isQuery">
<!-- -->
</div>
<!-- …… -->
<el-button type="primary" size="small" @click="handleAddBtn()" :disable="isCreate"
>新增用户</el-button
>
</template>
<script setup lang="ts">
import usePermissions from '@/hooks/usePermissions'
import { useRoute } from 'vue-router'
const route = useRoute()
const isCreate = usePermissions(route, 'create')
const isQuery = usePermissions(route, 'create')
</script>
# 四、权限与位运算
我们可以将&
、|
、^
位运算符应用于权限控制中:
这种方式可以极大的简化 按钮级别 的权限控制效率(后端只需要传一个数字即可)。
const CREATE = 1 // 0001
const DELETE = 2 // 0010
const UPDATE = 4 // 0100
const QUERY = 8 // 1000
测试代码
const CREATE = 1 // 0001
const DELETE = 2 // 0010
const UPDATE = 4 // 0100
const QUERY = 8 // 1000
// 权限组合 |
console.log('权限数字:', CREATE | QUERY) // 9
console.log(('二进制解读:', CREATE | QUERY).toString(2)) // 1001
console.log('-------')
// 权限解读 &
let permisssion1 = 11
console.log('当前权限:', permisssion1.toString(2)) // 查看
if (permisssion1 & UPDATE) {
console.log(' 有update权限')
} else {
console.log(' 无update权限')
}
console.log('-------')
// 权限操作 ^ (异或:有就去掉,没有就加上)
let permisssion2 = 14
console.log('当前权限:', permisssion2.toString(2)) // 查看
if (permisssion2 & DELETE) {
permisssion2 = permisssion2 ^ DELETE
console.log(' 去掉delete权限成功')
}
console.log('操作后权限:', permisssion2.toString(2)) // 查看
# 1、权限组合
有 1 则 1
- 按位或 |
// 权限组合 |
console.log('权限数字:', CREATE | QUERY) // 9
console.log(('二进制解读:', CREATE | QUERY).toString(2)) // 1001
console.log('-------')
# 2、权限解读
同为 1 则 1
- 按位与 &
// 权限解读 &
let permisssion1 = 11
console.log('当前权限:', permisssion1.toString(2)) // 查看
if (permisssion1 & UPDATE) {
console.log(' 有update权限')
} else {
console.log(' 无update权限')
}
console.log('-------')
# 3、权限操作
同则 0,异则 1
- 按位异或 ^
// 权限操作 ^ (异或:有就去掉,没有就加上)
let permisssion2 = 14
console.log('当前权限:', permisssion2.toString(2)) // 查看
if (permisssion2 & DELETE) {
permisssion2 = permisssion2 ^ DELETE
console.log(' 去掉delete权限成功')
}
console.log('操作后权限:', permisssion2.toString(2)) // 查看