feat: 优化路由加载机制

master
luoer 2024-01-12 11:29:00 +08:00
parent 46c6c9a3a7
commit d8230ad3b9
18 changed files with 218 additions and 75 deletions

4
.env.production Normal file
View File

@ -0,0 +1,4 @@
# 参见 .env
VITE_BASE = ./
VITE_HISTORY = hash

File diff suppressed because one or more lines are too long

View File

@ -18,5 +18,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置
COPY --from=builder /app/.github/nginx.conf /etc/nginx/conf.d/default.conf
# 显式暴露端口
EXPOSE 80
# 启动,关闭后台运行启动前台运行,不然 docker 会结束运行
CMD ["nginx", "-g", "daemon off;"]

143
README.md
View File

@ -53,20 +53,153 @@ pnpm dev
根据 src/pages 目录生成路由数组,包含以下以下规则:
- 以文件夹为路由,读取该文件夹下 index.vue 的信息作为路由信息,其他文件会跳过,可以包含子文件夹
- 以文件夹为路由,读取该文件夹下 index.vue 的信息作为路由信息,其他文件会跳过,可以包含子文件夹作为嵌套路由
- 在 src/pages 的文件夹层级,作为菜单层级,路由层级最终只有 2 层(配合 keep-alive 缓存)
- 在 src/pages 目录下,以 _ 开头的文件夹作为 1 级路由,其他作为 2 级路由,也就是应用路由
- 子文件夹下,只有 index.vue 文件有效,其他文件会忽略,这些文件可以作为子组件使用
- components 目录会被忽视
- xxx.xx.xx 文件会被忽视,例如 index.my.vue
- components 目录会被忽视
- xxx.xx.xx 文件会被忽视,例如 index.my.vue 文件。
对应目录下的 index.vue 文件中定义如下路由配置:
```jsonc
<route lang="json">
{
"parentMeta": {
// 具体属性查阅 src/types/vue-router.d.ts
// 其他 Route 参数
"meta": {
// 请看下面
}
}
</route>
```
目前支持的参数,如下:
```ts
interface RouteMeta {
/**
* 页面标题
* @description
* 菜单和导航面包屑等地方会用到
*/
title?: string;
/**
* 页面图标
* @description
* 使用 icon-park-outline 图标集的图标类名
*/
icon?: string;
/**
* 显示顺序
* @description
* 在菜单中的显示顺序,越小越靠前
*/
sort?: number;
/**
* 是否隐藏
* @description
* - false // 不隐藏(默认)
* - true // 在路由和菜单中隐藏,即忽略且不打包
* - 'menu' // 在菜单中隐藏,通过其他方式访问
* - 'prod' // 在生产环境下隐藏
*/
hide?: boolean | 'menu' | 'prod';
/**
* 所需权限
* @example
* ```js
* ['system:user']
* ```
*/
auth?: string[];
/**
* 是否缓存
* @description
* 是否使用 keep-alive 缓存
*/
cache?: boolean;
/**
* 组件名字
* @description
* 组件名字,当 cache为true 时必须
*/
name?: string;
/**
* 是否显示loading
* @description
* 可以自定义 loading 文本
*/
loading?: boolean | string;
/**
* 链接
* @description
* ```js
* 'https://juetan.cn'
* ```
*/
link?: string;
}
```
### 嵌套布局
默认情况下,嵌套路由会使用父级 index.vue 作为布局文件,如果不需要布局,只需在父级路由指定 component 为 null 即可,如下:
```jsonc
{
"component": null,
"meta": {}
}
```
这样,其层级仅作为菜单层级,在路由上表现为扁平。
### 路由权限
在每个路由的 index.vue 文件中,通过 meta.auth 字段指定访问该路由所需的权限,示例如下:
```jsonc
{
"meta": {
"auth": ["system:user", "system:menu"]
}
}
```
默认全部需要登陆才可访问,其中有 2 个比较特殊的权限:
- `*` 表示无需登陆即可访问,适合挂一些比较通用的页面。
- `unlogin` 表示未登录才可以访问。例如登录页,登陆后访问该页面会被拒绝。
用户登陆后获取的权限,应存储在 userStore.auth 字段中,在路由的 beforeEach 守卫中,会比较两个是否匹配,匹配上则继续,否则会显示如下 403 页面:
![Alt text](image.png)
### 动态路由
相比于比较流行的加法挂载,我更倾向于减法挂载。即默认加载完所有路由,在 beforeEach 钩子根据权限移除不必要的路由。
### 动态首页
在作为首页路由的 index.vue 文件中,指定 alias 为 '/' 即可,默认是 home/index.vue 文件。如需动态更新首页,在 beforeEach 获取完菜单信息,通过 removeRoute 移除旧的首页路由,通过 addRoute 添加新的首页路由即可。
### 路由缓存
在路由的 index.vue 文件,首先指定好组件的名字,再通过 cache 字段开启缓存,示例如下:
```html
<script>
defineOptions({
name: "MyPage"
})
</script>
<route>
{
"meta": {
// 组件名字
"name": "MyPage",
// 开启缓存
"cache": true
}
}
</route>

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -38,6 +38,7 @@
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.loading {
display: flex;

View File

@ -13,9 +13,11 @@ export default defineComponent({
watch(
() => route.path,
() => {
selectedKeys.value = route.matched.map(i => i.path);
selectedKeys.value = route.matched.map(i => i.aliasOf?.path ?? i.path);
},
{ immediate: true }
{
immediate: true,
}
);
function goto(route: MenuItem) {

View File

@ -204,6 +204,7 @@ const buttons = [
<route lang="json">
{
"redirect": "/",
"meta": {
"name": "LayoutPage",
"sort": 101,

View File

@ -108,6 +108,7 @@ const stat = {
<route lang="json">
{
"alias": "/",
"meta": {
"sort": 1000,
"title": "首页",

View File

@ -173,7 +173,6 @@ const { component: UserTable } = useTable({
{
"meta": {
"name": "SystemDepartmentPage",
"keepAlive": true,
"sort": 10301,
"title": "部门管理",
"icon": "icon-park-outline-group"

View File

@ -106,6 +106,7 @@ const { component: RoleTable } = useTable({
"name": "SystemRolePage",
"sort": 10302,
"title": "角色管理",
"auth": ["role"],
"icon": "icon-park-outline-shield"
}
}

View File

@ -176,7 +176,6 @@ const { component: UserTable } = useTable({
"cache": true,
"sort": 10301,
"title": "用户管理",
"auth": ["*"],
"icon": "icon-park-outline-user"
}
}

View File

@ -2,18 +2,16 @@ import { api } from '@/api';
import { env } from '@/config/env';
import { store, useUserStore } from '@/store';
import { useMenuStore } from '@/store/menu';
import { treeEach, treeFilter, treeFind } from '@/utils/listToTree';
import { treeEach } from '@/utils/listToTree';
import { Notification } from '@arco-design/web-vue';
import { Router } from 'vue-router';
import { menus } from '../menus';
import { APP_HOME_NAME } from '../routes/base';
import { APP_ROUTE_NAME, routes } from '../routes/page';
import { appRoutes } from '../routes/page';
/**
*
* @param to
* @description store pinia-plugin-peristedstate
* @returns
*/
export function useAuthGuard(router: Router) {
api.expireHandler = () => {
@ -39,17 +37,17 @@ export function useAuthGuard(router: Router) {
return true;
}
// 直接访问跳转回首页(非路由跳转)
if (!from.matched.length) {
return '/';
}
// 提示已登陆
Notification.warning({
title: '跳转提示',
content: `您已登陆,如需重新登陆请退出后再操作!`,
});
// 直接访问跳转回首页(不是从路由跳转)
if (!from.matched.length) {
return '/';
}
// 已登陆不允许
return false;
}
@ -64,37 +62,24 @@ export function useAuthGuard(router: Router) {
// 未获取权限进行获取
if (!menuStore.menus.length) {
// 菜单处理
const authMenus = treeFilter(menus, item => {
if (item.path === env.homePath) {
item.path = '/';
}
return true;
});
menuStore.setMenus(authMenus);
menuStore.setMenus(menus);
menuStore.setHome(env.homePath);
// 路由处理
for (const route of routes) {
router.addRoute(route);
}
// 缓存处理
treeEach(routes, (item, level) => {
treeEach(appRoutes, item => {
const { cache, name } = item.meta ?? {};
if (cache && name) {
menuStore.caches.push(name);
}
// if (item.path === menuStore.home) {
// item.alias = '/';
// }
// if (!router.hasRoute(item.name!)) {
// const route = { ...item, children: undefined } as any;
// router.addRoute(route.parentName!, route);
// }
});
// 首页处理
const home = treeFind(routes, i => i.path === menuStore.home);
if (home) {
const route = { ...home, name: APP_HOME_NAME, alias: '/' };
router.removeRoute(home.name!);
router.addRoute(APP_ROUTE_NAME, route);
return router.replace(to.path);
}
return to.fullPath;
}
// 兜底处理

View File

@ -2,7 +2,6 @@ import { createRouter } from 'vue-router';
import { useAuthGuard } from '../guards/auth';
import { useProgressGard } from '../guards/progress';
import { useTitleGuard } from '../guards/title';
import { baseRoutes } from '../routes/base';
import { historyMode } from './util';
import { routes } from '../routes/page';
@ -11,7 +10,7 @@ import { routes } from '../routes/page';
*/
export const router = createRouter({
history: historyMode(),
routes: [...baseRoutes, ...routes],
routes: routes,
});
/**

View File

@ -1,14 +0,0 @@
import { RouteRecordRaw } from 'vue-router';
export const APP_HOME_NAME = '__APP_HOME__';
/**
*
*/
export const baseRoutes: RouteRecordRaw[] = [
{
path: '/',
name: APP_HOME_NAME,
component: () => 'Home Page',
},
];

View File

@ -15,8 +15,8 @@ function treeRoutes(list: RouteRecordRaw[]) {
for (const item of list) {
const parentPath = item.path.split('/').slice(0, -1).join('/');
const parent = map[parentPath];
item.parentName = (parent?.name as string) || APP_ROUTE_NAME;
if (parent) {
(item as any).parentPath = parentPath;
(parent.children || (parent.children = [])).push(item);
} else {
tree.push(item);
@ -47,12 +47,14 @@ function sortRoutes(routes: RouteRecordRaw[]) {
const transformRoutes = (routes: RouteRecordRaw[]) => {
const topRoutes: RouteRecordRaw[] = [];
const appRoutes: RouteRecordRaw[] = [];
let app: RouteRecordRaw;
for (const route of routes) {
if (route.name === APP_ROUTE_NAME) {
app = route;
route.children = appRoutes;
}
if ((route.name as string)?.startsWith(TOP_ROUTE_PREF)) {
if (route.name === APP_ROUTE_NAME) {
route.children = appRoutes;
}
route.path = route.path.replace(TOP_ROUTE_PREF, '');
topRoutes.push(route);
continue;
@ -60,7 +62,8 @@ const transformRoutes = (routes: RouteRecordRaw[]) => {
appRoutes.push(route);
}
return [topRoutes, sortRoutes(treeRoutes(appRoutes))];
app!.children = sortRoutes(treeRoutes(appRoutes));
return [topRoutes, app!.children];
};
export const [routes, appRoutes] = transformRoutes(generatedRoutes);

View File

@ -1,16 +1,16 @@
import { defineStore } from "pinia";
import { defineStore } from 'pinia';
export const useUserStore = defineStore({
id: "user",
id: 'user',
state: (): UserStore => {
return {
id: 0,
username: "juetan",
nickname: "绝弹",
avatar: "https://github.com/juetan.png",
accessToken: "",
username: 'juetan',
nickname: '绝弹',
avatar: 'https://github.com/juetan.png',
accessToken: '',
refreshToken: undefined,
auth: []
auth: [],
};
},
actions: {
@ -48,7 +48,10 @@ export const useUserStore = defineStore({
accessToken && (this.accessToken = accessToken);
},
},
persist: true,
persist: {
key: '__APP_USER__',
paths: ['accessToken'],
},
});
export interface UserStore {
@ -65,11 +68,11 @@ export interface UserStore {
*/
nickname: string;
/**
*
*
*/
avatar?: string;
/**
* JWT
* 访
*/
accessToken?: string;
/**

View File

@ -1,6 +1,30 @@
import "vue-router";
import 'vue-router';
declare module 'vue-router' {
interface RouteRecordRaw {
parentName?: string;
}
interface RouteRecordSingleViewWithChildren {
parentName?: string;
}
interface RouteRecordSingleView {
parentName?: string;
}
interface RouteRecordMultipleViews {
parentName?: string;
}
interface RouteRecordMultipleViewsWithChildren {
parentName?: string;
}
interface RouteRecordRedirect {
parentName?: string;
}
declare module "vue-router" {
interface RouteMeta {
/**
*