权限管理
分类:
- 页面权限
- 功能(按钮)权限
- 接口权限
vue3-element-admin 的实现方案
一般我们在业务中将 路由可以分为两种,constantRoutes
和 asyncRoutes
。
-
constantRoutes: 代表那些不需要动态判断权限的路由,如登录页、404(或者不存在的路由)、首页、数据大屏等通用页面。
-
asyncRoutes: 代表那些需求动态判断权限并通过
addRoutes
动态添加的页面。
后台管理系统中的路由都具有不同的访问权限,侧边菜单栏也是同理,需要根据权限,异步生成。
整体步骤都十分类似:
我们在登录后获取 token ,将其存入 localStorage 中,用来"象征用户身份"。
登录表单提交业务实现:
js
/** 登录表单提交 */
function handleLoginSubmit() {
loginFormRef.value?.validate((valid: boolean) => {
if (valid) {
loading.value = true;
userStore
.login(loginData.value)
.then(() => {
const { path, queryParams } = parseRedirect();
router.push({ path: path, query: queryParams });
})
.catch(() => {
getCaptcha();
})
.finally(() => {
loading.value = false;
});
}
});
}
调用登录接口,存储 token 到localStorage 中。
js
/**
* 登录
* @param {LoginData}
* @returns
*/
function login(loginData: LoginData) {
return new Promise<void>((resolve, reject) => {
AuthAPI.login(loginData)
.then((data) => {
const { tokenType, accessToken } = data;
localStorage.setItem(TOKEN_KEY, tokenType + " " + accessToken); // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
resolve();
})
.catch((error) => {
reject(error);
});
});
}
登录接口。
js
class AuthAPI {
/** 登录 接口*/
static login(data: LoginData) {
const formData = new FormData();
formData.append("username", data.username);
formData.append("password", data.password);
formData.append("captchaKey", data.captchaKey);
formData.append("captchaCode", data.captchaCode);
return request<any, LoginResult>({
url: "/api/v1/auth/login",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
}
// ...
}
获取验证码。
js
/** 获取验证码 */
function getCaptcha() {
AuthAPI.getCaptcha().then((data) => {
loginData.value.captchaKey = data.captchaKey;
captchaBase64.value = data.captchaBase64;
});
}
通过上述过程,我们已经成功获取 token 并存储在了 localStorage 中。
之后我们就可以根据 token "用户身份" 来进行权限控制了。
token 可以获取用户角色,而不同角色对应不同权限的路由,然后通过 router.addRoutes 动态挂载路由。
调用获取路由接口,获取路由表,进行动态路由处理,将其与常量路由进行拼接,得到总路由。
js
/**
* 生成动态路由
*/
function generateRoutes() {
return new Promise<RouteRecordRaw[]>((resolve, reject) => {
MenuAPI.getRoutes()
.then((data) => {
const dynamicRoutes = transformRoutes(data);
routes.value = constantRoutes.concat(dynamicRoutes);
resolve(dynamicRoutes);
})
.catch((error) => {
reject(error);
});
});
}
这里使用 mock 数据:
js
export default defineMock([
{
url: "menus/routes",
method: ["GET"],
body: {
code: "00000",
data: [
{
path: "/doc",
component: "Layout",
redirect: "https://juejin.cn/post/7228990409909108793",
name: "/doc",
meta: {
title: "平台文档",
icon: "document",
hidden: false,
alwaysShow: false,
params: null,
},
children: [
{
path: "internal-doc",
component: "demo/internal-doc",
name: "InternalDoc",
meta: {
title: "平台文档(内嵌)",
icon: "document",
hidden: false,
alwaysShow: false,
params: null,
},
},
{
path: "https://juejin.cn/post/7228990409909108793",
name: "Https://juejin.cn/post/7228990409909108793",
meta: {
title: "平台文档(外链)",
icon: "link",
hidden: false,
alwaysShow: false,
params: null,
},
},
],
},
{
path: "/multi-level",
component: "Layout",
name: "/multiLevel",
meta: {
title: "多级菜单",
icon: "cascader",
hidden: false,
alwaysShow: true,
params: null,
},
children: [
{
path: "multi-level1",
component: "demo/multi-level/level1",
name: "MultiLevel1",
meta: {
title: "菜单一级",
icon: "",
hidden: false,
alwaysShow: true,
params: null,
},
children: [
{
path: "multi-level2",
component: "demo/multi-level/children/level2",
name: "MultiLevel2",
meta: {
title: "菜单二级",
icon: "",
hidden: false,
alwaysShow: false,
params: null,
},
children: [
{
path: "multi-level3-1",
component: "demo/multi-level/children/children/level3-1",
name: "MultiLevel31",
meta: {
title: "菜单三级-1",
icon: "",
hidden: false,
keepAlive: true,
alwaysShow: false,
params: null,
},
},
{
path: "multi-level3-2",
component: "demo/multi-level/children/children/level3-2",
name: "MultiLevel32",
meta: {
title: "菜单三级-2",
icon: "",
hidden: false,
keepAlive: true,
alwaysShow: false,
params: null,
},
},
],
},
],
},
],
},
],
msg: "一切ok",
},
},
]);
转换路由数据为组件(根据实际业务进行弹性操作)。
js
/**
* 转换路由数据为组件
*/
const transformRoutes = (routes: RouteVO[]) => {
const asyncRoutes: RouteRecordRaw[] = [];
routes.forEach((route) => {
const tmpRoute = { ...route } as RouteRecordRaw;
// 顶级目录,替换为 Layout 组件
if (tmpRoute.component?.toString() == "Layout") {
tmpRoute.component = Layout;
} else {
// 其他菜单,根据组件路径动态加载组件
const component = modules[`../../views/${tmpRoute.component}.vue`];
if (component) {
tmpRoute.component = component;
} else {
tmpRoute.component = modules[`../../views/error-page/404.vue`];
}
}
if (tmpRoute.children) {
tmpRoute.children = transformRoutes(route.children);
}
asyncRoutes.push(tmpRoute);
});
return asyncRoutes;
};
vue-element-admin 的实现方案
当然他们只是实现的写法不同,大致的思路还是相同的。
为了便于理解主要思路和提取关键代码,下面使用尚硅谷硅谷甄选项目的实现方案代码讲解(和vue-element-admin的大差不差)。
先看下用户信息:
在用户 store 中过滤用户的异步路由(用路由的名字进行过滤区分-所以要保证名字的唯一):
js
//用于过滤当前用户需要展示的异步路由
// asyncRoute 所有异步路由 routes 用户拥有权限的路由
function filterAsyncRoute(asnycRoute: any, routes: any) {
return asnycRoute.filter((item: any) => {
if (routes.includes(item.name)) {
if (item.children && item.children.length > 0) {
// 新的 item.children 也需要进行同样的过滤操作
item.children = filterAsyncRoute(item.children, routes);
}
return true;
}
})
}
获取用户个人信息后再 store 中操作路由:
js
//计算当前用户需要展示的异步路由
let userAsyncRoute = filterAsyncRoute(cloneDeep(asnycRoute), result.data.routes);
//菜单需要的数路由数据
this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute];
//目前路由器管理的只有常量路由,再异步获取路由路由信息后,异步路由、任意路由动态追加到路由管理中
[...userAsyncRoute, anyRoute].forEach((route: any) => {
router.addRoute(route);
});
路由表:
js
//对外暴露配置路由(常量路由):全部用户都可以访问到的路由
export const constantRoute = [
{
//登录
path: '/login',
component: () => import('@/views/login/index.vue'),
name: 'login',
meta: {
title: '登录',//菜单标题
hidden: true,//代表路由标题在菜单中是否隐藏 true:隐藏 false:不隐藏
icon: "Promotion",//菜单文字左侧的图标,支持element-plus全部图标
}
}
,
{
//登录成功以后展示数据的路由
path: '/',
component: () => import('@/layout/index.vue'),
name: 'layout',
meta: {
title: '',
hidden: false,
icon: ''
},
redirect: '/home',
children: [
{
path: '/home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
hidden: false,
icon: 'HomeFilled'
}
}
]
},
{
//404
path: '/404',
component: () => import('@/views/404/index.vue'),
name: '404',
meta: {
title: '404',
hidden: true,
icon: 'DocumentDelete'
}
},
{
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: {
title: '权限管理',
icon: 'Lock'
},
redirect: '/acl/user',
children: [
{
path: '/acl/user',
component: () => import('@/views/acl/user/index.vue'),
name: 'User',
meta: {
title: '用户管理',
icon: 'User'
}
},
{
path: '/acl/role',
component: () => import('@/views/acl/role/index.vue'),
name: 'Role',
meta: {
title: '角色管理',
icon: 'UserFilled'
}
},
{
path: '/acl/permission',
component: () => import('@/views/acl/permission/index.vue'),
name: 'Permission',
meta: {
title: '菜单管理',
icon: 'Monitor'
}
}
]
}
,
{
path: '/product',
component: () => import('@/layout/index.vue'),
name: 'Product',
meta: {
title: '商品管理',
icon: 'Goods',
},
redirect: '/product/trademark',
children: [
{
path: '/product/trademark',
component: () => import('@/views/product/trademark/index.vue'),
name: "Trademark",
meta: {
title: '品牌管理',
icon: 'ShoppingCartFull',
}
},
{
path: '/product/attr',
component: () => import('@/views/product/attr/index.vue'),
name: "Attr",
meta: {
title: '属性管理',
icon: 'ChromeFilled',
}
},
{
path: '/product/spu',
component: () => import('@/views/product/spu/index.vue'),
name: "Spu",
meta: {
title: 'SPU管理',
icon: 'Calendar',
}
},
{
path: '/product/sku',
component: () => import('@/views/product/sku/index.vue'),
name: "Sku",
meta: {
title: 'SKU管理',
icon: 'Orange',
}
},
]
}
]
//任意路由
export const anyRoute = {
//任意路由
path: '/:pathMatch(.*)*',
redirect: '/404',
name: 'Any',
meta: {
title: '任意路由',
hidden: true,
icon: 'DataLine'
}
}
路由器对象(初始化的时候只注册了常量路由):
js
//创建路由器
let router = createRouter({
//路由模式hash
history: createWebHashHistory(),
routes: constantRoute,
//滚动行为
scrollBehavior() {
return {
left: 0,
top: 0
}
}
});
路由鉴权守卫(这里在某些业务情况下可以增加白名单,对权限进行再一次划分):
js
//路由鉴权:鉴权,项目当中路由能不能被的权限的设置(某一个路由什么条件下可以访问、什么条件下不可以访问)
import router from '@/router';
import setting from './setting';
import nprogress from 'nprogress';
//引入进度条样式
import "nprogress/nprogress.css";
nprogress.configure({ showSpinner: false });
//获取用户相关的小仓库内部token数据,去判断用户是否登录成功
import useUserStore from './store/modules/user';
import pinia from './store';
let userStore = useUserStore(pinia);
//全局守卫:项目当中任意路由切换都会触发的钩子
//全局前置守卫
router.beforeEach(async (to: any, from: any, next: any) => {
document.title = `${setting.title} - ${to.meta.title}`
//to:你将要访问那个路由
//from:你从来个路由而来
//next:路由的放行函数
nprogress.start();
//获取token,去判断用户登录、还是未登录
let token = userStore.token;
//获取用户名字
let username = userStore.username;
//用户登录判断
if (token) {
//登录成功,访问login,不能访问,指向首页
if (to.path == '/login') {
next({ path: '/' })
} else {
//登录成功访问其余六个路由(登录排除)
//有用户信息
if (username) {
//放行
next();
} else {
//如果没有用户信息,在守卫这里发请求获取到了用户信息再放行
try {
//获取用户信息
await userStore.userInfo();
//放行
//万一:刷新的时候是异步路由,有可能获取到用户信息、异步路由还没有加载完毕,出现空白的效果
next({...to,replace:true})
} 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 } });
}
}
})
//全局后置守卫
router.afterEach((to: any, from: any) => {
nprogress.done();
});
//第一个问题:任意路由切换实现进度条业务 ---nprogress
//第二个问题:路由鉴权(路由组件访问权限的设置)
//全部路由组件:登录|404|任意路由|首页|数据大屏|权限管理(三个子路由)|商品管理(四个子路由)
//用户未登录:可以访问login,其余六个路由不能访问(指向login)
//用户登录成功:不可以访问login[指向首页],其余的路由可以访问
这里用到了 next({...to,replace:true}) ,它的原理是:
router.addRoutes是同步方法,整体流程:
- 路由跳转,根据目标地址从router中提取route信息,由于此时还没addRouters,所以解析出来的route是个空的,不包含组件。
- 执行beforeEach钩子函数,然后内部会动态添加路由,但此时route已经生成了,不是说router.addRoutes后,这个route会自动更新,如果直接next(),最终渲染的就是空的。
- 调用next({ ...to, replace: true }),会abort刚刚的跳转,然后重新走一遍上述逻辑,这时从router中提取的route信息就包含组件了,之后就和正常逻辑一样了。
主要原因就是生成route是在执行beforeEach钩子之前。
页面权限总结
页面权限:
- 用户登录后,服务端返回一个权限树(用树形结构呈现权限数据),然后我们去解析这个树形结构,得到我们需要的路由表(动态路由对象),本质上就是一个由路由对象为元素的数组。
- 然后通过 vue 中的动态路由,也就是 addRoutes,动态的添加路由。
- 最后根据路由去渲染多级菜单栏。
按钮权限
可以自定义一个全局指定,用于按钮权限的判断。
js
import pinia from '@/store';
import useUserStore from '@/store/modules/user';
let userStore =useUserStore(pinia)
export const isHasButton = (app: any) => {
//获取对应的用户仓库
//全局自定义指令:实现按钮的权限
app.directive('has', {
//代表使用这个全局自定义指令的DOM|组件挂载完毕的时候会执行一次
mounted(el:any,options:any) {
//自定义指令右侧的数值:如果在用户信息buttons数组当中没有
//从DOM树上干掉
if(!userStore.buttons.includes(options.value)){
el.parentNode.removeChild(el);
}
},
})
}
el 为该元素,options 为:
在 app.ts 中引入。
js
import {isHasButton} from '@/directive/has.ts';
isHasButton(app);
使用:
js
v-has="`btn.Trademark.add`"
按钮(功能)权限总结
服务端返回的权限树中包含了指定页面下指定按钮的数据,可以通过 v-if 或者 disable 来控制按钮权限。
接口权限
配合功能权限,一般由服务端进行处理。