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

第一部分

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-link
          v-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;
}>();

 

posted @ 2025-11-12 19:19  超级宝宝11  阅读(0)  评论(0)    收藏  举报