阅读视图

发现新文章,点击刷新页面。

ruoyi-vue(十五)——布局设置,导航栏,侧边栏,顶部栏

一、布局设置

在前端页面有一个布局设置,点开可以设置主题风格,颜色,是否显示标签页等功能。

image.png

1.1 路由

页面所有请求都是通过路由,先来看路由:src→router→index.js。 路由中引用了Layout,从项目中的 src/layout 路径导入一个默认导出的组件,并将其命名为 Layout

import Layout from '@/layout'

在vite.config.js中通过resolve.alias设置了路径别名,将@映射到项目的src目录。import Layout from '@/layout' 实际上就是从 src/layout/index.vue 文件中导入默认导出的组件。

'@': path.resolve(__dirname, './src')

除了登录,注册,404等页面外基本都用了Layout

// 公共路由
export const constantRoutes = [
  {
    path: '/redirect',
    component: Layout,
    hidden: true,
    children: [
      {
        path: '/redirect/:path(.*)',
        component: () => import('@/views/redirect/index.vue')
      }
    ]
  },
  {
    path: '/login',
    component: () => import('@/views/login'),
    hidden: true
  },
  {
    path: '/register',
    component: () => import('@/views/register'),
    hidden: true
  },
  {
    path: "/:pathMatch(.*)*",
    component: () => import('@/views/error/404'),
    hidden: true
  },
  {
    path: '/401',
    component: () => import('@/views/error/401'),
    hidden: true
  },
  {
    path: '',
    component: Layout,
    redirect: '/index',
    children: [
      {
        path: '/index',
        component: () => import('@/views/index'),
        name: 'Index',
        meta: { title: '首页', icon: 'dashboard', affix: true }
      }
    ]
  },
  {
    path: '/user',
    component: Layout,
    hidden: true,
    redirect: 'noredirect',
    children: [
      {
        path: 'profile/:activeTab?',
        component: () => import('@/views/system/user/profile/index'),
        name: 'Profile',
        meta: { title: '个人中心', icon: 'user' }
      }
    ]
  }
]

// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
  {
    path: '/system/user-auth',
    component: Layout,
    hidden: true,
    permissions: ['system:user:edit'],
    children: [
      {
        path: 'role/:userId(\\d+)',
        component: () => import('@/views/system/user/authRole'),
        name: 'AuthRole',
        meta: { title: '分配角色', activeMenu: '/system/user' }
      }
    ]
  },
  {
    path: '/system/role-auth',
    component: Layout,
    hidden: true,
    permissions: ['system:role:edit'],
    children: [
      {
        path: 'user/:roleId(\\d+)',
        component: () => import('@/views/system/role/authUser'),
        name: 'AuthUser',
        meta: { title: '分配用户', activeMenu: '/system/role' }
      }
    ]
  },
  {
    path: '/system/dict-data',
    component: Layout,
    hidden: true,
    permissions: ['system:dict:list'],
    children: [
      {
        path: 'index/:dictId(\\d+)',
        component: () => import('@/views/system/dict/data'),
        name: 'Data',
        meta: { title: '字典数据', activeMenu: '/system/dict' }
      }
    ]
  },
  {
    path: '/monitor/job-log',
    component: Layout,
    hidden: true,
    permissions: ['monitor:job:list'],
    children: [
      {
        path: 'index/:jobId(\\d+)',
        component: () => import('@/views/monitor/job/log'),
        name: 'JobLog',
        meta: { title: '调度日志', activeMenu: '/monitor/job' }
      }
    ]
  },
  {
    path: '/tool/gen-edit',
    component: Layout,
    hidden: true,
    permissions: ['tool:gen:edit'],
    children: [
      {
        path: 'index/:tableId(\\d+)',
        component: () => import('@/views/tool/gen/editTable'),
        name: 'GenEdit',
        meta: { title: '修改生成配置', activeMenu: '/tool/gen' }
      }
    ]
  }
]

1.2 组件

1.2.1 Template模板部分

