VUE3 后台管理系统「路由鉴权」

预计阅读时间: 6 分钟

🌏 前言:

  在“VUE3 后台管理系统「模板构建」”文章中,详细的介绍了我使用 vue3.0vite2.0 构建的后台管理系统,虽然只是简单的一个后台管理系统,其中涉及的技术基本都覆盖了,基于 vue3 的 ”vue-routervuex,以及借助第三方开源插件来实现 vuex 数据持久化。前边只是介绍了 vue 后台管理系统的页面布局,以及一些常用的插件的使用,如:富文本编辑器、视频播放器、页面滚动条美化(前边忘记介绍了,此次文章中将会进行添加和补充)。

  本次文章主要介绍的是 vue-router 的动态匹配和动态校验,来实现不同账号不同权限,通过前端来对用户权限进行相应的限制;在一些没有访问权限的路径下访问时给予相应的提示以及后续相应的跳转复原等逻辑操作。用户鉴权,前端可以进行限制,也可以通过后台接口数据进行限制,之前开发过程中遇到过通过后台接口来动态渲染路由的,接下来介绍的是纯前端来做路由访问的限制。

🏀 路由配置:

Vue 路由配置
1import Layout from "../layout/Index.vue";
2import RouteView from "../components/RouteView.vue";
3
4const layoutMap = [
5	{
6		path: "/",
7		name: "Index",
8		meta: { title: "控制台", icon: "home" },
9		component: () => import("../views/Index.vue")
10	},
11	{
12		path: "/data",
13		meta: { title: "数据管理", icon: "database" },
14		component: RouteView,
15		children: [
16			{
17				path: "/data/list",
18				name: "DataList",
19				meta: { title: "数据列表", roles: ["admin"] },
20				component: () => import("../views/data/List.vue")
21			},
22			{
23				path: "/data/table",
24				name: "DataTable",
25				meta: { title: "数据表格" },
26				component: () => import("../views/data/Table.vue")
27			}
28		]
29	},
30	{
31		path: "/admin",
32		meta: { title: "用户管理", icon: "user" },
33		component: RouteView,
34		children: [
35			{
36				path: "/admin/user",
37				name: "AdminAuth",
38				meta: { title: "用户列表", roles: ["admin"] },
39				component: () => import("../views/admin/AuthList.vue")
40			},
41			{
42				path: "/admin/role",
43				name: "AdminRole",
44				meta: { title: "角色列表" },
45				component: () => import("../views/admin/RoleList.vue")
46			}
47		]
48	},
49	{
50		path: "user",
51		name: "User",
52		hidden: true /* 不在侧边导航展示 */,
53		meta: { title: "个人中心" },
54		component: () => import("../views/admin/User.vue")
55	},
56	{
57		path: "/error",
58		name: "NotFound",
59		hidden: true,
60		meta: { title: "Not Found" },
61		component: () => import("../components/NotFound.vue")
62	}
63];
64
65const routes = [
66	{
67		path: "/login",
68		name: "Login",
69		meta: { title: "用户登录" },
70		component: () => import("../views/Login.vue")
71	},
72	{
73		path: "/",
74		component: Layout,
75		children: [...layoutMap]
76	},
77	{ path: "/*", redirect: { name: "NotFound" } }
78];
79
80export { routes, layoutMap };
提示:
  • 此次路由列表分为两部分,其中一部分是默认路由,即无需权限校验的路由路径(如:Login 登录页);
  • 其中 layoutMap 中的路由元素是全部与路由路径相关的配置信息,即包裹所有用户权限的路径路由信息;
  • 路由鉴权最终限制的就是 layoutMap 数组中的数据元素,并且进行相应的筛选限制来达到限制路由访问的目的。

🚗 路由拦截:

路由拦截 - vue-router4.x 版本
1// vue-router4.0版写法
2import { createRouter, createWebHistory } from "vue-router";
3import { decode } from "js-base64";
4import { routes } from "./router";
5import NProgress from "nprogress";
6import "nprogress/nprogress.css";
7
8NProgress.configure({ showSpinner: false });
9
10const router = createRouter({
11	history: createWebHistory(),
12	routes: [...routes],
13	scrollBehavior(to, from, savedPosition) {
14		if (savedPosition) {
15			return savedPosition;
16		} else {
17			return { top: 0 };
18		}
19	}
20});
21
22// 路由拦截与下方vue-router3.x写法相同
路由拦截 - vue-router3.x 版本
1// vue-router3.x版写法
2import Vue from "vue";
3import VueRouter from "vue-router";
4import { decode } from "js-base64";
5import { routes } from "./router";
6import NProgress from "nprogress";
7import "nprogress/nprogress.css";
8
9NProgress.configure({ showSpinner: false });
10
11Vue.use(VueRouter);
12
13const router = new VueRouter({
14	mode: "history",
15	base: process.env.BASE_URL,
16	routes: [...routes],
17	scrollBehavior(to, from, savedPosition) {
18		if (savedPosition) {
19			return savedPosition;
20		} else {
21			return { top: 0 };
22		}
23	}
24});
25
26router.beforeEach((to, from, next) => {
27	NProgress.start();
28	const jwt = sessionStorage.getItem("jwt") || "";
29
30	document.title = jwt ? (to.meta.title ? to.meta.title + " - 管理应用" : "管理系统") : "系统登录";
31	if (to.path === "/login") {
32		!!jwt ? next("/") : next();
33	} else {
34		if (from.path === "/login" && !jwt) {
35			NProgress.done(true);
36			next(false);
37			return;
38		}
39		if (!!jwt) {
40			if (to.meta.hasOwnProperty("roles")) {
41				let roles = to.meta.roles || [],
42					{ role } = jwt && JSON.parse(decode(jwt));
43				roles.includes(role) ? next() : next("/error");
44				return;
45			}
46			next();
47		} else {
48			next("/login");
49		}
50	}
51});
52
53router.afterEach(() => {
54	NProgress.done();
55});
56
57export default router;
📚 提示
  • 依据访问的路由节点的信息,进行动态的路由权限校验,有访问权限的放过,没有访问权限的路由进行相应的拦截处理;
  • nprogress 为路由访问的进度条,访问时有相应的进度条指示,也有转动的小菊花(即路由加载指示器)可通过相关配置进行相关的配置;
  • 当有用户信息时访问“/login”时则默认重定向到系统控制台页,反之则不进行拦截,让其跳转至登录页面;
  • 当访问非登录页面时,要进行 role 管理员权限的校验,有权限则放过,继续向后执行,反之则重定向到“/error”页面提示其无权访问当前路径。

🍎 路由过滤:

路由处理
1/* 处理权限 */
2export const hasPermission = (route, role) => {
3	if (route["meta"] && route.meta.hasOwnProperty("roles")) {
4		return route.meta.roles.includes(role);
5	}
6	return true;
7};
8
9/* 过滤数组 */
10export const filterAsyncRouter = (routers, role) => {
11	let tmp = [];
12	tmp = routers.filter(el => {
13		if (hasPermission(el, role)) {
14			if (el["children"] && el.children.length) {
15				el.children = filterAsyncRouter(el.children, role);
16			}
17			return true;
18		}
19		return false;
20	});
21	return tmp;
22};
👀 注:

此两函数为封装的过滤指定权限的路由数据,返回过滤后的数据(即当前账号有权访问的页面);

vuex 存储和过滤路由信息

