前端权限流程(基于rbac实现思想)
1. 权限控制
1.1. 实现思想
基于rbac权限控制思想实现,给用户分配角色,给角色分配权限
给用户分配角色业务
注意:上方图片是个示例图,代表给用户分配职位(角色),页面中使用了Element-plus的el- checkbox组件和el-checkbox-group组件
静态结构(不完整)
<el-form-item label="用户姓名"><el-input v-model="userParams.username" :disabled="true"></el-input></el-form-item><el-form-item label="职位列表"><el-checkbox>全选</el-checkbox><!-- 显示职位的的复选框 --><el-checkbox-group><el-checkboxv-for="(role, index) in 10":key="index":label="index">{{ index }}</el-checkbox></el-checkbox-group></el-form-item>
获取&&存储数据
首先先获取全部的职位(角色)的数据与当前用户已有的职位(角色)的数据,分别进行保存
//收集顶部复选框全选数据
let checkAll = ref<boolean>(false)
//控制顶部全选复选框不确定的样式
let isIndeterminate = ref<boolean>(true)
//存储全部职位的数据
let allRole = ref<AllRole>([])
//当前用户已有的职位
let userRole = ref<AllRole>([])
//分配角色按钮的回调
const setRole = async (row: User) => {//存储已有的用户信息Object.assign(userParams, row)//获取全部的职位的数据与当前用户已有的职位的数据let result: AllRoleResponseData = await reqAllRole(userParams.id as number)if (result.code == 200) {//存储全部的职位allRole.value = result.data.allRolesList//存储当前用户已有的职位userRole.value = result.data.assignRoles//抽屉显示出来drawer1.value = true}
}
展示数据
<!-- 抽屉结构:用户某一个已有的账号进行职位分配 --><el-drawer v-model="drawer1"><template #header><h4>分配角色(职位)</h4></template><template #default><el-form><el-form-item label="用户姓名"><el-input v-model="userParams.username" :disabled="true"></el-input></el-form-item><el-form-item label="职位列表"><el-checkbox@change="handleCheckAllChange"v-model="checkAll":indeterminate="isIndeterminate">全选</el-checkbox><!-- 显示职位的的复选框 --><el-checkbox-groupv-model="userRole"@change="handleCheckedCitiesChange"><el-checkboxv-for="(role, index) in allRole":key="index":label="role">{{ role.roleName }}</el-checkbox></el-checkbox-group></el-form-item></el-form></template></el-drawer>
详细解释
全选部分:
@change:全选框点击时的回调
v-model:绑定的数据,根据这个值决定是否全选(是一个布尔值)
:indeterminate:不确定状态,既没有全选也没有全不选
复选框部分:
v-for="(role, index) in allRole"
:遍历allRole。
:label="role"
:收集的数据(勾上的数据)
v-model="userRole"
:绑定收集的数据,也就是收集的数据存储到userRole中。
@change:勾选变化时的回调(点击每个复选框都会执行的回调)
全选框勾选的回调:
实现原理:函数会将勾选与否注入到val中,如果是,就将全部数据(allRole)赋值给选中的数据(userRole),选中的数据通过v-model实现页面的同步变化
该函数会接收到一个布尔值,如果为真代表全选按钮被勾选,在回调中需要将全部角色(职位)数据赋值给在el-checkbox-group使用v-model绑定的变量中(该变量表示被选中的数据)
//顶部的全部复选框的change事件
const handleCheckAllChange = (val: boolean) => {//val:true(全选)|false(没有全选)userRole.value = val ? allRole.value : []//不确定的样式(确定样式)isIndeterminate.value = false
}
复选框
每勾选一个复选框就会执行该函数,并收集勾选的数据到数组中传递进该函数
在函数内部判断数组的长度是否等于全部角色数据,如果等于需要将上方的权限按钮勾上,因为已经使用v-model绑定了,所以直接给checkAll.value赋一个布尔值即可
//顶部全部的复选框的change事件
const handleCheckedCitiesChange = (value: string[]) => {//顶部复选框的勾选数据//代表:勾选上的项目个数与全部的职位个数相等,顶部的复选框勾选上checkAll.value = value.length === allRole.value.length//不确定的样式isIndeterminate.value = value.length !== allRole.value.length
}
当点击确定按钮的回调
需要收集两个参数:第一个:当前用户的id标识,第二个:从收集好的userRole中使用map过滤出选中的角色的id标识(数组)
然后发起分配用户角色的请求,请求成功,重新获取更新完毕用户的信息。
//确定按钮的回调(分配职位)
const confirmClick = async () => {//收集参数let data: SetRoleData = {userId: userParams.id as number,roleIdList: userRole.value.map((item) => {return item.id as number}),}//分配用户的职位let result: any = await reqSetUserRole(data)if (result.code == 200) {//提示信息ElMessage({ type: 'success', message: '分配职务成功' })//关闭抽屉drawer1.value = false//获取更新完毕用户的信息,更新完毕留在当前页getHasUser(pageNo.value)}
}
看到这里,相信你一定有收获!!!
给角色分配权限业务
静态结构
当点击上图中的分配权限按钮时,会弹出一个抽屉组件(el-drawer),利用树组件展示所有权限数据,并可以看到点击的角色已有的权限数据(勾选的)
注意:上方图片是个示例图,代表给角色分配权限,页面中使用了Element-plus的el-table和el-drawer,el-tree组件。
注意:因为这是在vue3和typescript项目中,所以进行了类型限制。
type这里MenuData与MenuList互相调用,适合这种树状的数据结构(type还可以定义多个复杂类型)
//菜单与按钮数据的ts类型
export interface MenuData {id: numbercreateTime: stringupdateTime: stringpid: numbername: stringcode: stringtoCode: stringtype: numberstatus: nulllevel: numberchildren?: MenuListselect: boolean
}
export type MenuList = MenuData[]
分配权限按钮
获取&&存储数据
根据角色id发送请求获取全部角色权限数据(包含该角色已有的职位)
//准备一个数组:数组用于存储勾选的节点的ID(四级的)
let selectArr = ref<number[]>([])
//已有的职位的数据
const setPermisstion = async (row: RoleData) => {//抽屉显示出来drawer.value = true//收集当前要分类权限的职位的数据Object.assign(RoleParams, row)//根据职位获取权限的数据let result: MenuResponseData = await reqAllMenuList(RoleParams.id as number)if (result.code == 200) {menuArr.value = result.data//下面这行代码下面会具体讲解,可以先往下看selectArr.value = filterSelectArr(menuArr.value, [])}
}
使用树组件展示全部权限数据(树组件详细使用说明)
我们重点关注el-tree组件,先介绍一些常用属性和方法
const defaultProps = {//子树为节点对象的childrenchildren: 'children',//节点标签为节点对象的name属性label: 'name',
}
常用属性:
1. data:展示的数据(数据源)
2. show-checkbox:节点是否可被选择(点击可以选中)
3. node-key:每个树节点用来作为唯一标识的属性,整棵树应该是唯一的(如果树中包含children子数据,该属性不能省略)
4. default-expand-all:默认展开所有节点
5. default-checked-keys:默认勾选的节点的 key 的数组(是一个数组,数组中存放的就是上面node-key存放的唯一标识)
6. default-expanded-keys:默认展开的节点的 key 的数组(是一个数组,数组中存放的就是上面node-key存放的唯一标识)
7. current-node-key:当前选中的节点(可以是number或string类型)
8. props:接收一个对象,对象中可以包含以下两个属性(还可以包含其他属性,这里只列举了以下两个)
label:指定节点标签为节点对象的某个属性值(就是代表了要在页面中展示的节点名称) ,children:指定子树为节点对象的某个属性值(就是代表去哪个字段下读取数据当作子节点的数据)(注意:label和children这两个属性名是不变的,属性值需要根据项目需要进行修改)
常用方法:
使用el-tree树组件提供的方法时,需要先在el-tree组件标签上利用ref打上标识(<el-tree ref="xxx"> </el-tree>),然后通过ref得到el-tree组件实例才能调用对应方法!
1. getCheckedKeys:如果在el-tree标签上设置了show-checkbox属性且被选中,通过树组件实例.getCheckedKeys进行调用,它将返回当前选中节点key的数组(该数组由所有被选中的节点的id属性组成【为什么是id属性呢? 因为:在el-tree标签上设置了node-key="id"属性。所以该方法会收集所有选中的节点对象的id属性】)
2. getHalfCheckedKeys:如果在el-tree标签上设置了show-checkbox属性且被选中,通过树组件实例.getHalfCheckedKeys进行调用,它将返回当前半选中的节点的id属性组成的数组
如遇这种情况该方法一般会和上面的getCheckedKeys配合使用
在树组件中展示已分配的权限
为什么要使用递归函数?
因为不能直接根据1级,2级,3级的select属性进行判断,要判断最内层的职位对象的select属性是真是假,因为层级较深要判断最内层的职位对象的select属性,所以使用了递归函数
具体实现思路:
目的:要先对返回的全部权限数据进行过滤,找到最内层的职位对象,根据其select属性判断!
封装一个递归函数,接收两个参数,一个全部权限数据,一个空数组(用于存放满足level为4级职位对象中的select属性为真的职位的id),在递归函数 中使用for Each遍历全部权限数据,判断item.select && item.level === 4如果满足说明到最内层了,将当前这个对象的id属性(item.id)push到空数组中,否则代表没有到最内层,继续判断item.children && item.children.length > 0 满足该条件递归调用 filterSelectArr(item.children,initArr),最后返回这个过滤好的数组,交给el-tree组件的default-checked-keys属性,那么就可以找出该角色已有的权限就可以在树组件选中了
//分配权限按钮的回调
//已有的职位的数据
const setPermisstion = async (row: RoleData) => {//抽屉显示出来drawer.value = true//收集当前要分类权限的职位的数据Object.assign(RoleParams, row)//根据职位获取权限的数据let result: MenuResponseData = await reqAllMenuList(RoleParams.id as number)if (result.code == 200) {menuArr.value = result.data/*最后返回这个过滤好的数组,交给el-tree组件的default-checked-keys属性,那么就可以找出该角色已有的权限就可以在树组件选中了*/selectArr.value = filterSelectArr(menuArr.value, [])}
}
// 递归函数
const filterSelectArr = (allData: any, initArr: any) => {allData.forEach((item: any) => {if (item.select && item.level == 4) {initArr.push(item.id)}if (item.children && item.children.length > 0) {//层层递进,直到最内层的职位对象的level属性===4filterSelectArr(item.children, initArr)}})//返回这个过滤好的数组return initArr
}
收集用户分配的权限
我们这里收集主要用到了2个方法:getCheckedKeys、getHalfCheckedKeys。它们会返回已选择以及半选择用户的id数组(这两个方法上方都有详细解释)
//抽屉确定按钮的回调
const handler = async () => {//职位(角色)的IDconst roleId = RoleParams.id as number//选中节点的ID getCheckedKeys方法会得到show-checkbox为true选中的全部节点对象的id组成的数组//为什么是能收集到id 因为el-tree配置了node-key="id"属性let arr = tree.value.getCheckedKeys()//半选的IDlet arr1 = tree.value.getHalfCheckedKeys()let permissionId = arr.concat(arr1)//下发权限let result: any = await reqSetPermisstion(roleId, permissionId)if (result.code == 200) {//抽屉关闭drawer.value = false//提示信息ElMessage({ type: 'success', message: '分配权限成功' })//页面刷新window.location.reload()}
}
继续加油!
1.2. 实现深度
3级权限:菜单权限、页面权限、按钮权限
菜单权限
1. 路由分析
菜单的权限:
超级管理员账号:admin atguigu123 拥有全部的菜单、按钮的权限
飞行员账号 硅谷333 111111 不包含权限管理模块、按钮的权限并非全部按钮
同一个项目:不同人(职位是不一样的,他能访问到的菜单、按钮的权限是不一样的)一、目前整个项目一共多少个路由!!!
login(登录页面)、
404(404一级路由)、
任意路由、
首页(/home)、
数据大屏、
权限管理(三个子路由)
商品管理模块(四个子路由)1.1开发菜单权限
---第一步:拆分路由
静态(常量)路由:大家都可以拥有的路由
login、首页、数据大屏、404异步路由:不同的身份有的有这个路由、有的没有
权限管理(三个子路由)
商品管理模块(四个子路由)任意路由:任意路由1.2菜单权限开发思路
目前咱们的项目:任意用户访问大家能看见的、能操作的菜单与按钮都是一样的(大家注册的路由都是一样的)
2. 路由拆分
//对外暴露配置路由(常量路由)
export const constantRoute = [{//登录路由path: '/login',component: () => import('@/views/login/index.vue'),name: 'login', //命名路由meta: {title: '登录', //菜单标题hidden: true, //路由的标题在菜单中是否隐藏},},{//登录成功以后展示数据的路由path: '/',component: () => import('@/layout/index.vue'),name: 'layout',meta: {hidden: false,},redirect: '/home',children: [{path: '/home',component: () => import('@/views/home/index.vue'),meta: {title: '首页',hidden: false,icon: 'HomeFilled',},},],},{path: '/404',component: () => import('@/views/404/index.vue'),name: '404',meta: {title: '404',hidden: true,},},{path: '/screen',component: () => import('@/views/screen/index.vue'),name: 'Screen',meta: {hidden: false,title: '数据大屏',icon: 'Platform',},},
]//异步路由
export const asnycRoute = [{path: '/acl',component: () => import('@/layout/index.vue'),name: 'Acl',meta: {hidden: false,title: '权限管理',icon: 'Lock',},redirect: '/acl/user',children: [{path: '/acl/user',component: () => import('@/views/acl/user/index.vue'),name: 'User',meta: {hidden: false,title: '用户管理',icon: 'User',},},{path: '/acl/role',component: () => import('@/views/acl/role/index.vue'),name: 'Role',meta: {hidden: false,title: '角色管理',icon: 'UserFilled',},},{path: '/acl/permission',component: () => import('@/views/acl/permission/index.vue'),name: 'Permission',meta: {hidden: false,title: '菜单管理',icon: 'Monitor',},},],},{path: '/product',component: () => import('@/layout/index.vue'),name: 'Product',meta: {hidden: false,title: '商品管理',icon: 'Goods',},redirect: '/product/trademark',children: [{path: '/product/trademark',component: () => import('@/views/product/trademark/index.vue'),name: 'Trademark',meta: {hidden: false,title: '品牌管理',icon: 'ShoppingCartFull',},},{path: '/product/attr',component: () => import('@/views/product/attr/index.vue'),name: 'Attr',meta: {hidden: false,title: '属性管理',icon: 'CollectionTag',},},{path: '/product/spu',component: () => import('@/views/product/spu/index.vue'),name: 'Spu',meta: {hidden: false,title: 'SPU管理',icon: 'Calendar',},},{path: '/product/sku',component: () => import('@/views/product/sku/index.vue'),name: 'Sku',meta: {hidden: false,title: 'SKU管理',icon: 'Orange',},},],},
]//任意路由
//任意路由
export const anyRoute = {//任意路由path: '/:pathMatch(.*)*',redirect: '/404',name: 'Any',meta: {title: '任意路由',hidden: true,icon: 'DataLine',},
}
3. 菜单权限的实现
思想:
先在本地对所有路由进行拆分,分为 静态(常量)路由:大家都可以拥有的路由 异步路由:不同的身份有的有这个路由、有的没有(根据用户登录成功之后返回的用户信息与本地的异步路由进行过滤筛选,匹配成功之后保存到数组中,动态的进行追加,不同的人追加的路由就是不一样的)
下图是服务器返回的用户信息,其中包含该用户信息的异步路由数据
注意:这里使用了递归。其次,这里是浅拷贝,会改变原有的路由。因此还需要改进。
//硅谷333: routes['Product','Trademark','Sku']
let guigu333 = ['Product', 'Trademark', 'Sku'];
function filterAsyncRoute(asnycRoute, routes) {return asnycRoute.filter(item => {if (routes.includes(item.name)) {if (item.children && item.children.length > 0) {item.children = filterAsyncRoute(item.children, routes)}return true}})
}
//硅谷333需要展示的异步路由
let guigu333Result = filterAsyncRoute(asnycRoute, guigu333);
console.log([...constRoute, ...guigu333Result, anyRoute], '硅谷333');
4. 获取路由
当调用用户仓库中的获取用户信息方法成功时,会拿到用户信息包括了用户的动态路由(异步路由),将全部异步路由和用户的异步路由传递进递归函数filterAsyncRoute(asyncRoute, routes)中
进行过滤筛选,userAsyncRoute代表过滤出来的异步路由,然后将常量路由、异步路由、任意路由展开放进一个数组中赋值给仓库中的menuRoutes属性,该属性的作用是登录成功之后会利用仓库中的这个属性来渲染左侧的导航栏。然后利用router的addRoute属性进行动态追加路由(常量路由已经在路由表中,只需要追加异步路由和任意路由)
完整代码:
。。。。。。import router from '@/router'
//引入路由(常量路由)
import { constantRoute, asnycRoute, anyRoute } from '@/router/routes'
//用于过滤当前用户需要展示的异步路由
function filterAsyncRoute(asnycRoute: any, routes: any) {return asnycRoute.filter((item: any) => {if (routes.includes(item.name)) {if (item.children && item.children.length > 0) {//硅谷333账号:product\trademark\attr\skuitem.children = filterAsyncRoute(item.children, routes)}return true}})
}
//创建用户小仓库
const useUserStore = defineStore('User', {//小仓库存储数据地方state: (): UserState => {return {。。。。。。。menuRoutes: constantRoute, //仓库存储生成菜单需要数组(路由)us。。。。。。}},//处理异步|逻辑地方actions: {。。。。。。。//获取用户信息方法async userInfo() {//获取用户信息进行存储const result: userInfoResponseData = await reqUserInfo()if (result.code == 200) {this.username = result.data.namethis.avatar = result.data.avatar//计算当前用户需要展示的异步路由const userAsyncRoute = filterAsyncRoute(asnycRoute, result.data.routes)//菜单需要的数据整理完毕this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute]//目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加;[...userAsyncRoute, anyRoute].forEach((route: any) => {router.addRoute(route)})return 'ok'} else {return Promise.reject(new Error(result.message))}},。。。。。。
})
//对外暴露小仓库
export default useUserStore
菜单权限的2个问题
深拷贝
之前获取需要的路由方法中使用的是浅拷贝,会改变原有的路由。因此我们这里引入深拷贝的方法
使用lodash库中的cloneDeep方法
//引入深拷贝方法
//@ts-expect-error
import cloneDeep from 'lodash/cloneDeep'
。。。。。。//获取用户信息方法async userInfo() {//获取用户信息进行存储const result: userInfoResponseData = await reqUserInfo()if (result.code == 200) {this.username = result.data.namethis.avatar = result.data.avatar//计算当前用户需要展示的异步路由const userAsyncRoute = filterAsyncRoute(cloneDeep(asnycRoute),result.data.routes,)//菜单需要的数据整理完毕this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute]//目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加;[...userAsyncRoute, anyRoute].forEach((route: any) => {router.addRoute(route)})return 'ok'} else {return Promise.reject(new Error(result.message))}},
路由懒加载
这样配置路由后,如果你访问的是异步路由,会在刷新的时候出现空白页面。原因是异步路由是异步获取的,加载的时候还没有。因此我们可以在路由守卫文件中改写。这个的意思就是一直加载。
//用户登录判断if (token) {//登陆成功,访问login。指向首页if (to.path == '/login') {next('/')} else {//登陆成功访问其余的,放行//有用户信息if (username) {//放行next()} else {//如果没有用户信息,在收尾这里发请求获取到了用户信息再放行try {//获取用户信息await userStore.userInfo()//万一刷新的时候是异步路由,有可能获取到用户信息但是异步路由没有加载完毕,出现空白效果next({ ...to })} catch (error) {//token过期|用户手动处理token//退出登陆->用户相关的数据清空await userStore.userLogout()next({ path: '/login', query: { redirect: to.path } })}}}} else {//用户未登录if (to.path == '/login') {next()} else {next({ path: '/login', query: { redirect: to.path } })}}
按钮权限
对于不同的用户,按钮的的显示与否
获取用户已有的按钮权限信息:用户登录成功之后,获取到的用户信息中包含了该用户的按钮权限并将其保存在仓库中
//创建用户相关的小仓库
import { defineStore } from 'pinia'
//引入接口
import { reqLogin, reqUserInfo, reqLogout } from '@/api/user'
import type {loginFormData,loginResponseData,userInfoResponseData,
} from '@/api/user/type'
import type { UserState } from './types/type'import router from '@/router'
。。。。。。
//创建用户小仓库
const useUserStore = defineStore('User', {//小仓库存储数据地方state: (): UserState => {return {token: GET_TOKEN(), //用户唯一标识tokenmenuRoutes: constantRoute, //仓库存储生成菜单需要数组(路由)username: '',avatar: '',//存储当前用户是否包含某一个按钮buttons: [],}},//处理异步|逻辑地方actions: {。。。。。。//获取用户信息方法async userInfo() {//获取用户信息进行存储const result: userInfoResponseData = await reqUserInfo()if (result.code == 200) {this.username = result.data.namethis.avatar = result.data.avatarthis.buttons = result.data.buttonsconsole.log(result)//计算当前用户需要展示的异步路由const userAsyncRoute = filterAsyncRoute(cloneDeep(asnycRoute),result.data.routes,)//菜单需要的数据整理完毕this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute]//目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加;[...userAsyncRoute, anyRoute].forEach((route: any) => {router.addRoute(route)})return 'ok'} else {return Promise.reject(new Error(result.message))}},。。。。。。
})
//对外暴露小仓库
export default useUserStore
自定义指令
这个需要你在每个按钮元素中使用v-has="btn.User.XXXX"去判断。比v-if方便。不需要在组件内部引入仓库
import pinia from '@/store'
import useUserStore from '@/store/modules/user'
const userStore = useUserStore(pinia)
export const isHasButton = (app: any) => {//获取对应的用户仓库//全局自定义指令:实现按钮的权限app.directive('has', {//代表使用这个全局自定义指令的DOM|组件挂载完毕的时候会执行一次mounted(el: any, options: any) {//自定义指令右侧的数值:如果在用户信息buttons数组当中没有//从DOM树上干掉//el就是dom元素//options:传入进来的值if (!userStore.buttons.includes(options.value)) {el.parentNode.removeChild(el)}},})
}
注册完成之后,需要在main.ts中引入
总结
模块权限和页面权限
动态的筛选模块权限和页面权限,基于动态路由表的方式动态添加路由
- 在路由守卫获取路由权限
- 进行权限筛选,提前划分路由权限:动态路由表、静态路由表
- 先通过后端返回的权限标识和路由进行对比筛选,先筛选模块权限,在基于模块晒页面路由
- 路由规则通过addRoutes动态添加,问题:跳转需要通过next(to.path)
- 菜单有问题:菜单通过
this.$router.options.routes
获取路由规则,根据路由规则生成菜单,该api只能获取到初始化的路由表,在vuex单独维护了一份新的路由表,用于渲染菜单 - 退出如果不做处理,路由权限会累加,退出时重置路由,创建一个新的路由实例,将旧的路由实例的matcher指向新的路由实例的matcher
按钮权限
- 自定义指令,使用按钮的时候,通过自定义指令绑定按钮标识,自定义指令内部的inserted钩子函数接收到标识后,和后端返回的标识进行对比,如果有符合的保留按钮。如果不符合直接
el.remove
- 高阶组件,组件接收一个组件,进行处理,返回一个新的组件,通过插槽将组建传递到按钮权限组件内部
<auto-permission perms="parking:card:add_edit"><el-button type="primary" @click="$router.push('/parking/addcard')">添加月卡</el-button>
</auto-permission>
组件内部通过render函数,可以返回jsx结构,通过this.$slots.default
获取到传递过来的内容,配合逻辑处理,标识对比,如果标识存在,返回插槽结构,否则不返回