news 2026/8/25 5:14:07

3.左侧菜单动态路由的实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
3.左侧菜单动态路由的实现

一、在此之前,我先把具体的路由写好:

1.为了后续分开两个不同账号的路由权限,所以两个账号的路由我是分开的,用于登陆接口返回:

新增路由文件(resourceList.ts)区分admin和user账号:

// \mock\modules\resourceList.ts const adminRouterLists = [ { id: 1, name: '系统管理', route: '/sysManage', resourceType: 'folder', icon: 'manage', children: [ { id: 11, name: '用户管理', route: '/sysManage/userManage', resourceType: 'menu' }, { id: 11, name: '菜单管理', resourceType: 'menu', route: '/sysManage/menuManage' } ] } ] const userRouterLists = [ { id: 1, name: '系统管理', route: '/sysManage', resourceType: 'folder', icon: 'manage', children: [ { id: 11, name: '用户管理', route: '/sysManage/userManage', resourceType: 'menu' } ] } ] export { adminRouterLists, userRouterLists }

登录接口中根据不同账号,resourceList返回不同路由:

// mock/index.ts import Mock from 'mockjs' import { adminRouterLists, userRouterLists } from './modules/resourceList' // 设置延迟 Mock.setup({ timeout: '200-600' }) // 登录接口 Mock.mock('/api/auth/login', 'post', (options: any) => { const body = JSON.parse(options.body) const { username, password } = body // 模拟验证逻辑 if (username === 'admin' && password === 'admin') { return { code: 200, data: { // @guid 是 Mock.js 内置的占位符,用于生成一个 全局唯一标识符(GUID)。 token: Mock.mock('@guid'), // @id 是 Mock.js 内置的占位符,用于生成一个随机且合法的身份证号码。 userId: Mock.mock('@id'), username: 'admin', nickname: '超级管理员', // 使用 DiceBear Avatar API 生成一个随机头像 SVG 图片 avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${Mock.mock('@name')}`, roles: ['admin'], resourceList: adminRouterLists }, msg: '登陆成功', success: true } } else if (username === 'user' && password === 'user') { return { code: 200, data: { token: Mock.mock('@guid'), userId: Mock.mock('@id'), username: 'user', nickname: '普通用户', avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${Mock.mock('@name')}`, roles: ['user'], resourceList: userRouterLists }, msg: '登陆成功', success: true } } else { return { code: 401, data: null, msg: '用户名或密码错误', success: false } } }) // 获取用户信息 Mock.mock('/api/auth/userInfo', 'get', () => { const token = localStorage.getItem('token') if (!token) { return { code: 401, data: null, message: '未登录', success: false } } // 模拟不同用户的权限 const isAdmin = token.includes('admin') || Math.random() > 0.5 return { code: 200, data: { id: Mock.mock('@id'), username: isAdmin ? 'admin' : 'user', nickname: isAdmin ? '管理员' : '普通用户', avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${Mock.mock('@name')}`, email: Mock.mock('@email'), phone: Mock.mock('@phone'), roles: isAdmin ? ['admin'] : ['user'], permissions: isAdmin ? ['*'] : ['read', 'write'] }, msg: '获取成功', success: true } }) Mock.mock('/api/auth/logout', 'post', () => { return { code: 200, data: null, msg: '退出成功', success: true } }) export default Mock

2.将其他涉及路由的地方都要新增相关信息:

(1)登录api中新增Route路由接口定义,LoginResult接口新增返回resourceList

