近 2 年一直使用蚂蚁金服的 Ant Design UI 框架以及其开箱即用的中台前端/设计解决方案 ANT DESIGN PRO (去年的圣诞风波有点影响,希望不再发生类似的事情),框架是一直更新一直迭代,不过里面涉及权限管理的部分的使用场景还是比较有限,兼容不了需要细化到各模块中的具体动作的场景。授人以鱼不如授人以渔,没有就自己撸一个呗。
设计思想
虽然是自己撸,但还是得站在前辈的肩膀上,离开设计的代码都不够优雅。向我司的校长(霸气绰号,具体为什么叫校长可以在 https://www.luweitech.cn/ 上找找,可能能找到 (#^.^#)
)学习——写代码要写得像诗一样优雅。
找了一圈,最终选了一个设计思想——RBAC,RBAC 以角色为基础的访问控制(英语:Role-based access control,RBAC),简单可以归纳为 who、what、how,即,who 对 what进行了 how 的操作,翻译成广东话就系:“有一个靓仔企一个野里面做左滴野”。
一张简单的图(图是盗来的~)理解:
即,张三、李四是“销售角色”,而“销售角色拥有查看“客户列表”和“编辑客户”两个动作的权限,自然而然的,张三、李四就拥有查看“客户列表”和“编辑客户”两个动作的权限。
完整一点就是(图也是盗来的):
由上可以看出,核心就三步:
- 定义角色
- 授权角色拥有的权限
- 给用户指定角色
准备
我觉得核心还是上面的设计思路,具体的代码实现只是思路的表达,后续封装得更通用再放出完整版出来吧。
ps:使用的 ant-design-pro 版本是 2.2.1,有比较多旧系统,还没一下子升级到最新的,各位可以用最新来撸
授权角色拥有的权限
定义角色这一步比较简单,就直接跳过了~
先说第二步,给角色授权权限。先上效果图:
这一步有几个关键步骤:
- 把
router.config.js
转化成上图中用于展示数据 - 构建好上图中的交互逻辑
- 把用户选择的权限按特定格式发给后台
一部分 router.config.js
,如下
export default [
// user
...节省位置,省略
// app
{
path: '/',
component: '../layouts/BasicLayout',
Routes: ['src/pages/Authorized'],
routes: [
{
path: '/',
redirect: '/welcome',
},
{
name: 'welcome',
path: '/welcome',
icon: 'smile',
component: './Welcome/Welcome',
power: ['MENU'],
},
{
name: 'revenueManagement',
path: '/revenueManagement',
icon: 'pay-circle',
power: ['MENU'],
routes: [
{
name: 'userDeposit',
path: '/revenueManagement/userDeposit',
component: './UserDeposit/UserDeposit',
power: ['MENU', 'CONTENT', 'EXPORT'],
},
{
name: 'userConsumptions',
path: '/revenueManagement/userConsumptions',
component: './UserConsumptions/UserConsumptions',
power: ['MENU', 'CONTENT', 'EXPORT'],
},
{
name: 'staffTuningLogs',
path: '/revenueManagement/staffTuningLogs',
component: './StaffTuningLogs/StaffTuningLogs',
power: ['MENU', 'CONTENT', 'EXPORT'],
},
{
name: 'userAccount',
path: '/revenueManagement/userAccount',
component: './UserAccount/UserAccount',
power: ['MENU', 'CONTENT', 'EXPORT', 'GIVE_COIN'],
},
]
},
],
},
];
比较关键是准备这几个数据:(聪明的你肯定知道 _
是lodash)
/**
* 过滤原始的 router 数据,返回有 power 属性的 item
* @param {Array} data router.config.js 中关于 app 部分的配置,即:RouterConfig[1].routes,注意,不要直接把 RouterConfig[1].routes 传递进来,这里会改变原来的数据,所以需要深复制后才传进来
* @returns {Array} 格式化后的 RouterConfig[1].routes,过滤掉没有 power 属性的 item
*/
function filterRouter(data) {
return data.filter((item) => {
if (item.routes) {
item.routes = filterRouter(item.routes);
}
return item.power;
})
}
/**
* 将 filterRouter且memoizeOneFormatter 出来后的数据的 power 属性改成 [{label: "查看菜单", value: "MENU"}] 的形式,用于在展示是可以出现中文
* @param {Array} data RouterConfig[1].routes执行 filterRouter且memoizeOneFormatter 函数后的数据,同样,该参数需要深复制后才传递进来
* @returns {Array} 修改 power 属性后的数据
*/
function setPowerText(data) {
return data.map((item) => {
if (item.children) {
item.children = setPowerText(item.children);
}
item.power = item.power.map((powerItem) => {
return {
label: powerName[powerItem],
value: powerItem,
}
});
return item;
});
}
/**
* path 为 key,power 为 value,将 filterRouter且memoizeOneFormatter 后的数据,转成这种 key-value 的对象
* @param {Array} data RouterConfig[1].routes执行 filterRouter且memoizeOneFormatter 函数后的数据,同样,该参数需要深复制后才传递进来
* @returns {Object}
* 例如:
{
'/list': ['MENU'],
'/list/basic-list': ['MENU', 'CONTENT', 'ADD', 'UPDATE', 'DELETE'],
'/exception': ['MENU'],
}
*/
function getAllPowerKeyValue(data) {
let result = {};
const recursion = (data) => {
data.forEach((item) => {
result[item.path] = item.power;
if (item.children) {
recursion(item.children);
}
});
}
recursion(data);
return result;
}
const powerOriginData = filterRouter(_.cloneDeep(RouterConfig[1].routes)); // 过滤没有 power 属性的项
const localePowerOriginData = memoizeOneFormatter(powerOriginData, undefined); // 将name 改成相应语言,注意,经过这个函数之后,原本的 routes 就改成 children 了
const powerTextData = setPowerText(_.cloneDeep(localePowerOriginData));
const allPowerKeyValueData = getAllPowerKeyValue(_.cloneDeep(localePowerOriginData));
powerOriginData
是过滤掉没有 power (power 是自己定义的一个属性,用来标明该模块中拥有哪些动作) 属性的项,减少接下来计算中的次数。
powerTextData
纯粹是为了展示用的,把动作的标识换成中文给用户选择时看
allPowerKeyValueData
主要是为了方便接下来的计算,把 router.config.js
中多余的字段都清掉,留下 key(以模块的 path 为 key)和对应的 power。
准备好这些展示数据,后面的交互逻辑和发送给后台就简单了,不啰嗦了~
使用
- 定义角色
- 授权角色拥有的权限
- 给用户指定角色
完成以上三步后,下一个模块就是直接使用了,这里分成两个部分:
- 登录时获取该用户的权限并初始化侧边栏
- 给各模块中的动作上锁
登录时拦截
- 登录后,结合当前用户信息,再向后台的接口请求数据,获取当前用户的所有权限
- 比如,如果后台返回的数据如下(第 2 步下面)
- 获得后台返回的数据后,将以上数据格式化成:
{/authority: ["MENU"], /authority/role: ["MENU", "CONTENT", "ADD", "UPDATE", "TRIGGER", "RESOURCE_AUTHORIZE"]}
,标志每个路由(页面)里面匹配当前用户的角色分别有哪些权限,然后存在local storage中,字段命名为:curStaffAuthorized
- 进入主页面后,加载
src/layouts/BasicLayout.js
组件时会构造侧边栏,在src/models/menu.js
的getMenuData
将以上缓存中curStaffAuthorized
的数据转换成侧边栏的数据,过程如下:(具体可以查看:v2.0 权限控制)getMenuData
的payload
参数中有一个routes
是config/router.config.js
中的所有路由- 结合缓存中
curStaffAuthorized
的数据就能知道当前用户哪些路由是有权限的,哪些路由没有权限,直接把没有权限的路由从要渲染到侧边栏的数据中删掉
后台返回的格式:("/authority"--这个 key 是路由,代表该路由或该页面有哪些权限)
{
"/authority":[{permission_id: 1, action: "MENU", name: "角色权限管理-角色管理-MENU", description: ""}],
"/authority/role":[
{permission_id: 2, action: "MENU", name: "角色权限管理-角色管理-MENU", description: ""},
{permission_id: 3, action: "CONTENT", name: "角色权限管理-角色管理-CONTENT", description: ""},
]
}
给各模块中的动作上锁
这一步就比较简单了(不过很麻烦,在想有没有更好的办法)
在 pages 中,根据 path
和 curStaffAuthorized
检查是否有该权限,然后根据标识控制对应功能的显示与否,比如:
let path = props.match.path;
this.contentPower = checkPower(CONTENT, path);
this.addPower = checkPower(ADD, path);
this.updatePower = checkPower(UPDATE, path);
this.deletePower = checkPower(DELETE, path);
this.triggerPower = checkPower(TRIGGER, path);
{this.addPower && <Button icon="plus" type="primary" onClick={this.handleAddClick}>新建</Button>}