例子:vue3+vite+router创建多级导航菜单,菜单收缩展开优化

news/2025/11/12 19:23:37/文章来源:https://www.cnblogs.com/superbaby11/p/19215081

第一部分

1、初始化项目

npm init vite@latest

npm run dev :运行项目

q+Enter:退出运行

image

image

 

 

 2、安装路由依赖

npm install vue-router@4  # Vue3 对应 vue-router 4.x 版本

 

第二部分:

创建页面组件

在 src/views/home/ 目录下创建两个页面组件:

仪表板(dashboard.vue)

<template><div class="page home-page"><h2>仪表板</h2><p>这是网站仪表板的内容</p></div>
</template><style scoped>
.page {padding: 20px;
}
.home-page {background-color: #f0f9ff;
}
</style>

态势感知(situation.vue)

<template><div class="page home-page"><h2>态势感知</h2><p>这是网站态势感知的内容</p></div>
</template><style scoped>
.page {padding: 20px;
}
.home-page {background-color: #f0f9ff;
}
</style>

在 src/views/threat/ 目录下创建7个页面组件:alarm.vue、application.vue、attack.vue、equipment.vue、file.vue、mining.vue、system.vue

在 src/views/rule-config 目录下创建3个页面组件:backdoorRule.vue、idsRule.vue、rule.vue

在 src/views/rule-config/iocRule 目录下创建2个页面组件:threat_intelligence.vue、vuln_rule.vue

内容除了中文部分其他都一样。

<template><div class="page about-page"><h2>告警列表</h2><p>这是告警列表页面内容</p></div>
</template><style scoped>
.page {padding: 20px;
}
.about-page {background-color: #fff0f0;
}
</style>

 

创建导航组件

在 src/components 目录下创建导航栏组件 SubMenuRecursive.vue
<template><ul class="menu-list"><li v-for="item in menuList" :key="item.name" class="menu-item"><div class="menu-link" :class="{ active: isActive(item) }"@click="handleClick(item)"><span class="icon">{{ item.icon }}</span><span class="text">{{ item.name }}</span><span class="arrow" v-if="isSubMenu(item) && item.children && item.children.length":class="{ 'arrow-open': isOpen(item) }"></span></div><template v-if="isSubMenu(item) && item.children && item.children.length"><SubMenuRecursive :menu-list="item.children" :parent-path="getItemPath(item)"v-show="isOpen(item)"/></template></li></ul>
</template><script setup lang="ts">
import { ref,computed } from "vue"; 
import { useRoute, useRouter } from "vue-router";
import type { SubMenu, ThirdMenu } from "../router/menu";
import SubMenuRecursive from "./SubMenuRecursive.vue"; // 补充自身导入(递归组件需要)// 类型守卫:严格判断是否为 SubMenu(有 children 属性)
const isSubMenu = (item: SubMenu | ThirdMenu): item is SubMenu => {return 'children' in item && Array.isArray(item.children);
};// 接收父组件传入的菜单列表和父路径
const props = defineProps<{menuList: (SubMenu | ThirdMenu)[];parentPath?: string;expandedKeys?: string[];  // 父组件传递的展开状态(不再自己定义)
}>();// 2. 定义自定义事件,用于向父组件更新状态
const emit = defineEmits<{"update:expandedKeys": (newExpandedKeys: string[]) => void;
}>();const route = useRoute();
const router = useRouter();// 判断菜单项是否激活
const isActive = (item: SubMenu | ThirdMenu) => {const itemPath = getItemPath(item);return itemPath && route.path.startsWith(itemPath);
};// 3. 修改 isOpen 函数,基于父组件传递的 expandedKeys 判断
const isOpen = (item: SubMenu | ThirdMenu) => {return isSubMenu(item) && props.expandedKeys.includes(item.name);
};// 获取菜单项的路径(用于路由跳转)
const getItemPath = (item: SubMenu | ThirdMenu) => {if (isSubMenu(item) && item.children && item.children.length > 0) { // 增加 length > 0 判断return item.children[0]!.path; // 此时 children[0] 一定存在
  }return item.path;
};// 4. 修改 handleClick 函数,触发父组件更新展开状态
const handleClick = (item: SubMenu | ThirdMenu) => {const itemPath = item.path;if (itemPath) {router.push(itemPath);}// 切换展开状态时,通知父组件更新if (isSubMenu(item) && item.children && item.children.length) {// 复制父组件的展开状态(避免直接修改 props)const newExpandedKeys = [...props.expandedKeys];const index = newExpandedKeys.indexOf(item.name);if (index > -1) {newExpandedKeys.splice(index, 1);} else {newExpandedKeys.push(item.name);}// 向父组件发送更新后的状态emit("update:expandedKeys", newExpandedKeys);}
};</script><style scoped>
/* 样式保持不变 */
.menu-list {list-style: none;padding: 0;margin: 0;
}.menu-item {margin: 2px 0;
}.menu-link {display: flex;align-items: center;gap: 10px;padding: 10px 20px;color: #ecf0f1;cursor: pointer;transition: background-color 0.2s;
}.menu-link:hover, .menu-link.active {background-color: #2c3e50;
}.icon {width: 20px;text-align: center;
}.arrow {margin-left: auto;font-size: 12px;transition: transform 0.2s;
}.arrow-open {transform: rotate(90deg);
}/* 三级菜单缩进 */
.menu-list .menu-list {padding-left: 20px;border-left: 1px dashed #4a6988;
}
</style>

 

 

