提示

  想看完整的 权限管理系统 实现,可以查看个人项目: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)) // 查看
更新于 : 8/7/2024, 2:16:31 PM