依赖包版本
vue3.2.25 vue-router4.0.0 pinia2.0.14 mockjs1.1.0
方案
我选择的方案是后端仅存储关键的path信息,返回一个string[]类型的数据,这样做的好处是,后台存储的数据足够简单,前端调试的时候也不需要依赖于后台,开了mock或者修改一下比对函数,就可以轻松的访问所有路由。
逻辑
大致的逻辑是,进入系统判断当前页面是否是白名单页面,是的话直接进入,如果不是的话,判断路由是否生成,如果没生成的话,生成路由,然后跳转。
VueRouter 类型扩展
原有的 RouteRecordRaw 及 RouteMeta 不能满足我们的需求,我们扩展一下。
在src目录下,新建types目录,创建router.d.ts文件
import { Component } from 'vue';
import { RouteMeta, RouteRecordName, RouteRecordRaw } from 'vue-router';
export type AppRouteRecordRaw = {
meta: RouteMeta;
name: RouteRecordName;
orderBy?: number;
children?: AppRouteRecordRaw[];
hidden?: boolean;
} & RouteRecordRaw;
declare module 'vue-router' {
interface RouteMeta {
title: string;
noPerm?: boolean;
icon?: Component;
url?: string;
}
}
之后我们使用AppRouteRecordRaw来定义路由类型,orderBy字段用于排序,hidden字段用于标注当前路由是否在侧边菜单显示,noPerm字段用于判断当前路由是否需要后端权限,icon字段为图标,url字段表示当前字段的外链地址。
其中icon的类型,可以按照你喜欢的方式自定义,我这里选择传入一个Component。
如果写了之后没生效,可以看一下tsconfig.json的include字段。
路由
路由目录结构
- src
- router
- guard // 拦截器文件夹
- routes // 路由文件夹
- modules // 存放对应的模块的路由
- index.ts
- constants.ts // 静态路由,无需权限
- index.ts
constants
constants.ts文件,用来存放前端的一些静态路由,如欢迎页 404 403 等,按实际需求添加即可,我这里添加一个示例的首页。
import { AppRouteRecordRaw } from '@/types/router';
import { RouteRecordRaw } from 'vue-router';
export const LAYOUT = () => import('@/layouts/default/index.vue');
export const BLANK_LAYOUT = () => import('@/layouts/default/blankLayout.vue');
const routes: AppRouteRecordRaw[] = [
{
path: '/',
name: 'Home',
redirect: '/dashboard',
meta: {
title: 'Home'
}
}
];
export const constants = routes as RouteRecordRaw[];
modules
我们把对应的侧边栏一级菜单的路由作为路由文件,与views下的目录一一对应,创建路由文件,例如:
import { LAYOUT } from '@/router/constants';
import { AppRouteRecordRaw } from '@/types/router';
import Icon from '@/components/Icon/index.vue';
const dashboard: AppRouteRecordRaw = {
orderBy: 0,
path: '/dashboard',
name: 'Dashboard',
redirect: '/dashboard/analysis',
component: LAYOUT,
meta: {
title: 'Dashboard',
icon: h(Icon, { name: 'dashboard' })
},
children: [
{
path: 'analysis',
name: 'DashboardAnalysis',
component: () => import('@/views/dashboard/analysis/index.vue'),
meta: {
title: '分析页',
icon: h(Icon, { name: 'analysis' })
}
},
{
path: 'workbench',
name: 'DashboardWorkbench',
component: () => import('@/views/dashboard/workbench/index.vue'),
meta: {
title: '工作台',
icon: h(Icon, { name: 'workbench' })
}
}
]
};
export default dashboard;
这里的 Icon 是一个SVG图标组件,不影响逻辑,不赘述。
我个人认为,modules下的文件与views下的目录一一对应,能够减少心智负担,寻找对应的路由页面无需查看路由文件,毕竟我们的路由是自动收集的。
路由自动收集
我们使用import.meta.globEager自动收集modules下的路由文件。
// /src/router/routes/index.ts
import { AppRouteRecordRaw } from '@/types/router';
import { RouteRecordRaw } from 'vue-router';
const modules = import.meta.globEager('./modules/*.ts');
const routeModules: AppRouteRecordRaw[] = [];
Object.keys(modules).forEach((key) => {
const route: AppRouteRecordRaw = modules[key].default;
routeModules.push(route);
});
export const asyncRoutes = routeModules.sort((current, next) => (current.orderBy || 0) - (next.orderBy || 0)) as RouteRecordRaw[];
创建路由
// /src/router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router';
import { constants } from './constants';
const router = createRouter({
history: createWebHashHistory(),
routes: [
...constants
]
});
export default router;
静态路由就已经创建好了,到main.ts注册即可,如果想要看效果的话,可以把asyncRoutes引入到routes里查看,接下来,我们去弄pinia,搞定asyncRoutes比对生成最终路由的问题。
pinia
创建store目录,新建index.ts文件夹,到main.ts注册即可。
// /src/store/index.ts
import { createPinia } from 'pinia';
const store = createPinia();
export default store;
permission
接着在store目录下新建modules目录,用来存放对应的状态文件,我们新建一个permission.ts处理权限相关的数据,该文件无需额外处理,创建后引入使用即可。
// /src/store/modules/permission.ts
import { onLogin } from '@/api/app';
import { filterAsyncRoutes } from '@/helper/router';
import { asyncRoutes } from '@/router/routes';
import { defineStore } from 'pinia';
import { RouteRecordRaw } from 'vue-router';
interface IPermissionStore {
authRoutes: RouteRecordRaw []
}
export const usePermissionStore = defineStore('permission', () => {
const store: IPermissionStore = reactive({
authRoutes: []
});
async function generateRoutes() {
// 这里是因为我之前封装的 useRequest 的妥协,用来请求对应权限列表的,可按照自己的axios封装修改,重要的是取出permList列表即可。
const { data: { data: { permList = [] } = {} } } = await onLogin().instance;
store.authRoutes = filterAsyncRoutes(asyncRoutes, permList);
}
return {
...toRefs(store),
generateRoutes
};
});
mock数据如下
import { MockMethod } from 'vite-plugin-mock';
const appMocks: MockMethod[] = [
{
url: '/api/login',
method: 'get',
timeout: 1000,
response: () => {
return {
code: 200,
msg: '',
data: {
permList: [
'/dashboard',
'/dashboard/analysis',
'/dashboard/workbench'
]
}
};
}
}
];
export default appMocks;
filterAsyncRoutes 则是将前端全量路由与后台权限数组做比对,配合vue-router的写法,做了一个递归判断。
// /src/helper/router.ts
import { RouteRecordRaw } from 'vue-router';
import { cloneDeep } from 'lodash-es';
export function filterAsyncRoutes(routes: RouteRecordRaw[], permList: string[], prefix = ''): RouteRecordRaw[] {
const res: RouteRecordRaw[] = [];
routes.forEach((route) => {
const tmp: RouteRecordRaw = cloneDeep<RouteRecordRaw>(route);
const path = tmp.path.charAt(0) === '/' ? tmp.path : `${prefix}${tmp.path}`;
if(tmp.meta?.noPerm) {
res.push(tmp);
} else if(permList.includes(path)) {
if(tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, permList, `${prefix}${tmp.path}/`);
}
res.push(tmp);
}
});
return res;
}
到这一步,我们需要的权限路由就已经存放在pinia中的authRoutes数组下了,之后,我们只需要在路由拦截中判断添加即可。
添加路由
我们到/src/router/guard目录下新建permissionGuard.ts目录,用来专门处理权限相关的拦截器。
import { usePermissionStore } from '@/store/modules/permisssion';
import { Router } from 'vue-router';
export function createPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const permissionStore = usePermissionStore();
if(!permissionStore.authRoutes.length) {
await permissionStore.generateRoutes();
permissionStore.authRoutes.map((route) => {
router.addRoute(route);
});
next(to);
}
next();
});
}
判断当前路由是否生成,如果没生成就生成路由再跳转到下一页。
这里实际处理会更复杂,比如对login register 404 403等页面添加白名单处理,无需添加权限路由这一步,这里依据对应的业务实现即可,我这里不做登录这些。
为了更好的管理guard目录下的拦截器,我们在/src/router/guard目录下新建一个index.ts统一管理。
import { Router } from 'vue-router';
import { createPermissionGuard } from './permissionGuard';
export function createGuard(router: Router) {
createPermissionGuard(router);
}
在/src/router/index.ts中注册
import { createRouter, createWebHashHistory } from 'vue-router';
import { constants } from './constants';
import { createGuard } from './guard';
const router = createRouter({
history: createWebHashHistory(),
routes: [
...constants
]
});
createGuard(router);
export default router;
现在路由的生成逻辑就完成了。
生成侧边菜单
NaiveUI 的侧边栏菜单使用比较简单,确定对应的数据源及渲染函数即可。
<script lang="ts" setup>
import { renderMenuLabel, renderIcon } from '@/helper/router';
import { useAppStore } from '@/store/modules/app';
import { usePermissionStore } from '@/store/modules/permisssion';
import { useRoute } from 'vue-router';
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const route = useRoute();
</script>
<template>
<div
class="h-full fixed top-0 left-0 transition-all duration-300 z-30 flex flex-col shadow-lg"
:class="[appStore.collapsed ? 'w-sidebar--collapsed' : 'w-sidebar']"
>
<div class="h-header flex items-center justify-center font-bold text-medium text-primary whitespace-nowrap overflow-hidden">
{{ appStore.collapsed ? 'N' : 'Naive Template' }}
</div>
<div class="flex-1 overflow-auto">
<NMenu
:value="(route.name as string)"
:collapsed="appStore.collapsed"
:collapsed-width="56"
:indent="20"
:options="permissionStore.authRoutes"
key-field="name"
:render-label="(route: any) => renderMenuLabel(route)"
:render-icon="(route: any) => renderIcon(route)"
/>
</div>
</div>
</template>
其中renderMenuLabel及renderIcon控制渲染的结果。
// /src/helper/router.ts
import { RouteRecordRaw, RouterLink } from 'vue-router';
import { cloneDeep } from 'lodash-es';
import { AppRouteRecordRaw } from '@/types/router';
import { NIcon } from 'naive-ui';
import { Component } from 'vue';
export function renderMenuLabel(route: AppRouteRecordRaw) {
if(route.meta.url) {
return h(
'a',
{
href: route.meta.url,
target: '_black'
},
{ default: () => route.meta.title }
);
}
return h(
RouterLink,
{
to: {
name: route.children ? '' : route.name
}
},
{ default: () => route.meta.title }
);
}
export function renderIcon(route: AppRouteRecordRaw) {
return h(NIcon, null, { default: () => h(route.meta.icon as Component) });
}
渲染的结果添加一点儿样式就是这样的效果
结语
这种方案是我认为比较友好简单的方式,通常会配合一个权限管理平台一起使用。 GitHub,觉得有用的可以点个星,这个项目是写给组内的人学习的,所以提交记录比较干净,以后也会一直更新的。