在 src/layouts 目录下创建导航栏组件 MainLayout.vue

<template><div class="layout-container"><header class="top-nav"><div class="logo">Admin Panel</div><nav class="main-menu"><router-linkv-for="menu in mainMenus":key="menu.path":to="menu.path"class="main-menu-item":class="{ active: isMainMenuActive(menu) }"><span class="icon">{{ menu.icon }}</span><span class="text">{{ menu.name }}</span></router-link></nav></header><div class="content-wrapper"><aside class="sidebar"><div class="sidebar-header"><h3>{{ currentMainMenu?.name }} 菜单</h3></div><nav class="sub-menu"><SubMenuRecursive :menu-list="currentSubMenus" :expanded-keys="expandedKeys" @update:expanded-keys="handleExpandedKeysUpdate" ></SubMenuRecursive> </nav></aside><main class="main-content"><router-view /></main></div></div>
</template><script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import type { MainMenu, SubMenu } from "../router/menu";
import { mainMenus } from "../router/menu";
import SubMenuRecursive from "../components/SubMenuRecursive.vue";const route = useRoute();
// 先获取第一个菜单,不存在则用默认值
const firstMenu = mainMenus[0] || { name: "", path: "", subMenus: [] } as MainMenu;// 用 firstMenu 初始化,避免 undefined
const currentMainMenu = ref<MainMenu | null>(firstMenu);
const currentSubMenus = ref<SubMenu[]>(firstMenu.subMenus);// 检查一级菜单是否激活
const isMainMenuActive = (menu: MainMenu) => {return route.path.startsWith(menu.path);
};// 1. 提升展开状态到父组件(全局唯一,对应当前一级菜单的下级菜单展开状态)
const expandedKeys = ref<string[]>([]);// 2. 接收子组件的展开状态更新(子组件点击二级菜单时触发)
const handleExpandedKeysUpdate = (newExpandedKeys: string[]) => {expandedKeys.value = newExpandedKeys;
};// 路由变化时更新当前菜单
watch(() => route.path,(newPath) => {const matchedMainMenu = mainMenus.find((menu) => newPath.startsWith(menu.path));if (matchedMainMenu) {currentMainMenu.value = matchedMainMenu;currentSubMenus.value = matchedMainMenu.subMenus;// 关键:重置展开状态(清空旧状态)expandedKeys.value = [];// 可选:初始化时自动展开当前路由对应的父菜单(复用原 initExpanded 逻辑)
      initExpanded(matchedMainMenu.subMenus, newPath);}},{ immediate: true }
);// 新增初始化函数
const initExpanded = (menuList: (SubMenu | ThirdMenu)[], currentPath: string) => {const findParent = (list: (SubMenu | ThirdMenu)[]) => {list.forEach((item) => {if (isSubMenu(item) && item.children && item.children.length) {const hasActiveChild = item.children.some((child) => currentPath.startsWith(child.path));if (hasActiveChild) {expandedKeys.value.push(item.name);}findParent(item.children);}});};findParent(menuList);
};// 辅助:需要在父组件中引入 isSubMenu 类型守卫(或直接复制逻辑)
const isSubMenu = (item: SubMenu | ThirdMenu): item is SubMenu => {return 'children' in item && Array.isArray(item.children);
};
</script><style scoped>
.layout-container {display: flex;flex-direction: column;min-height: 100vh;color: #333;
}/* 顶部导航 */
.top-nav {display: flex;align-items: center;height: 60px;background-color: #2c3e50;color: white;padding: 0 20px;box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}.logo {font-size: 1.2rem;font-weight: bold;margin-right: 30px;
}.main-menu {display: flex;gap: 2px;
}.main-menu-item {display: flex;align-items: center;gap: 8px;padding: 0 15px;height: 60px;color: #ecf0f1;text-decoration: none;transition: background-color 0.2s;
}.main-menu-item:hover, .main-menu-item.active {background-color: #34495e;
}/* 内容区 */
.content-wrapper {display: flex;flex: 1;
}/* 侧边栏 */
.sidebar {width: 220px;background-color: #34495e;color: white;padding: 20px 0;
}.sidebar-header {padding: 0 20px 15px;border-bottom: 1px solid #2c3e50;margin-bottom: 15px;
}.sidebar-header h3 {font-size: 0.9rem;color: #bdc3c7;margin: 0;
}/* 主内容区 */
.main-content {flex: 1;padding: 20px;background-color: #f5f7fa;overflow-y: auto;
}
</style>

