背景:按照传统的开发方式方式,每次新开发一个系统,就需要花费大量时间精力去搭建权限控制模块,如果我们把权限控制这一整个模块都抽离成一个独立的权限控制插件,支持单命令安装,全面暴露参数与方法,就可以通过配置快速集成完整的权限控制机制。
意义:便于集成与扩展,提高项目构建速度,减少重复代码,降低工作量。提高开发效率,减少因人工手动搭建导致的不必要的错误。
vivien-permission插件
这是一个基于后台管理系统中的路由菜单权限控制系统,通过 vue-router 全局控制后台管理系统的菜单权限。
功能
使用文档
该插件的源代码及其使用文档均放在该仓库中。
实现原理
页面/菜单权限实现思路
1、后端权限管理配置
-
后台系统维护侧边栏目录的配置,包括目录名称、图标、链接等。
-
后端接口能够返回侧边栏的树形结构数据,这些数据应该包含每个菜单项对应的路由地址和权限标识。
2、前端路由配置
-
前端项目中定义好静态路由和动态路由的配置。
-
静态路由通常是那些不需要权限即可访问的页面,如登录页、404页面等。动态路由则是根据用户角色和权限来动态生成的路由。
3、路由匹配与生成
-
调用后端接口获取侧边栏树形结构数据。前端通过递归遍历后端返回的树形结构数据,并与前端配置的路由进行匹配。
-
对于匹配成功的路由,将其加入到异步路由表中。
4、路由表整合
-
将动态生成的异步路由表和静态的常规路由表进行整合。
-
确保整合后的路由表是完整的,并且按照正确的顺序排列。
5、生成侧边栏菜单
-
根据整合后的路由表,生成侧边栏菜单的DOM结构。
-
侧边栏菜单应该包含所有用户有权限访问的菜单项。对于没有权限访问的菜单项,应该进行隐藏或者显示为不可点击状态。
6、路由守卫与权限校验
-
在前端实现路由守卫,对用户的访问进行权限校验。
-
当用户尝试访问某个页面时,检查该用户是否具有访问该页面的权限。如果没有权限,则重定向到无权限页面或提示用户。
7、缓存与性能优化
-
对于一些不经常变动的侧边栏数据,可以考虑使用缓存来提高性能。
-
在用户登录成功后,可以将侧边栏数据缓存起来,避免重复请求后端接口。
实现之前,需要先知道一些前置知识,有利于更好地理解。
http://t.csdnimg.cn/4zkwQhttp://t.csdnimg.cn/4zkwQ
核心片段
1、登录成功后,获取到token和用户信息,进行存储,然后跳转首页
// 登录方法
const login = async function (params: any) {
try {
//添加 try catch 捕获异常
await userStore.Login(params);
await userStore.GetUserInfo();
routerNext();
} catch (err) {
console.error(err);
}
};
const routerNext = function () {
if (router.currentRoute.value.query.redirect) { //如果重新登陆后需要返回原先的路由地址
router.push(router.currentRoute.value.query.redirect as string);
} else {
router.push({ name: "TV_FDS_LIST" });
}
};
2、在后台权限管理系统根据侧边栏目录配置侧边栏和菜单、前端项目代码配置路由
3、后端接口返回用户有权限访问的路由表和拥有的权限列表
4、 递归匹配后端路由和前端路由配置,添加路由异步路由表和常规路由表,形成最终的路由表
- 递归后端接口返回的信息获取用户权限列表的方法:
/**
* 获取嵌套对象的所有对象的 key 对应 value值
* @param {*} data 嵌套对象
* @param {*} arr 存放属性数组
* @param {*} children 保存嵌套子对象的属性
* @param {*} key 获取的 value 对应的 key
* @returns
*/
export function getChildValue(
data: Array<T> = [],
arr: Array<T> = [],
key: string = '',
children: string = 'children'
) {
if (!key || data.length <= 0) return
data.forEach(item => {
if (item[children]) {
getChildValue(item.children, arr, key, children)
}
arr.push(item[key])
})
}
// 获取用户权限列表
async GetAuthority(getAuthList: Function, domain: string): Promise<T> {
try {
if (!getAuthList || typeof getAuthList !== "function") {
return Error("getAuthList 参数错误")
}
const authority: authorityType = {
menuNames: [], // 菜单权限名称列表
rule: [],// 按钮级别权限
}
/**
*请求获取路由权限列表,返回对象:
{
menuNames: [], // 菜单权限名称列表
rule: [],// 按钮级别权限
}
*/
const data = await getAuthList({
token: getToken()
})
authority.menuNames = data.menuNames
authority.rule = data.rule
this.SetAuthority(authority);
return authority
} catch (error) {
this.ClearLocal(domain);
return null;
}
},
- 前端匹配生成路由的方法:
// 生成异步路由
GenerateRoutes(routesMenuNames: Array<RouteItem>, asyncRoutes: AppRouteModule[], basicRoutes: AppRouteModule[]) {
// 过滤常量路由:过滤没有权限的异步路由
filterRoutes(basicRoutes, routesMenuNames)
// 过滤异步路由:过滤没有权限的异步路由
filterRoutes(asyncRoutes, routesMenuNames)
this.SetRoutes(asyncRoutes, basicRoutes)
return asyncRoutes
},
- 过滤路由的方法:
/**
* Filter asynchronous routing tables by recursion
* 过滤没有权限的常量路由路由:递归前端路由,查找 name 不存在的路由,删除
* @param routes asyncRoutes
* @param roles
*/
export function filterRoutes(routesInstans: Array<T>, routesMenuNames: Array<T>): void {
// 开发环境侧边栏路由不由后端管理系统控制
// if (process.env.NODE_ENV === envEnum.DEVELOPMENT) return
// 测试和生产环境下,对常量路由进行过滤
for (let i = 0; i < routesInstans.length; i++) {
const route = routesInstans[i]
if (route.children) {
filterRoutes(route.children, routesMenuNames)
}
if (routesMenuNames && routesMenuNames.length > 0 && (!route?.hidden)) {
route.hidden = (routesMenuNames.indexOf(route.name) < 0)
}
}
}
- 整合路由表的方法:
// 设置所有路由
SetRoutes(asyncFilterRoutes: Array<T>, constantAsyncRoutes: Array<T>) {
this.routes = constantAsyncRoutes.concat(asyncFilterRoutes).sort((value1: RouteItem, value2: RouteItem) => value1?.order - value2?.order) //所有路由
this.addRoutes = asyncFilterRoutes //新增异步路由获取后台管理系统路由(前台未设置权限页面,因此异步路由即为后台管理路由)
},
5、根据生成的路由表设置侧边栏菜单
// 设置侧边栏路由
SetRoute(routes: Array<RouteItem>) {
this.routes = routes
},
- 点击某一个主菜单,生成对应侧边栏菜单的方法:
/**
* 设置二级菜单显示的路由
* @param {} param0
* @param {*} routes 当前路由对象,包含路由名称 name 或则路由路径
* @returns
*/
SetShowRouters(routes: RouteItem) {
const { name, matched } = routes
let topRouteName = name // 二级路由顶部菜单栏名称
if (matched && matched.length > 0) { // 根据路由匹配路径获取二级顶部菜单栏名称
topRouteName = matched[0].name
}
const filterRouter = this.routes.map((item: RouteItem) => {
if (item.name !== topRouteName) {
item.hidden = true
} else {
item.hidden = false
}
return item
})
this.SetRoute(filterRouter)
return routes
}
6、当进行路由跳转时,路由守卫先判断token,没有token且路由地址也不在路由白名单内,就让用户跳转到登录页重新登陆拿token;如果有token,就需要对用户权限进行校验。
import type { Router, RouteItem } from 'vue-router';
import { getToken as toGetToken, getOAToken } from "@/utils/token";
import { routesStoreWithOut } from "@/store/routes";
import { useUserStoreWithOut } from "@/store/user";
import type { AppRouteModule } from "@/types/router";
import { Message as showMsg } from '@/plugin/Message.ts';
const routeStore = routesStoreWithOut();
const userStore = useUserStoreWithOut();
export async function createPermissionGuard(
router: Router,
whiteList: string[],
asyncRoutes: AppRouteModule[],
basicRoutes: AppRouteModule[],
getAuthList: Function,
checkOaLogin: Function,
domain: string,
Message: Function
) {
/**
* 问题: 直接使用 router.beforeEach 会导致在刷新页面时无法进入 router.beforeEach 的回调函数
* 原因:可能是因为在刷新页面时,Vue Router 的初始化过程尚未完成,导致路由守卫无法正常触发。
* 解决方案:将 router.beforeEach 回调函数的逻辑放在一个异步函数中,并在 Vue Router 初始化完成后再调用这个异步函数。你可以使用 router.isReady() 方法来判断 Vue Router 是否已经初始化完成。
* isReady: isReady(): Promise<void> 返回一个 Promise,它会在路由器完成初始导航之后被解析,也就是说这时所有和初始路由有关联的异步入口钩子和异步组件都已经被解析。如果初始导航已经发生,则该 Promise 会被立刻解析。
*/
router.isReady().then(() => {
router.beforeEach(async (to: any, from: any, next: Function) => {
// 判断用户是否已经登录,已经登录情况下,进入权限判断
if (toGetToken()) {
return await routerPermission(to, from, next, whiteList, asyncRoutes, basicRoutes, getAuthList, domain, Message)
} else {
// 兼容oa 系统单点登录,获取 oa 中的 token
const { oaToken } = getOAToken(domain)
// oa 存在 token,用户已经登录 oa
if (oaToken) {
try {
// 使用 oa token 换取当前系统的 token, 登录系统
await userStore.CheckOaLogin(checkOaLogin, domain);
return next();
} catch (err) {
userStore.ClearLocal(domain);
return next("/login?redirect=" + to.path);
}
// 用户未登录, 判断是否进入白名单页面路由
} else if (whiteList.includes(to.name as string)) {
return next();
} else {
return next("/login?redirect=" + to.path);
}
}
});
});
}
/**
* 路由权限判断函数,根据路由权限进入不同路由
*/
export async function routerPermission(
to: RouteItem,
from: RouteItem,
next: Function,
whiteList: string[],
asyncRoutes: AppRouteModule[],
basicRoutes: AppRouteModule[],
getAuthList: Function,
domain: string,
Message: Function
) {
// 已经存在 token, 进入用户登录页面
if (to.path == '/login' && from) {
// 从登录页面进入,直接进入登录页面
if (from.path === '/login' || '/') {
return next();
} else {
//已经存在 token, 从其他页面进入用户登录页面,直接返回来源页面
return next(from.path);
}
} else {
// 获取是否用户权限
const canAccess = await canUserAccess(to, whiteList, asyncRoutes, basicRoutes, getAuthList, domain)
if (canAccess) {
return next()
} else {
if (Message) {
Message({
message: "您没有权限访问页面,请联系系统管理员!",
type: "warning",
});
} else {
showMsg.error({
message: "您没有权限访问页面,请联系系统管理员!",
});
}
return false
}
}
}
/**
* 获取异步权限
* @param to
* @returns
*/
export async function canUserAccess(
to: RouteItem,
whiteList: string[],
asyncRoutes: AppRouteModule[],
basicRoutes: AppRouteModule[],
getAuthList: Function,
domain: string
) {
if (!to || to?.name === "Login") return false
try {
let accessRoutes = userStore.getAuthority || {}
if (accessRoutes?.menuNames && accessRoutes?.menuNames?.length === 0) {
// 获取用户异步路由权限
accessRoutes = await userStore.GetAuthority(getAuthList, domain)
// 生成用户所有路由权限
routeStore.GenerateRoutes(accessRoutes?.menuNames || [], asyncRoutes, basicRoutes)
}
const allRoutes = [...whiteList, ...accessRoutes?.menuNames]
return allRoutes.length > 0 && allRoutes.includes(to.name)
} catch (err) {
userStore.Logout(domain)
return false
}
}