【Vue】权限控制

秀秀_heo 2024-09-17 09:03:04 阅读 97

权限管理

分类:

页面权限功能(按钮)权限接口权限

vue3-element-admin 的实现方案

一般我们在业务中将 路由可以分为两种,<code>constantRoutes 和 asyncRoutes

constantRoutes: 代表那些不需要动态判断权限的路由,如登录页、404(或者不存在的路由)、首页、数据大屏等通用页面。

asyncRoutes: 代表那些需求动态判断权限并通过 addRoutes 动态添加的页面。

后台管理系统中的路由都具有不同的访问权限,侧边菜单栏也是同理,需要根据权限,异步生成。

整体步骤都十分类似:

我们在登录后获取 token ,将其存入 localStorage 中,用来“象征用户身份”。

登录表单提交业务实现:

/** 登录表单提交 */

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 中。

/**

* 登录

* @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);

});

});

}

登录接口。

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",

},

});

}

// ...

}

获取验证码。

/** 获取验证码 */

function getCaptcha() {

AuthAPI.getCaptcha().then((data) => {

loginData.value.captchaKey = data.captchaKey;

captchaBase64.value = data.captchaBase64;

});

}

通过上述过程,我们已经成功获取 token 并存储在了 localStorage 中。

之后我们就可以根据 token “用户身份” 来进行权限控制了。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

token 可以获取用户角色,而不同角色对应不同权限的路由,然后通过 router.addRoutes 动态挂载路由。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

调用获取路由接口,获取路由表,进行动态路由处理,将其与常量路由进行拼接,得到总路由。

<code>/**

* 生成动态路由

*/

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 数据:

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",

},

},

]);

转换路由数据为组件(根据实际业务进行弹性操作)。

/**

* 转换路由数据为组件

*/

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 中过滤用户的异步路由(用路由的名字进行过滤区分-所以要保证名字的唯一):

<code>//用于过滤当前用户需要展示的异步路由

// 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 中操作路由:

//计算当前用户需要展示的异步路由

let userAsyncRoute = filterAsyncRoute(cloneDeep(asnycRoute), result.data.routes);

//菜单需要的数路由数据

this.menuRoutes = [...constantRoute, ...userAsyncRoute, anyRoute];

//目前路由器管理的只有常量路由,再异步获取路由路由信息后,异步路由、任意路由动态追加到路由管理中

[...userAsyncRoute, anyRoute].forEach((route: any) => {

router.addRoute(route);

});

路由表:

//对外暴露配置路由(常量路由):全部用户都可以访问到的路由

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'

}

}

路由器对象(初始化的时候只注册了常量路由):

//创建路由器

let router = createRouter({

//路由模式hash

history: createWebHashHistory(),

routes: constantRoute,

//滚动行为

scrollBehavior() {

return {

left: 0,

top: 0

}

}

});

路由鉴权守卫(这里在某些业务情况下可以增加白名单,对权限进行再一次划分):

//路由鉴权:鉴权,项目当中路由能不能被的权限的设置(某一个路由什么条件下可以访问、什么条件下不可以访问)

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撸后台 系列二(登录权限篇) - 掘金 (juejin.cn)评论区

页面权限总结

页面权限:

用户登录后,服务端返回一个权限树(用树形结构呈现权限数据),然后我们去解析这个树形结构,得到我们需要的路由表(动态路由对象),本质上就是一个由路由对象为元素的数组。然后通过 vue 中的动态路由,也就是 addRoutes,动态的添加路由。最后根据路由去渲染多级菜单栏。

按钮权限

可以自定义一个全局指定,用于按钮权限的判断。

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 中引入。

<code>import { -- -->isHasButton} from '@/directive/has.ts';

isHasButton(app);

使用:

v-has="`btn.Trademark.add`"code>

按钮(功能)权限总结

服务端返回的权限树中包含了指定页面下指定按钮的数据,可以通过 v-if 或者 disable 来控制按钮权限。

接口权限

配合功能权限,一般由服务端进行处理。



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。