<template>
  <div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
    <div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
    <sidebar v-if="!sidebar.hide" class="sidebar-container" />
    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
      <div :class="{ 'fixed-header': fixedHeader }">
        <navbar @setLayout="setLayout" />
        <tags-view v-if="needTagsView" />
      </div>
      <app-main />
      <settings ref="settingRef" />
    </div>
  </div>
</template>
  • 根容器:
    • 使用动态class和style,根据主题设置背景色
    • 根据设备类型和侧边栏状态添加不同CSS类
  • 移动端遮罩层
    • 当在移动端且侧边栏打开时显示半透明黑色遮罩
    • 点击遮罩会触发handleClickOutside关闭侧边栏
  • 侧边栏组件:
    • 根据sidebar.hider状态决定是否显示侧边栏
  • 主内容区域:
    • 包含固定头部(导航栏和标签视图)和主要内容区域
    • 根据设置决定是否显示标签页视图
    • 集成设置组件

1.2.2 Script脚本部分

<script setup>
// 导入模块
import { useWindowSize } from '@vueuse/core'
import Sidebar from './components/Sidebar/index.vue'
import { AppMain, Navbar, Settings, TagsView } from './components'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'

// 初始化设置状态管理实例,用于管理应用的主题、标签页显示等设置
const settingsStore = useSettingsStore()
//计算属性
const theme = computed(() => settingsStore.theme)
const sideTheme = computed(() => settingsStore.sideTheme)
const sidebar = computed(() => useAppStore().sidebar)
const device = computed(() => useAppStore().device)
const needTagsView = computed(() => settingsStore.tagsView)
const fixedHeader = computed(() => settingsStore.fixedHeader)

// 计算属性,判断是否需要固定头部
const classObj = computed(() => ({
  hideSidebar: !sidebar.value.opened,
  openSidebar: sidebar.value.opened,
  withoutAnimation: sidebar.value.withoutAnimation,
  mobile: device.value === 'mobile'
}))

// 根据侧边栏和设备状态返回相应的CSS类名对象,用于动态设置布局样式
const { width, height } = useWindowSize()
// 使用VueUse库获取窗口尺寸的响应式数据
const WIDTH = 992 // refer to Bootstrap's responsive design

// 定义断点宽度,小于该宽度视为移动设备
watch(() => device.value, () => {
  if (device.value === 'mobile' && sidebar.value.opened) {
    useAppStore().closeSideBar({ withoutAnimation: false })
  }
})

// 监听设备类型变化,当切换到移动设备且侧边栏打开时,关闭侧边栏并带动画
watchEffect(() => {
  if (width.value - 1 < WIDTH) {
    useAppStore().toggleDevice('mobile')
    useAppStore().closeSideBar({ withoutAnimation: true })
  } else {
    useAppStore().toggleDevice('desktop')
  }
})

// 响应式监听窗口宽度变化,当宽度小于断点时切换为移动设备模式并关闭侧边栏(无动画),
// 否则切换为桌面设备模式
function handleClickOutside() {
  useAppStore().closeSideBar({ withoutAnimation: false })
}

// 处理点击遮罩层事件,关闭侧边栏并带动画。
const settingRef = ref(null)
function setLayout() {
  settingRef.value.openSetting()
}
</script>
  • 导入模块
    • 使用@vueuse/core的useWindowSize监听窗口大小
    • 导入各种子组件(侧边栏、主内容、导航栏等)
  • 状态管理
    • 获取状态管理实例,用于管理应用的主题、标签页显示等设置
    • 计算属性包括主题、侧边栏状态、设备类型等
  • 自适应处理
    • 监听窗口大小变化,当宽度小于992px时切换为移动端模式
    • 移动端模式自动关闭侧边栏
  • 交互方法
    • handleClickOutside点击遮罩关闭侧边栏
    • setLayout打开设置面板

1.2.3 Style样式部分

<style lang="scss" scoped>
// 引入mixin和变量模块,分别命名为mix和vars
@use "@/assets/styles/mixin.scss" as mix;
@use "@/assets/styles/variables.module.scss" as vars;