// api/auth.ts import request, { request as Request} from "@/utils/request"; // 路由接口定义 interface Route { id: number, name: string, route: string, icon: string, resourceType: string, children: Array<{ id: number, name: string, icon: string, route: string, resourceType: string}> } // 登录请求参数 export interface LoginParams { username: string, password: string } // 登陆响应数据 export interface LoginResult { token: string, userId: string, username: string, nickname: string, avatar?: string, roles: string[], resourceType: string, resourceList: Route[] } // 用户信息 export interface UserInfo { id: string, username: string, nickname: string, avatar: string, email: string, phone: string, roles: string[], permissions: string[] } // 登录(快捷写法,指定接口为post) // export const login = ( data: LoginParams ) => { // return request.post<LoginResult>('/auth/login', data) // } // 通用写法,动态method export const login = ( data: LoginParams ) => { return Request<LoginResult>({ url: '/auth/login', method: 'POST', data }) } // 获取用户信息 export const getUserInfo = () => { return request.get<UserInfo>('/auth/userInfo') } // 退出登录 export const logout = () => { return request.post('/auth/logout') } // 刷新token export const refreshToken = () => { return request.post<{ token: string }>('/auth/refresh') }

(2)新增路由的store,用于放置路由信息和相关操作:

// \src\store\modules\routerList.ts import { defineStore } from "pinia"; import { ref } from 'vue' export const useRouterStore = defineStore('router',() => { interface Route { id: number, name: string, route: string, icon: string, resourceType: string, children: Array<{ id: number, name: string, icon: string, route: string, resourceType: string}> } // state // 路由列表 const routerList = ref<Route[]>([]) const restoreRouter = () => { const stored = localStorage.getItem('resource') || sessionStorage.getItem('resource') if (!stored) return try { const parsed = JSON.parse(stored) if (Array.isArray(parsed)) routerList.value = parsed } catch { routerList.value = [] } } // actions // 从登录保存路由 function saveRouter(r: Route[]) { localStorage.setItem('resource', JSON.stringify(r)) sessionStorage.setItem('resource', JSON.stringify(r)) routerList.value = r } function getRouter() { restoreRouter() } restoreRouter() return { routerList, saveRouter, getRouter, restoreRouter } })

index.js中要引入这个模块

// src/store/index.ts import { createPinia } from 'pinia' const pinia = createPinia() export default pinia // 导出所有模块 export * from './modules/user' export * from './modules/setting' export * from './modules/routerList'

在这里我又新增了一个setting的store(也要在index中引入),用于控制左边菜单栏的折叠功能:

import { defineStore } from 'pinia' import { ref } from 'vue' export const useSettingStore = defineStore('settingStore', () => { // state const menuCollapse = ref<boolean>(false) // 控制左边菜单是否折叠 // actions function changeMenuCollapse() { menuCollapse.value = !menuCollapse.value console.log("menuCollapse", menuCollapse.value) } return { menuCollapse, changeMenuCollapse } })

(3)最重要的还有一个路由文件,里面包含了所有路由信息,也要同步更新,同时我还对里面的路由守卫做了一些优化(针对刷新以后路由和token丢失,每次刷新后就需要重新登陆,和左边才菜单消失的问题做的优化,当然路由守卫的优化,这是我后面在初步实现路由功能后才出现的问题,但我把他先放到前面来,和其他的ts文件一起):

src\router\index.ts

// src/router/index.ts // createRouter创建路由实例的函数,createWebHistory使用HTML5 History模式(URL不带 # ) import { createRouter, createWebHistory } from 'vue-router' // type { RouteRecordRaw }TypeScript类型,用于类型检查(type关键字表示这是纯类型,打包时会被移除) // RouteRecordRaw是Vue Router源码中定义的一个TypeScript类型/接口,规定了每个路由配置必须包含哪些字段、可选哪些字段、每个字段是什么类型 import type { RouteRecordRaw } from 'vue-router' // 👈 加上 type 关键字 // 登陆时的vue组件 import LoginView from '../views/login/index.vue' import { useUserStore } from '@/store/modules/user' import { useRouterStore } from '@/store/modules/routerList.ts' import Layout from '../views/layout/index.vue' const routes: RouteRecordRaw[] = [ { // 登录页用同步加载,因为是必须的 path: '/login', name: 'Login', component: LoginView, meta: { requiresAuth: false } }, { // 其他页面懒加载 path: '/', name: 'Home', redirect: '/sysManage', meta: { requiresAuth: true } }, { path: '/sysManage', name: 'sysManage', component: Layout, meta: { title: '系统管理' }, redirect: '/sysManage/userManage', children: [ { path: '/sysManage/userManage', name: 'userManage', component: () => import('@/views/sys/UserManage/index.vue'), meta: { title: '用户管理' } }, { path: '/sysManage/menuManage', name: 'menuManage', component: () => import('@/views/sys/menuManage/index.vue'), meta: { title: '菜单管理' } } ] } ] // 创建路由实例 const router = createRouter({ history: createWebHistory(), // 使用history模式 routes // 传入路由表 }) // 路由守卫(to:目标路由对象,from: 来源路由对象,next():控制跳转) router.beforeEach(async (to, _from, next) => { const token = localStorage.getItem('token') const userStore = useUserStore() const routerStore = useRouterStore() console.log("token",token) // 如果页面需要认证 if (to.meta.requiresAuth !== false) { if (!token) { // 未登录,跳到登录页 next({ path: '/login', query: { redirect: to.fullPath } // 记录原本想访问的地址 }) return } // 如果已登录但没有用户信息,尝试获取 if (!userStore.userInfo) { userStore.restoreUserInfo() // 从localStorage中恢复 // 如果回复后还为空,则重新获取用户信息 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() next() } catch { // 获取信息失败,清除token跳转登录 userStore.clearUserInfo() next({ path: '/login', query: { redirect: to.fullPath } }) } return } } if (!routerStore.routerList.length) { routerStore.getRouter() } // 已经登录并且有用户信息 next() } else { // 已经登陆的情况下访问登录页,跳转首页 if (token && to.path === '/login') { next('/') return } // 页面不需要认证 next() } }) //导出路由 export default router

二、开始写组件

(1)从layout.vue主页面写起

ant-design-vue中有基本的框架,可以直接用

最终实际实现效果图为:

我的设计为,左侧路由菜单——>SiderBar组件,右边顶部为Header组件,Header组件中又分为了headerLeft——> 折叠按钮,headerRight——>暂时还未开发,打算后续先写个人信息和退出登录的功能,然后是下面的路由展示区域

src\views\layout\index.vue

<template> <a-layout> <a-layout-sider class="siderBar" v-model:collapsed="collapsed" :trigger="null" collapsible> <div> logo </div> <SiderBar></SiderBar> </a-layout-sider> <a-layout> <Header></Header> <a-layout-content class="main-content"> <router-view v-slot="{ Component, route }"> <transition name="fade-transform" mode="out-in"> <keep-alive> <component v-if="Component" :is="Component" :key="route.path" /> </keep-alive> </transition> </router-view> </a-layout-content> </a-layout> </a-layout> </template> <script setup lang="ts"> import { defineComponent, computed } from 'vue'; import SiderBar from './components/siderBar/index.vue' import Header from './components/header/index.vue' import { useSettingStore } from '@/store/modules/setting.ts' defineComponent({ name: 'siderBar' }) const settingStore = useSettingStore() const collapsed = computed(() => { return settingStore.menuCollapse }) </script> <style scoped lang="scss"> .siderBar { background-color: #ffffff; color: #000000; font-size: 26px; } </style>

src\views\layout\components\header\index.vue

<template> <div class="container"> <HeaderLeft></HeaderLeft> <HeaderRight></HeaderRight> </div> </template> <script setup lang="ts"> import HeaderLeft from './components/headerLeft.vue' import HeaderRight from './components/headerRight.vue' </script> <style scoped> .container { display: flex; } </style>

src\views\layout\components\header\components\headerLeft.vue

要注意这里控制折叠的按钮,状态是在store里的setting.ts中控制的哦,所以这里是还要引入useSettingStore

<template> <div class="container"> <menu-unfold-outlined v-if="collapsed" class="trigger" @click="changeCollapsed" /> <menu-fold-outlined v-else class="trigger" @click="changeCollapsed" /> </div> </template> <script setup lang="ts"> import { computed } from 'vue' import { useSettingStore } from '@/store/modules/setting.ts' const settingStore = useSettingStore() const collapsed = computed(() => { return settingStore.menuCollapse }) function changeCollapsed() { settingStore.changeMenuCollapse() } </script> <style lang="scss" scoped> .container { width: 50%; padding: 15px 20px; } .trigger { font-size: 18px; } // #components-layout-demo-custom-trigger .trigger { // font-size: 18px; // line-height: 64px; // padding: 0 24px; // cursor: pointer; // transition: color 0.3s; // } // #components-layout-demo-custom-trigger .trigger:hover { // color: #1890ff; // } // #components-layout-demo-custom-trigger .logo { // height: 32px; // background: rgba(255, 255, 255, 0.3); // margin: 16px; // } // .site-layout .site-layout-background { // background: #fff; // } </style>

src\views\layout\components\header\components\headerRight.vue

还没开始开发,留空

<template> <div class="container"> headerRight </div> </template> <script setup lang="ts"> </script> <style scoped> .container { flex: 1; } </style>

关键的来了,sideBar的开发!!

这里我使用了一个组件的递归调用,便于之后有多层嵌套路由(虽然现在只有两层)!

\src\views\layout\components\siderBar\index.vue

<template> <div class="menu-scrollbar"> <a-menu mode="inline" v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys"> <MenuItem v-for="item in menuList" :key="item.route || item.id" :menu-item="item"></MenuItem> </a-menu> </div> </template> <script setup lang="ts"> import { ref, defineComponent, computed, watch} from 'vue' import { useRoute } from 'vue-router' import { useRouterStore } from '@/store/modules/routerList' import MenuItem from './menuItem.vue' defineComponent({ name: 'menu' }) const route = useRoute() const routerStore = useRouterStore() const selectedKeys = ref<string[]>([route.path]) const openKeys = ref<string[]>([]) const menuList = computed(() => routerStore.routerList) interface MenuRoute { route: string children?: MenuRoute[] } // 找出当前路由所属的所有父级菜单,用于初始化展开菜单 function findParentKeys(list: MenuRoute[], currentPath: string, parents: string[] = []): string[] { for (const item of list) { const nextParents = item.route ? [...parents, item.route] : parents if (item.route === currentPath) return parents if (item.children?.length) { const result = findParentKeys(item.children, currentPath, nextParents) if (result.length || item.children.some(child => child.route === currentPath)) return result } } return [] } // 监听路由变化,自动高亮 watch( [() => route.path, menuList], ([newPath, list]) => { selectedKeys.value = [newPath] openKeys.value = findParentKeys(list as MenuRoute[], newPath) }, { immediate: true } ) // function extractAllRoutes(routes: Route[]){ // const result: string[] = [] // routes.forEach(r => { // // 添加当前route // result.push(r.route) // // 如果有子路由,递归提取 // if (r.children && r.children.length) { // result.push(...extractAllRoutes(r.children)) // } // }) // return result // } </script> <style lang="scss" scoped> .menu-scrollbar { height: 100%; overflow-y: auto; overflow-x: hidden; } /* 自定义滚动条样式 */ .menu-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; } .menu-scrollbar::-webkit-scrollbar-track { background: transparent; border-radius: 3px; } .menu-scrollbar::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 3px; transition: all 0.3s; } .menu-scrollbar::-webkit-scrollbar-thumb:hover { background: #bfbfbf; } /* Firefox 兼容 */ .menu-scrollbar { scrollbar-width: thin; scrollbar-color: #d9d9d9 transparent; } </style>

\src\views\layout\components\siderBar\menuItem.vue

<MenuItem>组件内部,又使用了<MenuItem>组件自身

<template> <template v-if="menuItem.children && menuItem.children.length"> <a-sub-menu :key="menuItem.route || String(menuItem.id)"> <template #title> <span>{{ menuItem.name }}</span> </template> <MenuItem v-for="child in menuItem.children" :key="child.route || child.id" :menu-item="child"></MenuItem> </a-sub-menu> </template> <a-menu-item v-else :key="menuItem.route" @click="handleMenuClick(menuItem.route)"> {{ menuItem.name }} </a-menu-item> </template> <script setup lang="ts"> import { defineComponent, } from 'vue' import { useRouter } from 'vue-router' defineComponent({ name: 'menuItem' }) interface MenuItem { id: number name: string route: string resourceType: string children?: MenuItem[] } defineProps<{ menuItem: MenuItem }>() const router = useRouter() function handleMenuClick(route: string) { router.push(route) } </script> <style scoped> </style>

到这里,功能就已经基本实现了,还有一些小细节的优化放在下方(别忘了要添加相关路由的组件哦,比如我新增的菜单管理和用户管理,就要新增对应的组件才行哦):

菜单管理:\src\views\sys\menuManage.vue

<template> <div class="container"> 菜单管理 </div> </template> <script setup lang="ts"> </script> <style scoped> .container { } </style>

用户管理:\src\views\sys\UserManag.vue

<template> <div class="container"> 用户管理 </div> </template> <script setup lang="ts"> </script> <style scoped> .container { } </style>

小优化来啦:

(1)上面提到的路由守卫,因为我发现每次刷新后token就会丢失,需要重新登陆,且路由也会丢失,所以我在路由守卫中新增了routerStore.getRouter()和userStore.restoreUserInfo()的相关逻辑,意思是在发起请求前,都会检测一遍有没有路由信息和用户信息,如果没有则重新获取。具体文件改动上方有提。

(2)将‘\home’改为了‘\’,在自动重定向去系统管理

修改前:

{ // 其他页面懒加载 path: '/home', name: 'Home', component: () => import('../views/layout/index.vue'), meta: { requiresAuth: true } }

修改后:

{ // 其他页面懒加载 path: '/', name: 'Home', redirect: '/sysManage', meta: { requiresAuth: true } }

(3)每次登陆后虽然自动选中了我重定向后的路由,但是左侧的路由菜单仍然是收起状态未展开,使用感和观感都不好,所以我用了ant-design-vue中的openKeys,控制菜单展开:

主要是findParentKeys这个方法!找出需要展开的父级菜单节点!

\src\views\layout\components\siderBar\index.vue

<template> <div class="menu-scrollbar"> <a-menu mode="inline" v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys"> <MenuItem v-for="item in menuList" :key="item.route || item.id" :menu-item="item"></MenuItem> </a-menu> </div> </template> <script setup lang="ts"> import { ref, defineComponent, computed, watch} from 'vue' import { useRoute } from 'vue-router' import { useRouterStore } from '@/store/modules/routerList' import MenuItem from './menuItem.vue' defineComponent({ name: 'menu' }) const route = useRoute() const routerStore = useRouterStore() const selectedKeys = ref<string[]>([route.path]) const openKeys = ref<string[]>([]) const menuList = computed(() => routerStore.routerList) interface MenuRoute { route: string children?: MenuRoute[] } // 找出当前路由所属的所有父级菜单,用于初始化展开菜单 function findParentKeys(list: MenuRoute[], currentPath: string, parents: string[] = []): string[] { for (const item of list) { const nextParents = item.route ? [...parents, item.route] : parents if (item.route === currentPath) return parents if (item.children?.length) { const result = findParentKeys(item.children, currentPath, nextParents) if (result.length || item.children.some(child => child.route === currentPath)) return result } } return [] } // 监听路由变化,自动高亮 watch( [() => route.path, menuList], ([newPath, list]) => { selectedKeys.value = [newPath] openKeys.value = findParentKeys(list as MenuRoute[], newPath) }, { immediate: true } ) // function extractAllRoutes(routes: Route[]){ // const result: string[] = [] // routes.forEach(r => { // // 添加当前route // result.push(r.route) // // 如果有子路由,递归提取 // if (r.children && r.children.length) { // result.push(...extractAllRoutes(r.children)) // } // }) // return result // } </script> <style lang="scss" scoped> .menu-scrollbar { height: 100%; overflow-y: auto; overflow-x: hidden; } /* 自定义滚动条样式 */ .menu-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; } .menu-scrollbar::-webkit-scrollbar-track { background: transparent; border-radius: 3px; } .menu-scrollbar::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 3px; transition: all 0.3s; } .menu-scrollbar::-webkit-scrollbar-thumb:hover { background: #bfbfbf; } /* Firefox 兼容 */ .menu-scrollbar { scrollbar-width: thin; scrollbar-color: #d9d9d9 transparent; } </style>

最后实现效果为:

刚登陆时:

切换菜单:

菜单收缩(有点小瑕疵,之后再优化):

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/25 5:11:54

如何用 DriverStore Explorer 快速清理旧驱动

如何用 DriverStore Explorer 快速清理旧驱动 【免费下载链接】DriverStoreExplorer Driver Store Explorer 项目地址: https://gitcode.com/gh_mirrors/dr/DriverStoreExplorer 一张显卡的旧驱动就能占用超过 800MB 空间。DriverStore Explorer 是一款免费的 Windows 驱…

作者头像 李华
网站建设 2026/8/25 5:10:32

深度解析AI聊天本地部署:从开源模型到可控对话的实践指南

1. 先搞清楚“无限制AI聊天”到底在聊什么最近看到很多人在讨论“无限制AI聊天”、“无违禁词AI”这类话题&#xff0c;尤其是围绕DeepSeek及其衍生工具。很多人觉得&#xff0c;离开了某个特定平台&#xff0c;就找不到能“自由”对话的AI了&#xff0c;甚至有种“外面的世界在…

作者头像 李华
网站建设 2026/8/25 5:08:45

工业智能体:从核心架构到场景落地的工厂数字化转型实践

1. 项目概述&#xff1a;当工厂遇见智能体最近和几个在制造业干了十几年的老朋友聊天&#xff0c;大家不约而同地都在讨论一个词——“智能体”。从车间主任到技术总监&#xff0c;从设备维护到生产调度&#xff0c;似乎一夜之间&#xff0c;这个源自AI领域的概念&#xff0c;正…

作者头像 李华
网站建设 2026/8/25 5:05:25

企业级LLM应用数据库访问安全:权限生命周期管理与架构设计

这次我们来看一个在 LLM 应用开发领域&#xff0c;尤其是企业级场景下&#xff0c;一个至关重要但常被忽视的挑战&#xff1a;如何安全地让 LLM 访问生产数据库&#xff0c;并在需要时能干净、彻底地收回权限。项目标题“Giving an LLM your prod database is easy. Taking acc…

作者头像 李华
网站建设 2026/8/25 5:04:18

转行做无人机维修,值得投入一个月去学吗?

最近&#xff0c;“低空经济”彻底火了&#xff0c;无人机维修这个曾经低调的硬核技术赛道&#xff0c;突然成了转行求职者眼中的“香饽饽”。面对网上满天飞的“人才缺口大”、“月入过万”等标签&#xff0c;很多想寻求新出路的朋友都在问&#xff1a;现在转行做无人机维修&a…

作者头像 李华
网站建设 2026/8/25 5:04:11

免费的大模型Agnes-2.0-Flash升级到Agnes-2.5-Flash

Agnes-2.5-Flash vs Agnes-2.0-Flash&#xff1a;模型对比与接入指南依据 Agnes AI 官方文档整理 | https://agnes-ai.com/zh-Hans/docs/真伪✅ 真——基于 Agnes AI 官网公开文档核验&#xff08;agnes-ai.com/zh-Hans/docs/&#xff09;时间截至 2026-07-27 访问时核验对象开…

作者头像 李华