1 需求
- 管理系统“菜单”由后端接口返回,前端需要根据后端返回的“菜单”数组,构造路由,渲染侧栏菜单
- 有些菜单是子菜单,有对应的路由,但是不在侧栏显示(比如一些详情页面) 注:这里的“菜单”,不是字面意思的菜单,即可以是菜单也可以是按钮,如果是菜单则对应路由
2 分析
一般我们“菜单“保存在数据库时,不会仅仅保存路由相关参数,还会增加一下自定义参数,类似下面的数据结构:
const menu = {
id: "1",
pid: "-1", // 父id
nameCn: "平台管理", //中文名
type: "menu", //类别:menu 菜单,button 按钮
icon: "platform", //图标
routeName: "platform", //路由名
routePath: "/platform", //路由地址
routeLevel: 1, //路由级别: 一级路由(左侧显示) 二级路由(左侧不显示)
componentPath: "", //组件路径
sort: 1, //排序
children: [],
};
所以需要手动构造路由,渲染侧栏菜单
需要注意的地方:
- 通过设置 routeLevel 字段来判断当前“菜单“是否在左侧菜单显示
- 添加 el-menu 的 router 属性,这样可以点击 menu 自动跳转到 index 绑定的路径,否则需要绑定 click 事件,进行手动跳转(适用于需要跳转外链的情况,需要再递归菜单时进行判断,不给index赋值path)
- 如果当前“菜单“存在 children,但是 children 所有 child 是二级路由并且 path 没有以“/”开头(如下面路由中的硬件管理),那么需要将一个 Empty.vue 组件指向当前“菜单“,并且创建一个 path 为空的 child 来匹配当前“菜单“,否则二级路由无法跳转(无法跳转指的是是当前“菜单“组件路径直接配置目的组件路径)
- 如果当前“菜单“存在 children,但是 children 中有 child 的 path 没有以“/”开头,在构造左侧菜单时,需要拼接祖先 path 后再赋值给 index,否则无法跳转 这里使用 router.getRoutes()返回的所有路由,并且使用路由 name 匹配,这样比自己递归拼接方便,如下面 MenuItem.vue 组件中使用的 processRoutePath 函数
3 实现
效果图:
3.1 目录结构
3.2 代码:
components/SvgIcon.vue
<template>
<svg
aria-hidden="true"
:class="svgClass"
:style="{ width: size, height: size }"
>
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>
<script setup lang="ts">
const props = defineProps({
prefix: {
type: String,
default: "icon",
},
name: {
type: String,
required: false,
default: "",
},
color: {
type: String,
default: "",
},
size: {
type: String,
default: "1em",
},
className: {
type: String,
default: "",
},
});
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
const svgClass = computed(() => {
if (props.className) {
return `svg-icon ${props.className}`;
}
return "svg-icon";
});
</script>
<style scoped>
.svg-icon {
display: inline-block;
width: 1em;
height: 1em;
overflow: hidden;
vertical-align: -0.15em;
outline: none;
fill: currentcolor;
}
</style>
layout/index.vue
<template>
<div class="container">
<div class="aside">
<el-scrollbar height="100%">
<el-menu
:default-active="activeIndex"
:ellipsis="false"
:mode="'vertical'"
:collapse="false"
background-color="#545c64"
text-color="#fff"
active-text-color="#ffd04b"
router
>
<template v-for="item in menus" :key="item.id">
<MenuItem :menu="item"></MenuItem>
</template>
</el-menu>
</el-scrollbar>
</div>
<div class="content">
<RouterView></RouterView>
</div>
</div>
</template>
<script setup lang="ts">
import MenuItem from "./components/MenuItem.vue";
import { menus } from "../router/index";
const route = useRoute();
const activeIndex = ref<string>(route.path);
</script>
<style lang="scss">
.container {
width: 100%;
height: 100%;
display: flex;
.aside {
width: 300px;
height: 100%;
background-color: #545c64;
}
.content {
flex: 1;
}
}
</style>
layout/components/MenuItem.vue
<template>
<!-- 不存在children -->
<template v-if="!menu.children?.length">
<el-menu-item
v-if="menu.type === 'menu' && menu.routeLevel === 1"
:index="processRoutePath(menu)"
>
<template #title>
<SvgIcon :name="menu.icon" class="icon"></SvgIcon>
<span>{{ menu.nameCn }}</span>
</template>
</el-menu-item>
</template>
<!-- 存在children -->
<template v-else>
<el-sub-menu
v-if="menu.children.some((c: any) => c.type === 'menu' && c.routeLevel === 1)"
:index="menu.routePath"
>
<template #title>
<SvgIcon :name="menu.icon" class="icon"></SvgIcon>
<span>{{ menu.nameCn }}</span>
</template>
<template v-for="item in menu.children" :key="item.id">
<MenuItem :menu="item"></MenuItem>
</template>
</el-sub-menu>
<el-menu-item
v-else-if="menu.type === 'menu' && menu.routeLevel === 1"
:index="processRoutePath(menu)"
>
<template #title>
<SvgIcon :name="menu.icon" class="icon"></SvgIcon>
<span>{{ menu.nameCn }}</span>
</template>
</el-menu-item>
</template>
</template>
<script lang="ts" setup>
import MenuItem from "./MenuItem.vue";
import SvgIcon from "../../components/SvgIcon.vue";
const props = defineProps({
menu: {
type: Object,
required: true,
},
});
const router = useRouter();
const routes = router.getRoutes();
<!-- 用于处理子路由path没有以/开头的情况 -->
const processRoutePath = (item) => {
for (let route of routes) {
if (route.name === item.routeName) {
return route.path;
}
}
return item.routePath;
};
</script>
<style lang="scss">
.icon {
margin-right: 10px;
}
</style>
router/index.ts
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "index",
component: () => import("../layout/index.vue"),
meta: {
title: "index",
sort: 1,
},
},
],
// 刷新时,滚动条位置还原
scrollBehavior: () => ({ left: 0, top: 0 }),
});
export const menus = [
{
id: "1",
pid: "-1", // 父id
nameCn: "平台管理", //中文名
type: "menu", //类别:menu 菜单,button 按钮
icon: "platform", //图标
routeName: "platform", //路由名
routePath: "/platform", //路由地址
routeLevel: 1, //路由级别: 一级路由(左侧显示) 二级路由(左侧不显示)
componentPath: "", //组件路径
sort: 1, //排序
children: [
{
id: "1-1",
nameCn: "角色管理",
permission: "",
type: "menu",
pid: "1",
icon: "role",
iconColor: "",
routeName: "role",
routePath: "role",
routeLevel: 1,
componentPath: "/platform/role/index.vue",
sort: 1,
children: [
{
id: "1-1-1",
nameCn: "新增",
permission: "account:add",
type: "button",
pid: "1-1",
icon: "user",
iconColor: "",
routeName: "",
routePath: "",
routeLevel: null,
componentPath: "",
sort: 1,
children: [],
},
],
},
{
id: "1-2",
nameCn: "账户管理",
permission: "",
type: "menu",
pid: "1",
icon: "user",
iconColor: "",
routeName: "account",
routePath: "account",
routeLevel: 1,
componentPath: "/platform/account/index.vue",
sort: 2,
children: [],
},
],
},
{
id: "2",
pid: "-1",
nameCn: "资产管理",
type: "menu",
icon: "property",
routeName: "",
routePath: "/property",
routeLevel: 1,
componentPath: "",
sort: 2,
children: [
{
id: "2-1",
pid: "2",
nameCn: "文档管理",
permission: "",
type: "menu",
icon: "document",
iconColor: "",
routeName: "document",
routePath: "document",
routeLevel: 1,
componentPath: "/property/document/index.vue",
sort: 1,
children: [],
},
{
id: "2-2",
pid: "2",
nameCn: "硬件管理",
permission: null,
type: "menu",
icon: "hardware",
iconColor: "",
routeName: "",
routePath: "hardware",
routeLevel: 1,
componentPath: "/property/layout/Empty.vue",
sort: 2,
children: [
{
id: "2-2-1",
pid: "2-2",
nameCn: "硬件管理",
permission: null,
type: "menu",
icon: "",
routeName: "hardware",
routePath: "",
routeLevel: 2,
componentPath: "/property/hardware/index.vue",
sort: 1,
children: [],
},
{
id: "2-2-2",
pid: "2-2",
nameCn: "硬件配置",
permission: null,
type: "menu",
icon: "",
routeName: "config",
routePath: "config",
routeLevel: 2,
componentPath: "/property/hardware/Config.vue",
sort: 2,
children: [],
},
],
},
],
},
];
const initRouter = (routerTree: any) => {
const routerArr: any = [];
routerTree.forEach((item: any) => {
if (item.type === "menu") {
routerArr.push({
meta: { title: item.nameCn },
name: item.routeName || "",
path: item.routePath || "",
component: item.componentPath
? () => import(/* @vite-ignore */ "../view" + item.componentPath)
: "",
children: item.children ? initRouter(item.children) : [],
});
}
});
return routerArr;
};
const routers = initRouter(menus);
routers.forEach((item: any) => {
router.addRoute("index", item);
});
console.log(router.getRoutes());
export default router;
view/platform/account/index.vue
<template>账户管理</template>
view/platform/role/index.vue
<template>角色管理</template>
view/property/layout/Empty.vue
<template>
<router-view />
</template>
view/property/hardware/index.vue
<template>
<div>硬件管理</div>
<el-button type="primary" @click="handleCilck">硬件配置</el-button>
</template>
<script setup lang="ts">
const router = useRouter();
const handleCilck = () => {
router.push({
name: "config",
});
};
</script>
view/property/hardware/Config.vue
<template>硬件配置</template>
view/property/document/index.vue
<template>文档管理</template>