实现动态侧边菜单,包括多级菜单方案:

方案一、使用 VueRouter 的动态路由,遍历路由信息动态构建菜单。

优势:熟悉 Vue 路由的情况下,这种方式可能构建起来会省部分代码。
劣势:路由信息不可见,对于刚接手的开发者没有后端服务的情况下,无法摸索精确找 url 对应的页面,构建多级菜单时需要增加一个空页面作为子路由的载体,页面结构变复杂。
根据后端构建动态路由有点难度,如构建 children: [{ path: '', component: () => import('pages/IndexPage.vue') }]类似这样的代码。

方案二、单独构建菜单树,使用单独的一个菜单树结构来构建菜单。

单独构建菜单树,使用单独的一个菜单树结构来构建菜单,使用 url 或者 name 来匹配对应的路由信息。
优势:原始路由清晰,易于调试,多级菜单不需要空页面作为路由载体,等等
劣势:维护菜单树和路由树的对应关系时代码可能有点复杂,

这里笔者是用的第二种方式。

思路分析

第一,我们需要构建一个菜单树,怎么构建树呢,我们需要子节点包含父节点信息的平级结构,然后把它转成树结构,只有子节点包含父节点的信息我们才能构建树结构,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
id: 'main2', // 节点的Id信息
pid: '0', // 父节点Id信息,为0表示为根节点
name: '主页2', // 菜单名称
path: '/index', // 菜单url,对应路由信息
icon: 'home', // 菜单图标
auth: '23', // 权限标识,暂定,鉴权也可以使用url作为鉴权标识。
},
{
id: '1',
pid: '0',
name: '系统管理',
path: '',
icon: 'settings_applications',
auth: '23',
},
{
id: '1-1',
pid: '1',
name: '菜单管理',
path: '/sys-menu',
icon: 'menu',
auth: '23',
},

上面这样的平级数据组成树结构应该是这样的

  • 主页
  • 系统管理
    • 菜单管理

了解菜单数据的组成过后,开始编写处理菜单树的代码

stores文件夹下新建layout-store.ts文件,编写如下代码,需要熟悉pinia

1
2
3
4
5
6
7
import { defineStore } from "pinia";

export const useLayoutStore = defineStore("layout", {
state: () => ({}), // 存储的数据
getters: {}, // 计算属性
actions: {}, // 一些操作
});

定义菜单类型,在src下新建types目录,之后新建base.d.ts文件,编写如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
declare namespace WJ {
/* 菜单项 */
interface MenuItem {
// 菜单ID
id: string;
// 菜单父级Id根节点使用"0"
pid: string;
// 菜单名称
name: string;
// 菜单路径
path: string;
// 菜单图标
icon: string;
// 菜单权限编码
auth: string;
// 排序,数字越大越靠后
sort: number;
// 子级别菜单
children?: Array<WJ.MenuItem>;
}
}

在 router 文件夹下新建my-router.ts文件,编写如下内容,此文件内容就是一会需要展示的菜单原始数据。

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
export default [
{
id: "1",
pid: "0",
name: "系统管理",
path: "",
icon: "settings_applications",
sort: 0,
},
{
id: "1-1",
pid: "1",
name: "菜单管理",
path: "/sys-menu",
icon: "menu",
sort: -1,
},
{
id: "1-2",
pid: "1",
name: "用户管理",
path: "/sys-user",
icon: "person",
sort: -2,
},
];

修改layout-store.ts文件,编写平级菜单结构转树结构的方法

编写过后完整代码如下,

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
import { store } from "quasar/wrappers";
import { defineStore } from "pinia";

// 类型定义
interface LayoutType {
// 平级菜单数据
menuList: Array<WJ.MenuItem>;
}
export const useLayoutStore = defineStore("layout", {
state: (): LayoutType => ({
menuList: [],
}),
getters: {
// ID:WJ.MenuItem,Map 结构,Key为菜单ID
menuIdMap: (state): Map<string, WJ.MenuItem> => {
const map = new Map<string, WJ.MenuItem>();
state.menuList.forEach((item) => {
map.set(item.id, item);
});
return map;
},
// 树形菜单数据
menuTree(state): Array<WJ.MenuItem> {
// 将子元素依次放入父元素中
const res: Array<WJ.MenuItem> = [];
state.menuList.forEach((item) => {
const parent = this.menuIdMap.get(item.pid);
if (parent) {
const pc = parent.children || (parent.children = []);
pc.indexOf(item) === -1 ? pc.push(item) : "";
pc.sort((a, b) => a.sort - b.sort);
} else {
res.push(item);
}
});
res.sort((a, b) => a.sort - b.sort);
return res;
},
},
actions: {},
});

这里菜单数据的代码就写好了,接下来编写页面展示。

layouts文件夹下新建目录comp,在comp目录新建文件LeftDrawer.vue作为左边侧边栏内容组件,

LeftDrawer.vue组件代码如下

1
2
3
4
5
6
7
8
9
<template>
<div>
<h3>这是侧边菜单</h3>
</div>
</template>

<script setup lang="ts"></script>

<style scoped></style>

MainLayout.vue文件中引入LeftDrawer.vue组件,并替换q-drawer的标签中的q-list标签,然后精简代码,编辑过后MainLayout.vue代码如下所示

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
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
/>