配置路由

在 src 目录下创建 router 文件夹,新建 index.ts 和menu.ts路由配置文件:

menu.ts

// 1. 三级菜单接口(直接导出)
export interface ThirdMenu {name: string;path: string;icon?: string;
}// 2. 二级菜单接口(直接导出)
export interface SubMenu {name: string;path?: string;icon?: string;children?: ThirdMenu[];
}// 3. 一级菜单接口(直接导出)
export interface MainMenu {name: string;path: string;icon?: string;subMenus: SubMenu[];
}// 4. 菜单数据(直接使用接口类型标注)
export const mainMenus: MainMenu[] = [{name: "监测中心",path: "/sensor/home",subMenus: [{ name: "仪表板", icon: "📈",path: "/sensor/home/dashboard"},{ name: "态势感知", icon: "📊",path: "/sensor/home/situation"},]},{name: "威胁感知",path: "/sensor/threat",subMenus: [{ name: "告警列表", path: "/sensor/threat/alarm",icon: "📋"},{name: "威胁视角", icon: "👀",children: [{ name: "挖矿专项", path: "/sensor/threat/mining"},{ name: "文件安全", path: "/sensor/threat/file"},{ name: "应用安全", path: "/sensor/threat/application"},{ name: "系统安全", path: "/sensor/threat/system"},{ name: "设备安全", path: "/sensor/threat/equipment"}]},{ name: "攻击者视角", path: "/sensor/threat/attack",icon: "⚔️"}]},{name: "规则配置",path: "/sensor/rule-config",subMenus: [{ name: "网络漏洞利用", path: "/sensor/rule-config/rule",icon: "🕳️"},{ name: "webshell上传", path: "/sensor/rule-config/backdoorRule",icon: "⬆️"},{ name: "网络攻击", path: "/sensor/rule-config/idsRule",icon: "🌐"},{name: "自定义规则", icon: "👾",children: [{ name: "漏洞规则", path: "/sensor/rule-config/iocRule/vuln_rule"},{ name: "威胁情报", path: "/sensor/rule-config/iocRule/threat_intelligence"}]}]}
];

 

index.ts