// 应用包装器样式,使用clearfix清除浮动,设置相对定位,占满全屏
.app-wrapper {
  @include mix.clearfix;
  position: relative;
  height: 100%;
  width: 100%;
// 当在移动设备上且侧边栏打开时,使用固定定位。
  &.mobile.openSidebar {
    position: fixed;
    top: 0;
  }
}

// 移动端遮罩层样式,黑色半透明背景,覆盖全屏,层级为999。
.drawer-bg {
  background: #000;
  opacity: 0.3;
  width: 100%;
  top: 0;
  height: 100%;
  position: absolute;
  z-index: 999;
}

// 固定头部样式,使用固定定位,宽度为100%减去侧边栏宽度(200px),宽度变化有0.28秒过渡动画
.fixed-header {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 9;
  width: calc(100% - #{vars.$base-sidebar-width});
  transition: width 0.28s;
}

// 当侧边栏隐藏时,固定头部宽度为100%减去54px(折叠后的侧边栏宽度)
.hideSidebar .fixed-header {
  width: calc(100% - 54px);
}

.sidebarHide .fixed-header {
  width: 100%;
}

// 当侧边栏完全隐藏时,固定头部占满全屏宽度。
.mobile .fixed-header {
  width: 100%;
}
</style>
  • 移动端打开侧边栏时使用固定定位
  • 不同状态下(隐藏侧边栏、移动端等)的样式适配

二、导航栏

导航栏在web页面上方,左侧是一个隐藏或展开侧边栏的按钮,紧挨着的是面包屑导航组件,右边是工具栏。 在这里插入图片描述

2.1 Navbar组件

src→layout→components→Navbar.vue Navbar组件是系统的顶部导航栏,包含以下主要功能:

  • 侧边栏控制:通过汉堡菜单控制侧边栏展开/收起
  • 导航显示:根据设置显示面包屑或顶部菜单
  • 快捷功能:搜索、全屏、主题切换、布局大小调整
  • 用户信息:显示用户头像和昵称,提供个人中心和退出登录功能
  • 项目链接:提供源码和文档的快速访问
  • 响应式设计:移动端隐藏部分功能

这个组件整合了系统顶部的大部分常用功能,为用户提供了便捷的操作入口。

2.1.1 Template模板部分

<template>
  // 创建navbar容器,作为导航栏的根元素。
  <div class="navbar">
    // 汉堡菜单组件,用于控制侧边栏的展开/收起状态,点击时触发toggleSideBar方法
    <hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
    // 面包屑导航组件,当不使用顶部导航时显示,显示当前页面路径。
    <breadcrumb v-if="!settingsStore.topNav" id="breadcrumb-container" class="breadcrumb-container" />
    // 顶部导航菜单组件,当启用顶部导航时显示。
    <top-nav v-if="settingsStore.topNav" id="topmenu-container" class="topmenu-container" />

    // 右侧菜单容器,包含各种功能按钮和用户信息 
    <div class="right-menu">
      // 非移动设备上显示以下内容
      <template v-if="appStore.device !== 'mobile'">
        // 头部搜索组件,用于快速搜索菜单项
        <header-search id="header-search" class="right-menu-item" />
        // 显示项目源码链接的组件,使用Element Plus的tooltip提示"源码地址"。
        <el-tooltip content="源码地址" effect="dark" placement="bottom">
          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
        </el-tooltip>
        // 显示项目文档链接的组件,使用Element Plus的tooltip提示"文档地址"。
        <el-tooltip content="文档地址" effect="dark" placement="bottom">
          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
        </el-tooltip>
        // 全屏切换组件,用于切换页面全屏状态
        <screenfull id="screenfull" class="right-menu-item hover-effect" />
        // 主题切换组件
        <el-tooltip content="主题模式" effect="dark" placement="bottom">
          <div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
            <svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
            <svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
          </div>
        </el-tooltip>
        // 布局大小选择组件
        <el-tooltip content="布局大小" effect="dark" placement="bottom">
          <size-select id="size-select" class="right-menu-item hover-effect" />
        </el-tooltip>
      // 结束非移动端条件渲染
      </template>

      // 用户信息下拉菜单,显示用户头像和昵称,提供个人中心链接和退出登录功能
      <el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
        <div class="avatar-wrapper">
          <img :src="userStore.avatar" class="user-avatar" />
          <span class="user-nickname"> {{ userStore.nickName }} </span>
        </div>
        <template #dropdown>
          <el-dropdown-menu>
            <router-link to="/user/profile">
              <el-dropdown-item>个人中心</el-dropdown-item>
            </router-link>
            <el-dropdown-item divided command="logout">
              <span>退出登录</span>
            </el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
      // 设置按钮,当启用设置功能时显示,点击打开设置面板
      <div class="right-menu-item hover-effect setting" @click="setLayout" v-if="settingsStore.showSettings">
        <svg-icon icon-class="more-up" />
      </div>
    </div>
  </div>
// 结束navbar容器
</template>

2.1.2 Script脚本部分

<script setup>
// 导入Element Plus的消息框组件,用于确认对话框
import { ElMessageBox } from 'element-plus'
// 导入所需子组件
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect'
import HeaderSearch from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
// 导入状态管理模块
import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'

// 初始化各状态管理实例
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()

// 切换侧边栏展开/收起状态的方法。
function toggleSideBar() {
  appStore.toggleSideBar()
}

// 处理下拉菜单命令的通用方法,根据命令类型调用相应函数
function handleCommand(command) {
  switch (command) {
    case "setLayout":
      setLayout()
      break
    case "logout":
      logout()
      break
    default:
      break
  }
}
// 退出登录方法
function logout() {
  ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
      // 确认后调用用户登出方法并跳转到首页
    userStore.logOut().then(() => {
      location.href = '/index'
    })
  }).catch(() => { })
}