vuex 处理
1import Vue from "vue";
2import Vuex from "vuex";
3import { layoutMap } from "../router/router";
4import { filterAsyncRouter } from "../utils/tool";
5import createPersistedState from "vuex-persistedstate";
6import SecureLS from "secure-ls";
7import { CLEAR_USER, SET_USER, SET_ROUTES } from "./mutation-types";
8
9Vue.use(Vuex);
10
11const state = {
12	users: null,
13	routers: []
14};
15
16const getters = {};
17
18const mutations = {
19	[CLEAR_USER](state) {
20		state.users = null;
21		state.routers.length = 0;
22	},
23	[SET_USER](state, payload) {
24		state.users = payload;
25	},
26	[SET_ROUTES](state, payload) {
27		state.routers = payload;
28	}
29};
30
31const ls = new SecureLS({
32	encodingType: "aes" /* 加密方式 */,
33	isCompression: false /* 压缩数据 */,
34	encryptionSecret: "vue" /* 加密密钥 */
35});
36
37const actions = {
38	clearUser({ commit }) {
39		commit(CLEAR_USER);
40	},
41	setUser({ commit }, payload) {
42		let deepCopy = JSON.parse(JSON.stringify(layoutMap)),
43			accessedRouters = filterAsyncRouter(deepCopy, payload.role);
44		commit(SET_USER, payload);
45		commit(SET_ROUTES, accessedRouters);
46	}
47};
48
49const myPersistedState = createPersistedState({
50	key: "store",
51	storage: window.sessionStorage,
52	// storage: {
53	//     getItem: state => ls.get(state),
54	//     setItem: (state, value) => ls.set(state, value),
55	//     removeItem: state => ls.remove(state)
56	// } /* 永久存储 */
57	reducer(state) {
58		return { ...state };
59	}
60});
61
62export default new Vuex.Store({
63	state,
64	getters,
65	mutations,
66	actions
67	// plugins: [myPersistedState]
68});
👀 注解:
  • secure-ls 为加密工具函数,加密级别比较高,一般不可破解,基于密钥和私钥进行加密和解密,使用规则请参考 github;
  • vuex-persistedstate 为持久化处理 vuex 状态使用的,存储方式主要有 sessionStoragelocalStoragecookies,一般常用前两种方式;
  • 借助 vuex 来遍历过滤指定权限的路由,然后在 Menu.vue 中进行渲染和遍历。

🍉 路由列表渲染:

列表代码示例
1<template>
2	<a-layout-sider class="sider" v-model="collapsed" collapsible :collapsedWidth="56">
3		<div class="logo">
4			<a-icon type="ant-design" />
5		</div>
6		<a-menu
7			class="menu"
8			theme="dark"
9			mode="inline"
10			:defaultOpenKeys="[defaultOpenKeys]"
11			:selectedKeys="[$route.path]"
12			:inlineIndent="16"
13		>
14			<template v-for="route in routers">
15				<template v-if="!route['hidden']">
16					<a-sub-menu v-if="route.children && route.children.length" :key="route.path">
17						<span slot="title">
18							<a-icon :type="route.meta['icon']" />
19							<span>{{ route.meta.title }}</span>
20						</span>
21						<a-menu-item v-for="sub in route.children" :key="sub.path">
22							<router-link :to="{ path: sub.path }">
23								<a-icon v-if="sub.meta['icon']" :type="sub.meta['icon']" />
24								<span>{{ sub.meta.title }}</span>
25							</router-link>
26						</a-menu-item>
27					</a-sub-menu>
28					<a-menu-item v-else :key="route.path">
29						<router-link :to="{ path: route.path }">
30							<a-icon :type="route.meta['icon']" />
31							<span>{{ route.meta.title }}</span>
32						</router-link>
33					</a-menu-item>
34				</template>
35			</template>
36		</a-menu>
37	</a-layout-sider>
38</template>
39
40<script>
41	import { mapState } from "vuex";
42
43	export default {
44		name: "Sider",
45		data() {
46			return {
47				collapsed: false,
48				defaultOpenKeys: ""
49			};
50		},
51		computed: {
52			...mapState(["routers"])
53		},
54		created() {
55			this.defaultOpenKeys = "/" + this.$route.path.split("/")[1];
56		}
57	};
58</script>
59
60<style lang="less" scoped>
61	.sider {
62		height: 100vh;
63		overflow: hidden;
64		overflow-y: scroll;
65		&::-webkit-scrollbar {
66			display: none;
67		}
68
69		.logo {
70			height: 56px;
71			line-height: 56px;
72			font-size: 30px;
73			color: #fff;
74			text-align: center;
75			background-color: #002140;
76		}
77
78		.menu {
79			width: auto;
80		}
81	}
82</style>
83
84<style>
85	ul.ant-menu-inline-collapsed > li.ant-menu-item,
86	ul.ant-menu-inline-collapsed > li.ant-menu-submenu > div.ant-menu-submenu-title {
87		padding: 0 16px !important;
88		text-align: center;
89	}
90</style>
❗️ 注:

该菜单渲染是基于 Vue2.x 和Ant Design Vue来编辑实现的。

菜单 page 展示
1<template>
2	<el-aside :width="isCollapse ? `64px` : `200px`">
3		<div class="logo">
4			<img src="@/assets/img/avatar.png" alt="logo" draggable="false" />
5			<p>Vite2 Admin</p>
6		</div>
7		<el-menu
8			background-color="#001529"
9			text-color="#eee"
10			active-text-color="#fff"
11			router
12			unique-opened
13			:default-active="route.path"
14			:collapse="isCollapse"
15		>
16			<template v-for="item in routers" :key="item.name">
17				<template v-if="!item['hidden']">
18					<el-submenu v-if="item.children && item.children.length" :index="concatPath(item.path)">
19						<template #title>
20							<i :class="item.meta.icon"></i>
21							<span>{{ item.meta.title }}</span>
22						</template>
23						<template v-for="sub in item.children" :key="sub.name">
24							<el-menu-item :index="concatPath(item.path, sub.path)">
25								<i :class="sub.meta['icon']"></i>
26								<template #title>{{ sub.meta.title }}</template>
27							</el-menu-item>
28						</template>
29					</el-submenu>
30					<el-menu-item v-else :index="concatPath(item.path)">
31						<i :class="item.meta['icon']"></i>
32						<template #title>{{ item.meta.title }}</template>
33					</el-menu-item>
34				</template>
35			</template>
36		</el-menu>
37		<div class="fold" @click="changeCollapse">
38			<i v-show="!isCollapse" class="el-icon-d-arrow-left"></i>
39			<i v-show="isCollapse" class="el-icon-d-arrow-right"></i>
40		</div>
41	</el-aside>
42</template>
43
44<script>
45	import { computed, reactive, toRefs } from "vue";
46	import { useRoute } from "vue-router";
47	import { useStore } from "vuex";
48
49	export default {
50		setup() {
51			const route = useRoute();
52			const store = useStore();
53			const state = reactive({ isCollapse: false });
54			const routers = computed(() => store.state.routers);
55
56			const changeCollapse = () => {
57				state.isCollapse = !state.isCollapse;
58			};
59
60			const concatPath = (p_path, c_path = "") => {
61				return `${p_path !== "" ? "/" + p_path : "/"}${c_path !== "" ? "/" + c_path : ""}`;
62			};
63
64			return {
65				route,
66				routers,
67				concatPath,
68				changeCollapse,
69				...toRefs(state)
70			};
71		}
72	};
73</script>
👀 注:
  • 该菜单导航是基于 vue3 和支持 Vue3 版本的Element-Plus实现的,详细参数配置请参考 Element-plus 官网;
  • 此处获取的路由数组即鉴权过滤后的路由数组数据;此菜单将会依据登录信息动态遍历生成指定菜单数据。

🍌 总结:

  结合之前的模板代码,就可以完整的搭建出一个带有前端权限校验的 vue 后台管理系统,主要是梳理清路由数据和过滤后的路由鉴权后的路由数据信息。主要代码就是上述封装的过滤和权限校验函数。后续将放开后台模板代码,模板代码完善中......🍎🍎🍎