import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import Layout from "../layouts/MainLayout.vue";
import type { MainMenu, SubMenu, ThirdMenu } from "./menu";
import {mainMenus} from "./menu";// 导入所有视图组件(非动态导入方式)
import Dashboard from "../views/home/dashboard.vue";
import Situation from "../views/home/situation.vue";import Alarm from "../views/threat/alarm.vue";
import Mining from "../views/threat/mining.vue";
import File from "../views/threat/file.vue";
import Application from "../views/threat/application.vue";
import System from "../views/threat/system.vue";
import Equipment from "../views/threat/equipment.vue";
import Attack from "../views/threat/attack.vue";import Rule from "../views/rule-config/rule.vue";
import BackdoorRule from "../views/rule-config/backdoorRule.vue";
import IdsRule from "../views/rule-config/idsRule.vue";
import VulnRule from "../views/rule-config/iocRule/vuln_rule.vue";
import ThreatIntelligence from "../views/rule-config/iocRule/threat_intelligence.vue";// 组件映射表(路径 -> 组件)
const componentMap: Record<string, any> = {"/sensor/home/dashboard": Dashboard,"/sensor/home/situation": Situation,"/sensor/threat/alarm": Alarm,"/sensor/threat/mining": Mining,"/sensor/threat/file": File,"/sensor/threat/application": Application,"/sensor/threat/system": System,"/sensor/threat/equipment": Equipment,"/sensor/threat/attack": Attack,"/sensor/rule-config/rule":Rule,"/sensor/rule-config/backdoorRule":BackdoorRule,"/sensor/rule-config/idsRule":IdsRule,"/sensor/rule-config/iocRule/vuln_rule":VulnRule,"/sensor/rule-config/iocRule/threat_intelligence":ThreatIntelligence
};// 递归生成路由(支持三级菜单)
const generateRoutes = (): RouteRecordRaw[] => {const routes: RouteRecordRaw[] = [{path: "/",component: Layout,children: [],},];// 处理一级菜单mainMenus.forEach((mainMenu: MainMenu) => {// 一级菜单默认重定向到第一个三级菜单const firstSub = mainMenu.subMenus[0]!;const firstThird = firstSub.children?.[0] || firstSub;routes[0]!.children!.push({path: mainMenu.path,redirect: firstThird.path!,});// 处理二级和三级菜单mainMenu.subMenus.forEach((subMenu: SubMenu) => {// 若二级菜单有三级菜单,生成二级路由组if (subMenu.children && subMenu.children.length > 0) {subMenu.children.forEach((thirdMenu: ThirdMenu) => {routes[0]!.children!.push({path: thirdMenu.path!,component: componentMap[thirdMenu.path!],});});} else if (subMenu.path) {// 若二级菜单无三级菜单,直接生成路由routes[0]!.children!.push({path: subMenu.path,component: componentMap[subMenu.path],});}});});return routes;
};const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: generateRoutes(),
});export default router;

修改根组件(App.vue) 

复制代码
<template><router-view />
</template><script setup lang="ts"></script><style>
* {margin: 0;padding: 0;box-sizing: border-box;font-family: 'Segoe UI', sans-serif;
}body {background-color: #f5f7fa;
}
</style>
复制代码

 

配置入口文件(main.ts)

确保已正确挂载路由(通常初始化项目时已配置,确认即可):
复制代码
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router/index' // 导入路由配置createApp(App).use(router) // 挂载路由.mount('#app')
复制代码

 

 

清空项目自带的src/style.css文件,执行 npm run dev 启动项目,访问 http://localhost:5173 会看到: 

  • 顶部有黑色导航栏,包含「监测中心」「威胁感知」「规则配置」三个链接
  • 点击不同链接,下方左侧区域会切换对应子菜单(无需刷新)

image

 

 

 


 

 

项目笔记

1、SubMenuRecursive.vue有如下代码,什么功能?

// 2. 定义自定义事件,用于向父组件更新状态
const emit = defineEmits<{"update:expandedKeys": (newExpandedKeys: string[]) => void;
}>();

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/963692.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

专题:2025AI时代的医疗保健业:应用与行业趋势研究报告|附130+份报告PDF、数据、可视化模板汇总下载

原文链接:https://tecdat.cn/?p=44257原文出处:拓端抖音号@拓端tecdat2025年的医疗保健行业,正站在“压力”与“机遇”的十字路口:一边是中国65岁及以上人口占比将从15.6%飙升至2070年的42%,慢性病护理需求快压垮…

团队作业2——需求规格说明书

需求规格说明书这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13481这…

实用指南:Java优选算法——位运算

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