// 定义并实现setLayout事件发射方法,用于通知父组件打开设置面板
const emits = defineEmits(['setLayout'])
function setLayout() {
  emits('setLayout')
}

// 切换主题模式方法,调用设置存储中的切换主题功能
function toggleTheme() {
  settingsStore.toggleTheme()
}
</script>

2.1.3 Style样式部分

<style lang='scss' scoped>
// 导航栏基础样式
.navbar {
  height: 50px;
  overflow: hidden;
  position: relative;
  background: var(--navbar-bg);
  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

  // 汉堡菜单容器样式
  .hamburger-container {
    line-height: 46px;
    height: 100%;
    float: left;
    cursor: pointer;
    transition: background 0.3s;
    -webkit-tap-highlight-color: transparent;

    &:hover {
      background: rgba(0, 0, 0, 0.025);
    }
  }

  // 面包屑容器左浮动 
  .breadcrumb-container {
    float: left;
  }

  // 顶部菜单容器绝对定位
  .topmenu-container {
    position: absolute;
    left: 50px;
  }

  // 错误日志容器样式
  .errLog-container {
    display: inline-block;
    vertical-align: top;
  }

  // 右侧菜单样式容器
  .right-menu {
    float: right;
    height: 100%;
    line-height: 50px;
    display: flex;

    &:focus {
      outline: none;
    }

    // 右侧菜单项通用样式
    .right-menu-item {
      display: inline-block;
      padding: 0 8px;
      height: 100%;
      font-size: 18px;
      color: #5a5e66;
      vertical-align: text-bottom;

      &.hover-effect {
        cursor: pointer;
        transition: background 0.3s;

        &:hover {
          background: rgba(0, 0, 0, 0.025);
        }
      }
      // 主题切换按钮样式
      &.theme-switch-wrapper {
        display: flex;
        align-items: center;

        svg {
          transition: transform 0.3s;
          
          &:hover {
            transform: scale(1.15);
          }
        }
      }
    }
    
    // 用户头像容器样式
    .avatar-container {
      margin-right: 0px;
      padding-right: 0px;

      .avatar-wrapper {
        margin-top: 10px;
        right: 5px;
        position: relative;

        .user-avatar {
          cursor: pointer;
          width: 30px;
          height: 30px;
          border-radius: 50%;
        }

        .user-nickname{
          position: relative;
          left: 5px;
          bottom: 10px;
          font-size: 14px;
          font-weight: bold;
        }

        i {
          cursor: pointer;
          position: absolute;
          right: -20px;
          top: 25px;
          font-size: 12px;
        }
      }
    }
  }
}
</style>