<q-toolbar-title> Quasar App </q-toolbar-title>

<div>Quasar v{{ $q.version }}</div>
</q-toolbar>
</q-header>

<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
<LeftDrawer></LeftDrawer>
</q-drawer>

<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>

<script setup lang="ts">
import { ref } from "vue";
import LeftDrawer from "./comp/LeftDrawer.vue";

const leftDrawerOpen = ref(false);

function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>

运行项目,如下图所示

示意图

说明组件引入成功,然后在comp文件夹下新建MenuItem.vue文件,编写如下内容代码,此代码有点复杂,用到了q-list,q-expansion-item组件,如果不熟悉组件使用方法,可查看官方文档学习使用

MenuItem.vue代码如下,我尽可能的多写注释,便于理解,如果没看懂,可以把代码拉到本地自己琢磨琢磨。

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
<template>
<template v-for="item in menuTree" :key="item.id">
<q-item v-if="!item.children" @click="handleClick(item)" clickable>
<q-item-section avatar>
<q-icon :name="item.icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ item.name }}</q-item-label>
</q-item-section>
</q-item>
<q-expansion-item
:icon="item.icon"
:label="item.name"
:content-inset-level="0.2"
v-else
>
<!-- 如果包含子集。则递归调用此组件 -->
<MenuItem :menu-tree="item.children"></MenuItem>
</q-expansion-item>
</template>
</template>

<script setup lang="ts">
import { useRouter } from "vue-router";

interface Props {
// 菜单树
menuTree: Array<WJ.MenuItem>;
}
withDefaults(defineProps<Props>(), {});
const router = useRouter();

/* 处理点击事件 */
const handleClick = (item: WJ.MenuItem) => {
router.push(item.path);
};
</script>

<style scoped></style>

编写完成过后,修改LeftDrawer.vue文件,要在此文件中引入MenuItem.vue组件,代码如下

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
<template>
<q-scroll-area class="fit">
<q-list bordered>
<!-- 主页 -->
<q-item
clickable
:active="route.fullPath === layout.mainPage.path"
@click="router.push({ path: layout.mainPage.path })"
>
<q-item-section avatar>
<q-icon :name="layout.mainPage.icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ layout.mainPage.name }}</q-item-label>
</q-item-section>
</q-item>
<!-- 菜单组件 -->
<menu-item :menu-tree="layout.menuTree"></menu-item>
</q-list>
</q-scroll-area>
</template>

<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { useLayoutStore } from "src/stores/layout-store";
import { useRoute, useRouter } from "vue-router";
import myRoute from "src/router/my-router";
const MenuItem = defineAsyncComponent(() => import("./MenuItem.vue"));
const route = useRoute();
const router = useRouter();
const layout = useLayoutStore();
// 注意这里,赋值菜单数据
layout.menuList = myRoute as Array<WJ.MenuItem>;
</script>

<style scoped></style>

这个时候不出意外的话,LeftDrawer.vue内代码会有报错,提示没有mainPage属性,我们给它加上即可,修改layout-store.ts内代码,在LayoutType中加入代码 mainPage: WJ.MenuItem,加入过后,如下

1
2
3
4
5
interface LayoutType {
// 平级菜单数据
menuList: Array<WJ.MenuItem>;
mainPage: WJ.MenuItem;
}

加入过后,代码提示报错,这时候我们再编写代码在state中添加mainPage属性,修改过后完整代码如下

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
import { defineStore } from "pinia";

interface LayoutType {
// 平级菜单数据
menuList: Array<WJ.MenuItem>;
// 主页
mainPage: WJ.MenuItem;
}
export const useLayoutStore = defineStore("layout", {
state: (): LayoutType => ({
menuList: [],
// 主要看这里
mainPage: {
id: "0",
pid: "0",
name: "首页",
sort: 0,
icon: "home",
auth: "",
path: "/",
},
}),
getters: {
// ID:WJ.MenuItem
menuIdMap: (state): Map<string, WJ.MenuItem> => {
const map = new Map<string, WJ.MenuItem>();
state.menuList.forEach((item) => {
map.set(item.id, item);
});
return map;
},
// 树形菜单数据
menuTree(state): Array<WJ.MenuItem> {
// 将子元素依次放入父元素中
const res: Array<WJ.MenuItem> = [];
state.menuList.forEach((item) => {
const parent = this.menuIdMap.get(item.pid);
if (parent) {
const pc = parent.children || (parent.children = []);
pc.indexOf(item) === -1 ? pc.push(item) : "";
pc.sort((a, b) => a.sort - b.sort);
} else {
res.push(item);
}
});
res.sort((a, b) => a.sort - b.sort);
return res;
},
},
actions: {},
});

重启项目,可以看到,我们编写的动态菜单已经加载出来了,
动态菜单

至此我们的动态菜单的展示已经编写完成,不过点击你会发现会跳转到 404 页面,因为我们并没有编写对应的路由配置,接下来我们将优化这个动态菜单功能,如,菜单高亮,支持外部链接,支持内部打开外部链接等等……..

动态菜单到此编写完成。

此篇博客进度对代码分支——dynamic-menu

封面