英语_阅读_Postman_待读

Tom lives in a small town with his wife and two children. 汤姆和他的妻子以及两个孩子住在一个小镇上。 He has worked as a postman for more than twenty years, but life today is very different from when he…

CF1984F Reconstruction

比较偏套路。 首先你要知道,给我们这些信息的组合其实有用的很少,我们利用相邻两位的信息就足以规约出所有限制了。 就是分类讨论相邻位的 P/S 关系,那么就会得出一系列关于 \(a_i, a_{i - 1}\) 的限制了(与总和也…

英语_句子摘抄

Tom knows that even as the world moves forward, some things, like love and family, last forever.汤姆知道,即使世界在前进,有些东西,如爱与家庭,是永恒不变的。

详细介绍:python编程基础知识

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

[USACO18JAN] G/S 题解

[USACO18JAN] G/S 题解双倍经验双倍爽!P4185 [USACO18JAN] MooTube G 题解 P6111 [USACO18JAN] MooTube S 题解(数据弱化版) 题目链接 题目链接(弱化版) 我的博客 前言 如标题所言,是双倍经验。不同的是P6111可以…

计算机网络 —— 交换机 —— 二层交换机 or 三层交换机

计算机网络 —— 交换机 —— 二层交换机 or 三层交换机下图取自: https://cloud.tencent.com/developer/article/2323546本博客是博主个人学习时的一些记录,不保证是为原创,个别文章加入了转载的源地址,还有个…

IDM超详细安装下载教程,一次安装免费使用 Internet Download Manager

一、IDM 软件简介 Internet Download Manager(IDM)是实用的下载工具,核心信息如下: 核心功能 高速下载:分块下载技术,速度提升最多 8 倍。 断点续传:意外中断后可从上次结束位置继续下载。 浏览器集成:支持 Ch…

P7912 [CSP-J 2021] 小熊的果篮

P7912 [CSP-J 2021] 小熊的果篮 题解题目传送门 欢迎来到我的博客 闲话:被 set 薄纱了,以后该好好练练 set 了QAQ模拟好题。本题做法很多,这里介绍的是这篇题解的做法。 我们将所有 0 出现的下标扔进一个堆,将所有…

完整教程:对于环形链表、环形链表 II、随机链表的复制题目的解析

完整教程:对于环形链表、环形链表 II、随机链表的复制题目的解析pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "…

银河麒麟高级服务器操作系统V10SP3(X86)【在使用Xshell通过SSH连接时遇到 “服务器发送了一个意外的数据包。receives:3,expected:20”错误】问题解决方法

【问题描述】在使用Xshell通过SSH连接银河麒麟高级服务器操作系统V10SP3时,出现如下报错:服务器发送了一个意外的数据包。receives:3,expected:20【问题分析过程】1.检查服务器上的防火墙(firewalld)是否阻止了SSH…

数据结构与算法:动态规划的深度探讨 - 指南

数据结构与算法:动态规划的深度探讨 - 指南pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", &quo…

第六章蓝墨云班习题

import os, pythoncom, win32com.client as win32# ---------------------- 工具函数 ---------------------- def get_or_add_style(doc, name):try:return doc.Styles(name)except:return doc.Styles.Add(Name=name,…

[network] IPv4 vs. IPv6 address pool

=============== let’s expand on that precisely.1. IPv4 address space size Each IPv4 address is 32 bits long. Therefore, the total number of possible combinations is: [ 2^{32} = 4,294,967,296 ] That’s…

详细介绍:微信小程序开发实战指南(三)-- Webview访问总结

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

[Network] subnet mask

Q: ip address: 192.168.1.100subnet mask 255.255.255.0why is 192.168.x.x so special?what does this subnet mask mean? 192 in binary form is 11000000168 in binary form is 10101000why these two number ar…

flask: 用flask-cors解决跨域问题

一,安装第三方库 $ pip install flask-cors 二,代码: from flask_cors import CORSapp = Flask(__name__)# 解决存在跨域的问题 CORS(app)

Linux小课堂: 用户管理与权限控制机制详解 - 实践

Linux小课堂: 用户管理与权限控制机制详解 - 实践2025-11-12 18:36 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; displa…