三、侧边栏

web页面左侧的侧边栏

image.png

3.1 Sidebar组件

src→layout→Sidebar→index.vue Sidebar组件实现了完整的侧边栏导航功能,具有以下特点:

  • 可配置性:支持显示/隐藏Logo、主题切换、折叠/展开等配置
  • 动态菜单:根据权限动态生成菜单项
  • 主题支持:支持多种主题和暗黑模式
  • 状态管理:与应用状态管理集成,保持状态同步
  • 自适应:根据屏幕大小和设置自动调整布局,颜色和样式根据主题动态调整 通过组合Logo、SidebarItem等子组件构建完整的侧边栏界面

3.1.1 Template模板部分

<template>
  // 创建侧边栏容器,根据showLogo的值决定是否添加'has-logo'类
  <div :class="{ 'has-logo': showLogo }" class="sidebar-container">
    // 条件渲染Logo组件,当showLogo为true时显示,传递collapse属性控制Logo的折叠状态
    <logo v-if="showLogo" :collapse="isCollapse" />
    // 使用Element Plus的滚动条组件包装菜单内容,设置包装类名为scrollbar-wrapper
    <el-scrollbar wrap-class="scrollbar-wrapper">
      // 创建Element Plus菜单组件,并设置以下属性
      <el-menu
        // 当前激活的菜单项
        :default-active="activeMenu"
        // 控制菜单是否折叠
        :collapse="isCollapse"
        // 菜单背景色
        :background-color="getMenuBackground"
        // 菜单文字颜色
        :text-color="getMenuTextColor"
        // 只保持一个子菜单展开
        :unique-opened="true"
        // 激活菜单项的文字颜色
        :active-text-color="theme"
        // 是否使用折叠动画
        :collapse-transition="false"
        // 菜单模式为垂直
        mode="vertical"
        // 菜单主题类
        :class="sideTheme"
      >
        // 遍历sidebarRouters,为每个路由创建SidebarItem组件,传递路由信息和基础路径
        <sidebar-item
          v-for="(route, index) in sidebarRouters"
          :key="route.path + index"
          :item="route"
          :base-path="route.path"
        />
      // 结束菜单和滚动条组件
      </el-menu>
    </el-scrollbar>
  </div>
</template>

3.1.2 Script脚本部分

<script setup>
// 导入所需组件和模块
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/assets/styles/variables.module.scss'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'

// 获取路由和各状态管理实例
const route = useRoute()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()

// 计算属性,获取权限存储中的侧边栏路由
const sidebarRouters = computed(() => permissionStore.sidebarRouters)
// 根据设置决定是否显示logo
const showLogo = computed(() => settingsStore.sidebarLogo)
// 获取侧边栏主题设置
const sideTheme = computed(() => settingsStore.sideTheme)
// 获取当前主题颜色
const theme = computed(() => settingsStore.theme)
// 根据侧边栏是否打开决定菜单是否折叠
const isCollapse = computed(() => !appStore.sidebar.opened)

// 根据暗黑模式和主题设置获取菜单背景色
const getMenuBackground = computed(() => {
  if (settingsStore.isDark) {
    return 'var(--sidebar-bg)'
  }
  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
})

// 根据暗黑模式和主题设置获取菜单文字颜色
const getMenuTextColor = computed(() => {
  if (settingsStore.isDark) {
    return 'var(--sidebar-text)'
  }
  return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText
})

// 根据路由元信息决定当前激活的菜单项
const activeMenu = computed(() => {
  const { meta, path } = route
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})
</script>

3.1.3 Style样式部分

