基于tdesign的动态路由实现
发表于:2023-10-12 |

前言

本篇文章来讲解下如何使用tdesign来实现动态路由配置,就是后台进行配置,然后后端将根据后台配置信息,将路由根据权限返回给前台,前台(客户端)通过接口会调用获取路由信息进行渲染。好的,接下来我们就开始吧

服务端接口

这里我就大概讲解下服务端的接口大概啥样,大家也可以通过apifox自己模拟一下,或者懂后端的友友们可以自己写一下这些个接口。

后台配置接口

差不多就是这样的字段
获取所有路由信息
更新路由信息

后台页面绘制

card实现基础布局

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<t-card style="height: 100%">
<t-space>
<t-button theme="primary" size="small">
<template #icon><Icon name="add" /></template>
新增
</t-button>
</t-space>
</t-card>
</template>
<script setup lang="ts">
import { Icon } from 'tdesign-icons-vue-next';
</script>

card实现基础布局

table实现基础布局

定义列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { PrimaryTableCol, TableRowData } from 'tdesign-vue-next';
// 表格列数据
export const TABLE_COLUMN: PrimaryTableCol<TableRowData>[] = [
{
colKey: 'title',
title: '菜单标题',
width: 180,
ellipsis: true,
},
{
colKey: 'icon',
title: '图标',
width: 70,
ellipsis: true,
},
{
colKey: 'path',
title: '路由地址',
ellipsis: true,
},
{
colKey: 'permission',
title: '权限标识',
width: 100,
ellipsis: true,
},
{
colKey: 'redirect',
title: '重定向地址',
ellipsis: true,
},
{
colKey: 'component',
title: '组件路径',
ellipsis: true,
},
{
colKey: 'menuSort',
title: '路由顺序',
width: 100,
},
{
colKey: 'iframe',
title: '可见',
width: 90,
ellipsis: true,
},
{
colKey: 'op',
title: '操作',
width: 100,
fixed: 'right',
},
];

实现table

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<template>
<t-card style="height: 100%">
<t-space>
<t-button theme="primary" size="small">
<template #icon><Icon name="add" /></template>
新增
</t-button>
</t-space>
<t-enhanced-table
:columns="TABLE_COLUMN"
:data="listData"
:loading="tableLoading"
row-key="id"
ref="enhancedTableRef"
:tree="{
childrenKey: 'children',
checkStrictly: false,
treeNodeColumnIndex: 0,
expandTreeNodeOnClick: true,
}"
>
<template #icon="{ row }">
<icon :name="row.icon" />
</template>
<template #op="{ row }">
<t-button size="small">
<template #icon>
<Icon name="edit-1" />
</template>
</t-button>
<t-button theme="danger" size="small">
<template #icon><Icon name="delete" /></template>
</t-button>
</template>
<template #iframe="{ row }">
<t-tag v-if="row.iframe === false" theme="danger"> 不可见 </t-tag>
<t-tag v-else theme="success"> 可见 </t-tag>
</template>
</t-enhanced-table>
</t-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from 'tdesign-icons-vue-next';
import { TABLE_COLUMN } from './constants';

// table数据
const listData = ref([]);
// table加载状态
const tableLoading = ref(false);
</script>

加载数据

定义一下返回类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export interface IMenuList {
id: number;
path: string; // 路由
componentName: string; //组件名称
redirect?: string | null; // 重定向
component: string; //组件路径
icon?: string | null; //菜单图标
menuSort?: number; //顺序
IFrame?: boolean; //是否可见
iframe?: boolean; // 是否可见
title: string; //菜单名
pid: number; // 上级菜单
permission?: string; //权限标识,这里我暂定'admin,user1,user2,all'
children?: IMenuList[] | null;
meta?: string;
}

写请求