// 设置侧边栏容器背景色,使用v-bind绑定计算属性
<style lang="scss" scoped>
.sidebar-container {
  background-color: v-bind(getMenuBackground);
  // 设置滚动条包装器背景色
  .scrollbar-wrapper {
    background-color: v-bind(getMenuBackground);
  }

  // 设置菜单样式
  .el-menu {
    border: none;
    height: 100%;
    width: 100% !important;
    
    // 设置菜单项和子菜单标题在悬停时的背景色
    .el-menu-item, .el-sub-menu__title {
      &:hover {
        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
      }
    }

    // 设置菜单项文字颜色,并为激活状态的菜单项设置特殊颜色和背景
    .el-menu-item {
      color: v-bind(getMenuTextColor);
      
      &.is-active {
        color: var(--menu-active-text, #409eff);
        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
      }
    }

    // 设置子菜单标题文字颜色
    .el-sub-menu__title {
      color: v-bind(getMenuTextColor);
    }
  }
}
</style>

四、顶部栏

页面上方的菜单栏,开启后会在页面上方显示一级菜单 关闭状态: 在这里插入图片描述

打开状态: 在这里插入图片描述

4.1 TopNav组件

src→components→TopNav→index.vue TopNav组件是一个基于Element Plus的顶部水平导航菜单,主要功能包括:

  • 动态菜单显示:根据路由配置动态生成顶部菜单项
  • 菜单折叠:当菜单项过多时,自动将超出部分折叠到"更多菜单"下拉中
  • 自适应:根据屏幕宽度动态调整可见菜单项数量
  • 路由联动:与侧边栏菜单联动,点击顶部菜单项可切换侧边栏内容
  • 多类型链接支持:支持内部路由跳转和外部链接新窗口打开
  • 主题定制:支持主题颜色定制

该组件充分利用了Vue 3的响应式特性和组合式API,结合Element Plus组件库实现顶部导航菜单。

4.1.1 Template模板部分

<template>
  <el-menu
    // 绑定当前激活的菜单项
    :default-active="activeMenu"
    // 设置为水平模式
    mode="horizontal"
    // 菜单选项被选择时的处理函数
    @select="handleSelect"
    // 禁用菜单项的省略显示
    :ellipsis="false"
  >
    // 遍历显示顶部菜单项
    <template v-for="(item, index) in topMenus">
      <el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber">
        // 如果菜单项有图标则显示SVG图标
        <svg-icon
        v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
        :icon-class="item.meta.icon"/>
        // 显示菜单项标题
        {{ item.meta.title }}
      </el-menu-item>
    </template>

    <!-- 顶部菜单超出数量折叠 -->
    <el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
      // 折叠菜单的标题
      <template #title>更多菜单</template>
      // 显示被折叠的菜单项
      <template v-for="(item, index) in topMenus">
        <el-menu-item
          :index="item.path"
          :key="index"
          v-if="index >= visibleNumber">
        <svg-icon
          v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
          :icon-class="item.meta.icon"/>
        {{ item.meta.title }}
        </el-menu-item>
      </template>
    </el-sub-menu>
  </el-menu>
</template>

4.1.2 Script脚本部分

<script setup>
// 导入模块
import { constantRoutes } from "@/router"
import { isHttp } from '@/utils/validate'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'

// 顶部栏初始化
const visibleNumber = ref(null)
// 当前激活菜单的 index
const currentIndex = ref(null)
// 隐藏侧边栏路由
const hideList = ['/index', '/user/profile']

const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const route = useRoute()
const router = useRouter()

// 主题颜色
const theme = computed(() => settingsStore.theme)
// 所有的路由信息
const routers = computed(() => permissionStore.topbarRouters)

// 顶部显示菜单
const topMenus = computed(() => {
  let topMenus = []
  routers.value.map((menu) => {
    // 过滤隐藏菜单
    if (menu.hidden !== true) {
      // 兼容顶部栏一级菜单内部跳转
      if (menu.path === '/' && menu.children) {
          // 如果是根路径且有子菜单,使用第一个子菜单
          topMenus.push(menu.children[0])
      } else {
          // 否则直接使用该菜单
          topMenus.push(menu)
      }
    }
  })
  return topMenus
})

// 设置子路由
const childrenMenus = computed(() => {
  let childrenMenus = []
  routers.value.map((router) => {
    for (let item in router.children) {
      // 处理子路由路径
      if (router.children[item].parentPath === undefined) {
        if(router.path === "/") {
          router.children[item].path = "/" + router.children[item].path
        } else {
          if(!isHttp(router.children[item].path)) {
            router.children[item].path = router.path + "/" + router.children[item].path
          }
        }
        router.children[item].parentPath = router.path
      }
      childrenMenus.push(router.children[item])
    }
  })
  // 合并常量路由和处理后的子路由
  return constantRoutes.concat(childrenMenus)
})

// 默认激活的菜单
const activeMenu = computed(() => {
  const path = route.path
  let activePath = path
  // 根据当前路由路径确定激活的菜单项
  if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
    const tmpPath = path.substring(1, path.length)
    if (!route.meta.link) {
      activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"))
      // 显示侧边栏
      appStore.toggleSideBarHide(false)
    }
  } else if(!route.children) {
    activePath = path
    // 隐藏侧边栏
    appStore.toggleSideBarHide(true)
  }
  activeRoutes(activePath)
  return activePath
})