这里用到了我之前封装的axios的知识,如果不懂的可以看看我之前的文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 菜单相关
// 查询列表
export function getMenuList({ pageNo, pageSize }: { pageNo: number; pageSize: number }) {
return request.post<{list:IMenuList[]}>({
url: Api.menuList,
data: {
pageNo,
pageSize,
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
baseURL: baseUrl,
});
}

调用请求

这里实际上分页没用到,因为我这里的数据量不大,所以就不分页了,但是接口需要,就很怪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { ref, onMounted, reactive } from 'vue';
import { Icon } from 'tdesign-icons-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { getMenuList } from '@/api/checkOrder';
import type { IMenuList } from '@/api/model/checkOrder';
import { TABLE_COLUMN } from './constants';

// table数据
const listData = ref([]);
// table加载状态
const tableLoading = ref(false);

// 分页信息
const pagination = reactive({
current: 1,
pageSize: 200,
total: 0,
});

// 查询菜单
const queryMenuList = async () => {
tableLoading.value = true;
const { current: pageNo, pageSize } = pagination;
try {
const data = await getMenuList({
pageNo,
pageSize,
});
pagination.total = data.total;
listData.value = data.list;
} catch {
MessagePlugin.error('查询菜单失败');
} finally {
tableLoading.value = false;
}
};

onMounted(() => {
queryMenuList();
});

效果图

删除逻辑

这里前端比较简单,只需要传id就行。

写请求

1
2
3
4
5
6
7
8
9
10
11
12
13
// 删除路由
export function deleteMenu(menuId: number) {
return request.post({
url: Api.deleteMenu,
data: {
menuId,
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
baseURL: baseUrl,
});
}

写触发逻辑

这里我省略下代码,不然代码贴太多了,这里给删除图标添加方法handleDeleteRouter方法,传值为当前行id

html部分
1
2
3
4
5
6
7
8
9
10
<template #op="{ row }">
<t-button size="small">
<template #icon>
<Icon name="edit-1" />
</template>
</t-button>
<t-button theme="danger" size="small" @click.stop="handleDeleteRouter(row.id)">
<template #icon><Icon name="delete" /></template>
</t-button>
</template>
js部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next';
import { getMenuList, deleteMenu } from '@/api/checkOrder';
// 删除路由
const handleDeleteRouter = (id: number) => {
const confirmDia = DialogPlugin.confirm({
header: '删除提示',
body: '确定删除当前选中菜单吗?',
onConfirm: () => {
deleteMenu(id).then(() => {
MessagePlugin.success('删除菜单成功');
confirmDia.hide();
queryMenuList();
});
},
onClose: () => {
confirmDia.hide();
},
});
};

效果图

新增编辑逻辑

弹窗绘制

这里我将弹窗封装成了一个组件,这里直接贴代码,讲解一下一些关键点

  1. manifest这个是tdesign提供的全部名称
  2. 图标popup因为visible被我们自己控制了,所以他原有的点击蒙层关闭的效果得我们自己控制,就有了handleIconVisibleChange方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    <template>
    <t-dialog
    :visible="visible"
    :header="header"
    @confirm="handleRouterUpdateConfirm"
    @cancel="handleRouterUpdateCancel"
    @close="handleRouterUpdateCancel"
    :closeOnOverlayClick="false"
    width="800px"
    >
    <!-- 菜单 -->
    <t-form
    :data="formData"
    :colon="true"
    labelWidth="120px"
    ref="formRef"
    :rules="ROUTER_FORM_RULES"
    @submit="handleSubmit"
    >
    <t-space direction="vertical" style="width: 100%">
    <t-row>
    <t-col :span="6">
    <t-form-item label="菜单标题" name="title">
    <t-input placeholder="请输入内容" v-model="formData.title" />
    </t-form-item>
    </t-col>
    <t-col :span="6">
    <t-form-item label="路由路径" name="path">
    <t-input placeholder="请输入内容" v-model="formData.path" />
    </t-form-item>
    </t-col>
    </t-row>
    <t-row>
    <t-col :span="6">
    <t-form-item label="组件路径" name="component">
    <t-input placeholder="请输入内容" v-model="formData.component" />
    </t-form-item>
    </t-col>
    <t-col :span="6">
    <t-form-item label="组件名称" name="componentName">
    <t-input placeholder="请输入内容" v-model="formData.componentName" />
    </t-form-item>
    </t-col>
    </t-row>
    <t-row>
    <t-col :span="6">
    <t-form-item label="是否可见" name="iframe">
    <t-radio-group v-model="formData.iframe">
    <t-radio :value="true">是</t-radio>
    <t-radio :value="false">否</t-radio>
    </t-radio-group>
    </t-form-item>
    </t-col>
    <t-col :span="6">
    <t-form-item label="重定向" name="redirect">
    <t-input placeholder="请输入内容" v-model="formData.redirect" />
    </t-form-item>
    </t-col>
    </t-row>
    <t-row>
    <t-col :span="6">
    <t-form-item label="是否为一级目录" name="isTop">
    <t-radio-group v-model="formData.isTop">
    <t-radio :value="true">是</t-radio>
    <t-radio :value="false">否</t-radio>
    </t-radio-group>
    </t-form-item>
    </t-col>
    <t-col :span="6" v-if="!formData.isTop">
    <t-form-item label="上级菜单" name="pid">
    <t-tree-select :data="treeList" clearable :tree-props="treeProps" v-model="formData.pid" />
    </t-form-item>
    </t-col>
    </t-row>
    <t-row>
    <t-col :span="6">
    <t-form-item label="权限标识" name="permission" help="选择则代表仅选择权限可见">
    <t-select multiple :options="ROLE_LIST" v-model="formData.permission"> </t-select>
    </t-form-item>
    </t-col>
    <t-col :span="6">
    <t-form-item label="顺序" name="menuSort" help="如有数字将排序在前,数字越小路由越靠前">
    <t-input placeholder="请输入数字" v-model="formData.menuSort"></t-input>
    </t-form-item>
    </t-col>
    </t-row>
    <t-form-item label="菜单图标" name="icon">
    <t-space style="width: 100%">
    <t-popup :visible="iconPopupVisible" destroyed-on-close @visible-change="handleIconVisibleChange">
    <t-input
    readonly
    placeholder="点击选择图标"
    v-model="formData.icon"
    @click="iconPopupVisible = !iconPopupVisible"
    clearable
    >
    <template #prefix-icon>
    <Icon :name="iconName" />
    </template>
    </t-input>
    <template #content>
    <div class="icon-search-line">
    <t-input placeholder="请输入图标名称进行搜索" v-model="iconSearchInput">
    <template #prefix-icon>
    <Icon name="search" />
    </template>
    </t-input>
    <t-button class="reset-icon-button" @click="handleResetIcon">重置图标</t-button>
    </div>
    <div class="icon-container-list">
    <div
    v-for="item in iconShowList"
    :key="item.stem"
    class="each-icon-line"
    @click="handleSelectIcon(item.stem)"
    >
    <t-icon :name="item.stem" class="each-icon"></t-icon>{{ item.stem }}
    </div>
    </div>
    </template>
    </t-popup>
    </t-space>
    </t-form-item>
    <t-form-item label="自定义meta" name="meta">
    <t-input v-model="formData.meta" />
    </t-form-item>
    </t-space>
    </t-form>
    </t-dialog>
    </template>

    <script lang="ts" setup>
    import { reactive, ref, computed, watch, nextTick } from 'vue';
    import { ROLE_LIST, ROUTER_FORM_RULES } from '../../constants';
    import type { IMenuList } from '@/api/model/checkOrder';
    import { manifest, Icon } from 'tdesign-icons-vue-next';
    import { MessagePlugin } from 'tdesign-vue-next';

    const props = defineProps<{
    header: string;
    visible: boolean;
    list: IMenuList[];
    currentRowData: IMenuList;
    type: 'add' | 'edit';
    }>();

    // 观测visible变化
    watch(
    () => props.visible,
    (newVal) => {
    nextTick(() => {
    if (newVal) {
    const { type, currentRowData } = props;
    // 将值全部重置
    formRef.value.reset();
    // 设置默认值
    formData.isTop = true;
    formData.iframe = true;
    iconName.value = 'search';
    // 如果是编辑
    if (type === 'edit') {
    Object.keys(formData).forEach((key) => {
    if (key === 'iframe') {
    formData[key] = Boolean(currentRowData[key] !== false);
    } else if (key === 'isTop') {
    if (currentRowData.pid) {
    formData[key] = false;
    }
    } else {
    formData[key] = currentRowData[key];
    if (key === 'icon' && currentRowData[key]) {
    iconName.value = currentRowData[key];
    }
    }
    });
    }
    }
    });
    },
    );

    // 表单数据
    const formData = reactive({
    title: '',
    path: '',
    component: '',
    componentName: '',
    iframe: true,
    permission: '',
    isTop: true,
    meta: '',
    redirect: '',
    pid: '',
    menuSort: '',
    icon: '',
    });

    // 表单的ref
    const formRef = ref('');

    // 树结构
    const treeProps = {
    keys: {
    label: 'title',
    value: 'id',
    children: 'children',
    },
    };

    // 树截断两级
    const treeList = computed(() => {
    const data = props.list;
    data.forEach((item) => {
    const eachChildren = item.children;
    if (eachChildren && eachChildren.length > 0) {
    eachChildren.forEach((child) => {
    child.children = [];
    });
    }
    });
    return data;
    });
    const emit = defineEmits(['confirm', 'cancel']);
    const handleSubmit = ({ validateResult }) => {
    if (validateResult === true) {
    const param = {
    ...formData,
    icon: formData.icon,
    permission: formData.permission.join(','),
    id: props.type === 'edit' ? props.currentRowData.id : undefined,
    IFrame: formData.iframe,
    pid: formData.isTop ? undefined : formData.pid,
    type: formData.isTop ? 1 : 2,
    };
    emit('confirm', param);
    }
    };
    const handleRouterUpdateConfirm = () => {
    formRef.value.submit();
    };
    const handleRouterUpdateCancel = () => {
    emit('cancel');
    };

    // icon搜索框
    const iconSearchInput = ref('');
    // icon展示列表
    const iconShowList = computed(() => {
    return manifest.filter((item) => item.stem.includes(iconSearchInput.value));
    });
    // 当前选中的icon名称
    const iconName = ref('search');
    // icon弹窗是否可见
    const iconPopupVisible = ref(false);
    // 选中某个icon之后
    const handleSelectIcon = (name: string) => {
    formData.icon = name;
    iconName.value = name;
    iconSearchInput.value = '';
    iconPopupVisible.value = false;
    };
    // 重置icon
    const handleResetIcon = () => {
    iconSearchInput.value = '';
    iconPopupVisible.value = false;
    formData.icon = '';
    iconName.value = 'search';
    };
    // 自定义触发点击蒙层关闭
    const handleIconVisibleChange = (
    visible: boolean,
    PopupVisibleChangeContext: { e?: PopupTriggerEvent; trigger?: PopupTriggerSource },
    ) => {
    const { trigger } = PopupVisibleChangeContext;
    if (trigger === 'document') {
    iconPopupVisible.value = false;
    }
    };
    </script>
    <style scoped>
    .icon-search-line {
    padding: 10px;
    display: flex;
    .reset-icon-button {
    margin-left: 10px;
    }
    }
    .icon-container-list {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-gap: 5px;
    width: 750px;
    padding: 10px;
    max-height: 300px;
    overflow-y: auto;
    .each-icon-line {
    padding: 5px 0;
    .each-icon {
    margin-right: 5px;
    }
    &:hover {
    background-color: #f5f5f5;
    cursor: pointer;
    }
    }
    }
    </style>

弹窗效果

弹窗效果
弹窗效果
弹窗效果

触发弹窗以及发送请求

请求部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 新增路由
export function addMenu(param: IMenuList) {
return request.post({
url: Api.addMenu,
data: {
...param,
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
baseURL: baseUrl,
});
}

// 菜单修改
export function editMenu(param: IMenuList) {
return request.post({
url: Api.editMenu,
data: {
...param,
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
baseURL: baseUrl,
});
}
完整页面代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
<template>
<t-card style="height: 100%">
<t-space>
<t-button theme="primary" size="small" @click="handleAddRouterDialog">
<template #icon><Icon name="add" /></template>
新增
</t-button>
</t-space>
<t-enhanced-table
:columns="TABLE_COLUMN"
:data="listData"
:loading="tableLoading"
row-key="id"
ref="enhancedTableRef"
:tree="{
childrenKey: 'children',
checkStrictly: false,
treeNodeColumnIndex: 0,
expandTreeNodeOnClick: true,
}"
>
<template #icon="{ row }">
<icon :name="row.icon" />
</template>
<template #op="{ row }">
<t-button size="small" @click.stop="handleEditRouterDialog(row)">
<template #icon>
<Icon name="edit-1" />
</template>
</t-button>
<t-button theme="danger" @click.stop="handleDeleteRouter(row.id)" size="small">
<template #icon><Icon name="delete" /></template>
</t-button>
</template>
<template #iframe="{ row }">
<t-tag v-if="row.iframe === false" theme="danger"> 不可见 </t-tag>
<t-tag v-else theme="success"> 可见 </t-tag>
</template>
</t-enhanced-table>
</t-card>

<!-- 新增编辑弹窗 -->
<UpdateInfoDialog
:header="routerUpdateDialogTitle"
@confirm="handleRouterUpdateConfirm"
:visible="routerUpdateDialogVisible"
@cancel="routerUpdateDialogVisible = false"
:list="listData"
:current-row-data="currentRowData"
:type="routerUpdateType"
/>
</template>
<script setup lang="ts">
import { getMenuList, addMenu, deleteMenu, editMenu } from '@/api/checkOrder';
import { onMounted, ref, reactive, computed } from 'vue';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { Icon } from 'tdesign-icons-vue-next';
import { TABLE_COLUMN } from './constants';
import UpdateInfoDialog from './components/update-info-dialog/index.vue';
import type { IMenuList } from '@/api/model/checkOrder';

// 分页信息
const pagination = reactive({
current: 1,
pageSize: 200,
total: 0,
});

// 表格数据
const listData = ref([]);

onMounted(() => {
queryMenuList();
});

const enhancedTableRef = ref(null);
// TABLE的loading
const tableLoading = ref(false);
// 查询菜单
const queryMenuList = async () => {
tableLoading.value = true;
const { current: pageNo, pageSize } = pagination;
try {
const data = await getMenuList({
pageNo,
pageSize,
});
pagination.total = data.total;
listData.value = data.list;
} catch {
MessagePlugin.error('查询菜单失败');
} finally {
tableLoading.value = false;
}

};

// 删除路由
const handleDeleteRouter = (id: number) => {
const confirmDia = DialogPlugin.confirm({
header: '删除提示',
body: '确定删除当前选中菜单吗?',
onConfirm: () => {
deleteMenu(id).then(() => {
MessagePlugin.success('删除菜单成功');
confirmDia.hide();
queryMenuList();
});
},
onClose: () => {
confirmDia.hide();
},
});
};

// 路由更新弹窗的显示隐藏
const routerUpdateDialogVisible = ref(false);
// 路由更新类型
const routerUpdateType = ref<'add' | 'edit'>('add');
// 弹窗标题
const routerUpdateDialogTitle = computed(() => {
return routerUpdateType.value === 'add' ? '新增菜单' : '编辑菜单';
});
// 打开新增弹窗
const handleAddRouterDialog = () => {
routerUpdateDialogVisible.value = true;
routerUpdateType.value = 'add';
};
// 当前编辑行的数据
const currentRowData = ref<IMenuList>();
// 打开编辑弹窗
const handleEditRouterDialog = (rowData: IMenuList) => {
routerUpdateDialogVisible.value = true;
routerUpdateType.value = 'edit';
currentRowData.value = rowData;
};

// 弹窗确认
const handleRouterUpdateConfirm = async (param: IMenuList) => {
try {
routerUpdateType.value === 'add' ? await addMenu(param) : await editMenu(param);
queryMenuList();
MessagePlugin.success(`${routerUpdateDialogTitle.value}成功`);
routerUpdateDialogVisible.value = false;
} catch {
MessagePlugin.error(`${routerUpdateDialogTitle.value}失败`);
}
};
</script>

前台页面调用

登录初始化路由

在前台的登录之后配置user的信息,之后初始化路由
登录初始化路由

路由权限

这里我的initRouter使用了默认路由和自己配置的路由结合
登录初始化路由

配置路由拦截

当没有异步路由的时候获取请求异步路由,上面的截图里面就有异步路由的构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import NProgress from 'nprogress'; // progress bar
import 'nprogress/nprogress.css'; // progress bar style
import { getPermissionStore, getUserStore } from '@/store';
import router from '@/router';
import { PAGE_NOT_FOUND_ROUTE } from '@/utils/route/constant';
import { RouteRecordRaw } from 'vue-router';

NProgress.configure({ showSpinner: false });

router.beforeEach(async (to, from, next) => {
// document.title = getLocalStorage('companyName') as string;
NProgress.start();
const permissionStore = getPermissionStore();
const { whiteListRouters } = permissionStore;

const userStore = getUserStore();
const { token } = userStore;

if (token) {
if (to.path === '/login') {
NProgress.done();
next();
return;
}
try {
NProgress.done();
const { asyncRoutes } = permissionStore;
if (asyncRoutes && asyncRoutes.length === 0) {
const routeList = await permissionStore.buildAsyncRoutes();
routeList.forEach((item: RouteRecordRaw) => {
router.addRoute(item);
});
// 避免路由请求不到丢失
if (routeList.length === 0) {
if (router.hasRoute(to.name)) {
next();
} else {
next(`/`);
}
}
if (to.name === PAGE_NOT_FOUND_ROUTE.name) {
// 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容
next({ path: to.fullPath, replace: true, query: to.query });
} else {
const redirect = decodeURIComponent((from.query.redirect || to.path) as string);
// 避免刷新丢失
next(to.path === redirect ? { ...to, replace: true } : { path: redirect });
return;
}
}
if (router.hasRoute(to.name)) {
next();
} else {
next(`/`);
}
} catch (error) {
next({
path: '/login',
query: { redirect: encodeURIComponent(to.fullPath) },
});
NProgress.done();
}
} else {
/* white list router */
if (whiteListRouters.indexOf(to.path) !== -1) {
next();
} else {
next({
path: '/login',
query: { redirect: encodeURIComponent(to.fullPath) },
});
}
NProgress.done();
}
});

router.afterEach((to) => {
if (to.path === '/login') {
const userStore = getUserStore();
const permissionStore = getPermissionStore();

userStore.logout();
permissionStore.restoreRoutes();
}
NProgress.done();
});

异步路由配置

transformRouteFormat

首先将后端的接口路由转化成我们前端常见的样子,我这里写了个方法为transformRouteFormat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function transformData(data) {
const { componentName, component, path, redirect, meta, children, title, icon, iframe } = data;
const transformedData = {
path: path,
name: componentName,
redirect: redirect,
component: component,
meta: {
...(meta ? eval(`(${meta})`) : {}),
title: title,
icon: icon,
hidden: Boolean(iframe === false),
},
children: children ? transformRouteFormat(children) : [],
};
return transformedData;
}

export function transformRouteFormat(children) {
return children.sort((a, b) => (a.menuSort ?? 999) - (b.menuSort ?? 999)).map((child) => transformData(child));
}

这里我原先用的eval,后来看了文档,更推荐Function,所以我就改成了Function

transformObjectToRoute

将前端路由进行渲染,主要是处理哪些引入路径的情况,把路径变成真的引入组件和icon

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import cloneDeep from 'lodash/cloneDeep';
import { shallowRef } from 'vue';
import { RouteItem, RouteMeta } from '@/api/model/permissionModel';
import {
LAYOUT,
EXCEPTION_COMPONENT,
PARENT_LAYOUT,
PAGE_NOT_FOUND_ROUTE,
} from '@/utils/route/constant';

const LayoutMap = new Map<string, () => Promise<typeof import('*.vue')>>();
LayoutMap.set('LAYOUT', LAYOUT);

// vite 3+ support dynamic import from node_modules
const iconsPath = import.meta.glob('../../../node_modules/tdesign-icons-vue-next/esm/components/*.js');

// 动态从包内引入单个Icon
async function getMenuIcon(iconName: string) {
const RenderIcon = iconsPath[`../../../node_modules/tdesign-icons-vue-next/esm/components/${iconName}.js`];

const Icon = await RenderIcon();
// @ts-ignore
return shallowRef(Icon.default);
}

let dynamicViewsModules: Record<string, () => Promise<Recordable>>;

// 动态引入路由组件
function asyncImportRoute(routes: RouteItem[] | undefined) {
dynamicViewsModules = dynamicViewsModules || import.meta.glob('../../view/**/*.vue');
if (!routes) return;

routes.forEach(async (item) => {
const { component, componentName } = item;
const { children } = item;

if (component) {
const layoutFound = LayoutMap.get(component.toUpperCase());
if (layoutFound) {
item.component = layoutFound;
} else {
item.component = dynamicImport(dynamicViewsModules, component);
}
} else if (componentName) {
item.component = PARENT_LAYOUT();
}
// if (item.meta.icon) item.meta.icon = await getMenuIcon(item.meta.icon);

// eslint-disable-next-line no-unused-expressions
children && asyncImportRoute(children);
});
}

function dynamicImport(dynamicViewsModules: Record<string, () => Promise<Recordable>>, component: string) {
const keys = Object.keys(dynamicViewsModules);
const matchKeys = keys.filter((key) => {
const k = key.replace('../../view', '');
const startFlag = component.startsWith('/');
const endFlag = component.endsWith('.vue') || component.endsWith('.tsx');
const startIndex = startFlag ? 0 : 1;
const lastIndex = endFlag ? k.length : k.lastIndexOf('.');
return '@/view/' + k.substring(startIndex, lastIndex) === component;
});
if (matchKeys?.length === 1) {
const matchKey = matchKeys[0];
return dynamicViewsModules[matchKey];
}
if (matchKeys?.length > 1) {
throw new Error(
'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure',
);
} else {
console.warn(`Can't find ${component} in pages folder`);
}
return EXCEPTION_COMPONENT;
}

// 将背景对象变成路由对象
export function transformObjectToRoute<T = RouteItem>(routeList: RouteItem[]): T[] {
routeList.forEach(async (route) => {
const component = route.component as string;

if (component) {
if (component.toUpperCase() === 'LAYOUT') {
route.component = LayoutMap.get(component.toUpperCase());
} else {
route.children = [cloneDeep(route)];
route.component = LAYOUT;
route.name = `${route.name}Parent`;
route.path = '';
route.meta = (route.meta || {}) as RouteMeta;
}
} else {
throw new Error('component is undefined');
}
// eslint-disable-next-line no-unused-expressions
route.children && asyncImportRoute(route.children);
if (route.meta.icon) route.meta.icon = await getMenuIcon(route.meta.icon);
});
return [PAGE_NOT_FOUND_ROUTE, ...routeList] as unknown as T[];
}

效果图

结语

好了,本篇文章就分享到这里,更多内容敬请期待~

上一篇:
基于keep-alive动态跳转白屏
下一篇:
【JAVA学习】02-JDK安装以及环境变量配置