// 设置可见菜单项数量
function setVisibleNumber() {
  const width = document.body.getBoundingClientRect().width / 3
  // 根据屏幕宽度计算可显示菜单项数量
  visibleNumber.value = parseInt(width / 85)
}

// 处理菜单项选择
function handleSelect(key, keyPath) {
  currentIndex.value = key
  const route = routers.value.find(item => item.path === key)
  if (isHttp(key)) {
    // http(s):// 路径新窗口打开
    window.open(key, "_blank")
  } else if (!route || !route.children) {
    // 没有子路由路径内部打开
    const routeMenu = childrenMenus.value.find(item => item.path === key)
    if (routeMenu && routeMenu.query) {
      let query = JSON.parse(routeMenu.query)
      router.push({ path: key, query: query })
    } else {
      router.push({ path: key })
    }
    appStore.toggleSideBarHide(true)
  } else {
    // 显示左侧联动菜单
    activeRoutes(key)
    appStore.toggleSideBarHide(false)
  }
}

// 激活路由
function activeRoutes(key) {
  let routes = []
  if (childrenMenus.value && childrenMenus.value.length > 0) {
    childrenMenus.value.map((item) => {
      if (key == item.parentPath || (key == "index" && "" == item.path)) {
        routes.push(item)
      }
    })
  }
  if(routes.length > 0) {
    // 设置侧边栏路由
    permissionStore.setSidebarRouters(routes)
  } else {
    appStore.toggleSideBarHide(true)
  }
  return routes
}

// 声明周期钩子
onMounted(() => {
  // 监听窗口大小变化
  window.addEventListener('resize', setVisibleNumber)
})

onBeforeUnmount(() => {
  // 移除事件监听
  window.removeEventListener('resize', setVisibleNumber)
})

onMounted(() => {
  // 初始化可见菜单项数量
  setVisibleNumber()
})
</script>

4.1.3 Style样式部分

<style lang="scss">
// 菜单项基本样式
.topmenu-container.el-menu--horizontal > .el-menu-item {
  float: left;
  height: 50px !important;
  line-height: 50px !important;
  color: #999093 !important;
  padding: 0 5px !important;
  margin: 0 10px !important;
}

// 激活菜单项样式
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
  border-bottom: 2px solid #{'var(--theme)'} !important;
  color: #303133;
}

// 子菜单项样式
/* sub-menu item */
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
  float: left;
  height: 50px !important;
  line-height: 50px !important;
  color: #999093 !important;
  padding: 0 5px !important;
  margin: 0 10px !important;
}

/* 背景色隐藏 */
.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
  background-color: #ffffff;
}

/* 图标右间距 */
.topmenu-container .svg-icon {
  margin-right: 4px;
}

// 菜单箭头样式
/* topmenu more arrow */
.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
  position: static;
  vertical-align: middle;
  margin-left: 8px;
  margin-top: 0px;
}


</style>
❌