Vue3 + Tailwind CSS 菜单搜索组件

0 阅读9分钟

@TOC


前言

在现代单页应用 (SPA) 中,尤其是后台管理系统,菜单导航是核心功能之一。当菜单项数量庞大且层级较深时,一个高效的菜单搜索功能能够极大地提升用户体验。本文将详细介绍一个基于 Vue 3 (Composition API) 和 Tailwind CSS 实现的菜单搜索组件 MenuSearch.vue

图片预览: 搜索框形态

在这里插入图片描述 触发弹窗搜索形态: 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

一、技术栈

  • Vue 3: 使用 Composition API 进行逻辑组织,利用其响应式系统。
  • Tailwind CSS: 用于快速构建美观的 UI 界面,提供原子化的 CSS 类。
  • Vue Router: 用于菜单项点击后的路由导航。

二、核心功能

  1. 实时搜索: 用户输入关键词时,实时从菜单数据中筛选匹配项。
  2. 扁平化处理: 将层级嵌套的菜单数据扁平化,并生成面包屑路径,方便搜索和展示。
  3. 弹窗展示: 点击搜索框或使用快捷键 Ctrl+K 时,以模态弹窗形式展示搜索界面和结果。
  4. 键盘导航: 支持使用 键在搜索结果中选择,Enter 键确认导航,Esc 键关闭弹窗。
  5. 模糊匹配: 同时搜索菜单标题和面包屑路径。
  6. 清晰提示: 搜索无结果时给出提示,并在弹窗底部显示操作快捷键。
  7. 自动聚焦: 弹窗打开时,搜索框自动获得焦点。

三、菜单数据结构 (menus.js)

组件依赖一个特定结构的菜单数据。menus.js 文件导出了一个名为 menus 的数组,每个菜单项对象包含以下属性:

  • title (String): 菜单项的显示标题。
  • icon (String, 可选): 菜单项的图标标识 (具体渲染方式需自行实现或对接图标库)。
  • path (String, 可选): 菜单项对应的路由路径。只有包含 path 的菜单项才会被视为可导航的最终菜单。
  • children (Array, 可选): 子菜单项数组,结构与父级相同,用于表示层级关系。
// @/config/menus.js
export const menus = [
    {
        title: '仪表盘',
        icon: 'home',
        path: '/'
    },
    {
        title: '系统管理',
        icon: 'setting',
        children: [
            { title: '用户管理', icon: 'home', path: '/system/user' },
            { title: '角色管理', icon: 'home', path: '/system/role' },
            {
                title: '菜单管理',
                icon: 'menu',
                children: [
                    { title: '菜单列表', icon: 'home', path: '/system/menus/list' },
                    { title: '权限设置', icon: 'unlock', path: '/system/menus/permissions' }
                ]
            }
        ]
    },
    // ...更多菜单项
];

四、MenuSearch.vue 组件详解

MenuSearch.vue完整代码:

<template>
<div class="p-4">
    <div class="relative">
    <div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
        <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
        </svg>
    </div>
    <input 
        v-model="searchTerm"
        type="text" 
        placeholder="搜索菜单 (Ctrl+K)" 
        class="block py-2 pl-10 w-full text-sm placeholder-gray-400 text-gray-100 bg-gray-800 rounded border-0 focus:outline-none focus:ring-1 focus:ring-gray-700"
        @input="handleSearch"
        @focus="showSearchModal = true"
        @click="showSearchModal = true"
        ref="searchInput"
    >
    </div>

    <!-- 搜索弹窗 -->
    <div v-if="showSearchModal" class="fixed inset-0 z-50 flex items-start justify-center pt-16" @click.self="closeSearchModal">
        <div class="bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-hidden">
            <div class="p-4 border-b border-gray-700">
                <div class="relative">
                    <div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
                        <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
                        </svg>
                    </div>
                    <input 
                        v-model="searchTerm"
                        type="text" 
                        placeholder="搜索菜单..." 
                        class="block py-3 pl-10 w-full text-base placeholder-gray-400 text-gray-100 bg-gray-700 rounded border-0 focus:outline-none focus:ring-1 focus:ring-blue-500"
                        @input="handleSearch"
                        ref="modalSearchInput"
                        autofocus
                    >
                </div>
            </div>
            
            <div class="max-h-96 overflow-y-auto">
                <div v-if="searchResults.length === 0 && searchTerm" class="p-4 text-gray-400 text-center">
                    未找到匹配的菜单项
                </div>
                <div v-else-if="searchTerm">
                    <div 
                        v-for="(result, index) in searchResults" 
                        :key="index"
                        class="p-3 hover:bg-gray-700 cursor-pointer flex items-center"
                        :class="{'bg-gray-700': activeIndex === index}"
                        @click="navigateToMenu(result)"
                        @mouseenter="activeIndex = index"
                    >
                        <div class="mr-3 text-gray-400">
                            <svg v-if="result.icon" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
                            </svg>
                        </div>
                        <div>
                            <div class="text-gray-100">{{ result.title }}</div>
                            <div v-if="result.breadcrumb" class="text-xs text-gray-400">{{ result.breadcrumb }}</div>
                        </div>
                        <div class="ml-auto text-xs text-gray-400">
                            <kbd class="px-2 py-1 bg-gray-600 rounded">Enter</kbd>
                        </div>
                    </div>
                </div>
            </div>
            
            <div class="p-3 border-t border-gray-700 text-xs text-gray-400 flex justify-between">
                <div>
                    <span class="mr-2 space-x-2">
                        <kbd class="px-2 py-1 bg-gray-600 rounded"></kbd>
                        <kbd class="px-2 py-1 bg-gray-600 rounded"></kbd>
                        选择
                    </span>
                    <span class="mr-2">
                        <kbd class="px-2 py-1 bg-gray-600 rounded">Enter</kbd>
                        打开
                    </span>
                </div>
                <div>
                    <span>
                        <kbd class="px-2 py-1 bg-gray-600 rounded">Esc</kbd>
                        关闭
                    </span>
                </div>
            </div>
        </div>
    </div>
</div>
</template>

<script setup>
import { ref, provide, inject, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { menus } from '@/config/menus'

const router = useRouter()
const searchTerm = ref('')
const showSearchModal = ref(false)
const searchResults = ref([])
const activeIndex = ref(0)
const searchInput = ref(null)
const modalSearchInput = ref(null)

// 提供搜索词的响应式对象,让菜单组件可以访问
provide('menuSearch', searchTerm)

// 扁平化菜单,用于搜索
const flattenMenus = (menuItems, parentPath = [], result = []) => {
  menuItems.forEach(item => {
    const currentPath = [...parentPath, item.title]
    const breadcrumb = currentPath.join(' > ')
    
    // 添加当前菜单项到结果中
    if (item.path) {
      result.push({
        title: item.title,
        path: item.path,
        icon: item.icon,
        breadcrumb: parentPath.length > 0 ? breadcrumb : null
      })
    }
    
    // 递归处理子菜单
    if (item.children && item.children.length > 0) {
      flattenMenus(item.children, currentPath, result)
    }
  })
  
  return result
}

const allMenuItems = computed(() => flattenMenus(menus))

// 处理搜索
const handleSearch = () => {
  if (!searchTerm.value.trim()) {
    searchResults.value = []
    return
  }
  
  const term = searchTerm.value.toLowerCase()
  searchResults.value = allMenuItems.value.filter(item => 
    item.title.toLowerCase().includes(term) || 
    (item.breadcrumb && item.breadcrumb.toLowerCase().includes(term))
  )
  
  activeIndex.value = 0
}

// 导航到选中的菜单
const navigateToMenu = (menuItem) => {
  if (menuItem && menuItem.path) {
    router.push(menuItem.path)
    closeSearchModal()
  }
}

// 关闭搜索弹窗
const closeSearchModal = () => {
  showSearchModal.value = false
  searchTerm.value = ''
  searchResults.value = []
}

// 处理键盘事件
const handleKeyDown = (e) => {
  // Ctrl+K 快捷键打开搜索弹窗
  if (e.ctrlKey && e.key === 'k') {
    e.preventDefault()
    showSearchModal.value = true
    nextTick(() => {
      if (modalSearchInput.value) {
        modalSearchInput.value.focus()
      }
    })
  }
  
  // 如果弹窗已打开,处理导航键
  if (showSearchModal.value) {
    switch (e.key) {
      case 'Escape':
        e.preventDefault()
        closeSearchModal()
        break
      case 'ArrowUp':
        e.preventDefault()
        if (searchResults.value.length > 0) {
          activeIndex.value = (activeIndex.value - 1 + searchResults.value.length) % searchResults.value.length
        }
        break
      case 'ArrowDown':
        e.preventDefault()
        if (searchResults.value.length > 0) {
          activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
        }
        break
      case 'Enter':
        e.preventDefault()
        if (searchResults.value.length > 0) {
          navigateToMenu(searchResults.value[activeIndex.value])
        }
        break
    }
  }
}

// 当弹窗打开时,自动聚焦搜索输入框
watch(showSearchModal, (newVal) => {
  if (newVal) {
    nextTick(() => {
      if (modalSearchInput.value) {
        modalSearchInput.value.focus()
      }
    })
  }
})

// 挂载全局键盘事件监听器
onMounted(() => {
  window.addEventListener('keydown', handleKeyDown)
})

// 卸载全局键盘事件监听器
onBeforeUnmount(() => {
  window.removeEventListener('keydown', handleKeyDown)
})
</script>

<style scoped>
.fixed {
  position: fixed;
}

.inset-0 {
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

.z-50 {
  z-index: 50;
}

kbd {
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  font-size: 0.75rem;
  line-height: 1rem;
}
</style> 

1. 模板 (Template)

组件模板主要包含两部分:

  • 主搜索框 (div.relative):

    • 一个带有搜索图标的 input 元素。
    • 通过 v-model="searchTerm" 绑定搜索关键词。
    • @focus@click 事件用于触发 showSearchModal = true 来显示搜索弹窗。
    • placeholder 提示用户可以使用 Ctrl+K 快捷键。
    • ref="searchInput" 用于潜在的外部聚焦需求。
  • 搜索弹窗 (div v-if="showSearchModal"):

    • 固定定位 (fixed inset-0),覆盖整个屏幕,并使用 z-50 确保层级。
    • 点击弹窗外部区域 (@click.self="closeSearchModal") 可关闭弹窗。
    • 弹窗头部: 包含一个带图标的搜索输入框 (ref="modalSearchInput"),同样绑定 searchTerm,并设置 autofocus
    • 搜索结果区域 (div.max-h-96 overflow-y-auto):
      • searchResults.length === 0 && searchTerm 时,显示 "未找到匹配的菜单项"。
      • 否则,使用 v-for 遍历 searchResults 展示结果。
      • 每个结果项:
        • 显示图标 (当前为通用占位符 SVG,可根据 result.icon 自定义)、标题和面包屑路径 (result.breadcrumb)。
        • @click="navigateToMenu(result)" 点击导航。
        • @mouseenter="activeIndex = index" 鼠标悬浮时更新高亮项。
        • :class="{'bg-gray-700': activeIndex === index}" 根据 activeIndex 高亮当前选中项。
        • 右侧显示 "Enter" 提示。
    • 弹窗底部: 使用 <kbd> 标签显示键盘操作提示 (↑ ↓ 选择, Enter 打开, Esc 关闭)。

2. 脚本 (<script setup>)

采用 Vue 3 Composition API 编写。

  • 响应式状态 (ref):

    • searchTerm: 存储用户输入的搜索词。
    • showSearchModal: 控制搜索弹窗的显示与隐藏。
    • searchResults: 存储过滤后的菜单项结果。
    • activeIndex: 当前键盘选中的搜索结果索引。
    • searchInput, modalSearchInput: input 元素的引用。
  • 依赖注入 (provide, inject):

    • provide('menuSearch', searchTerm): 虽然在此组件中没有直接的 inject,但设计上允许子组件或后代组件注入并响应 searchTerm 的变化。在当前组件的上下文中,此 provide 不是必需的,除非有其他组件依赖它。
  • 路由 (useRouter):

    • const router = useRouter(): 获取 Vue Router 实例用于导航。
  • 菜单数据 (menus):

    • import { menus } from '@/config/menus': 导入预定义的菜单数据。
  • 核心方法:

    • flattenMenus(menuItems, parentPath = [], result = []):
      • 递归函数,将树状的 menus 数据扁平化为一维数组 allMenuItems
      • 为每个可导航的菜单项(即包含 path 属性的项)创建一个对象,包含 title, path, icon
      • 同时,它会累积父级菜单的 title,生成 breadcrumb 字符串 (如: "系统管理 > 菜单管理 > 菜单列表"),用于更精确的搜索和展示。
    • allMenuItems = computed(() => flattenMenus(menus)):
      • 使用 computed 缓存扁平化后的菜单数据,只有当原始 menus 数据变化时才会重新计算。
    • handleSearch():
      • searchTerm 更新时调用 (通过 @input 事件)。
      • 如果搜索词为空,清空 searchResults
      • 将搜索词和菜单项的 titlebreadcrumb 都转为小写进行不区分大小写的比较。
      • 使用 filter 方法从 allMenuItems 中筛选出包含搜索词的项。
      • 重置 activeIndex0
    • navigateToMenu(menuItem):
      • 接收一个菜单项对象。
      • 如果菜单项有效且有 path,则使用 router.push(menuItem.path) 进行页面跳转。
      • 调用 closeSearchModal() 关闭弹窗。
    • closeSearchModal():
      • 设置 showSearchModalfalse
      • 清空 searchTermsearchResults
    • handleKeyDown(e):
      • 全局键盘事件监听器回调。
      • Ctrl+K: 阻止默认行为,打开搜索弹窗 (showSearchModal = true),并使用 nextTick 确保弹窗渲染后 modalSearchInput 聚焦。
      • 弹窗打开时:
        • Escape: 关闭弹窗。
        • ArrowUp: activeIndex 向上移动,循环选择。
        • ArrowDown: activeIndex 向下移动,循环选择。
        • Enter: 如果有搜索结果,则导航到 searchResults[activeIndex.value] 对应的菜单。
  • 侦听器 (watch):

    • watch(showSearchModal, (newVal) => { ... }): 侦听 showSearchModal 的变化。当其变为 true (弹窗打开) 时,使用 nextTick 确保 DOM 更新后,让弹窗内的搜索框 (modalSearchInput) 自动获得焦点。
  • 生命周期钩子:

    • onMounted(): 组件挂载后,添加全局 keydown 事件监听器 handleKeyDown
    • onBeforeUnmount(): 组件卸载前,移除全局 keydown 事件监听器,防止内存泄漏。

3. 样式 (<style scoped>)

组件主要依赖 Tailwind CSS 的原子类进行样式设置。<style scoped> 中定义了一些辅助样式:

  • .fixed, .inset-0, .z-50: 这些是 Tailwind CSS 也有的类,写的时候又写起了自定义样式了,稍微不太规范,可以当作优化点自行优化。
  • kbd: 为 <kbd> 标签(用于显示键盘按键)设置了特定的字体和大小,使其看起来更像键盘按键。

五、如何集成和使用

  1. 准备菜单数据: 确保在 @/config/menus.js (或指定的路径) 中定义了符合格式的菜单数据。

  2. 引入组件: 在需要使用菜单搜索的父组件中引入 MenuSearch.vue

    <template>
      <div class="sidebar">
        <!-- 其他侧边栏内容 -->
        <MenuSearch />
        <!-- 菜单列表等 -->
      </div>
    </template>
    
    <script setup>
    import MenuSearch from '@/components/MenuSearch.vue'; // 假设组件路径
    </script>
    
  3. 确保路由配置: MenuSearch 组件通过 vue-router 进行导航,因此您的项目中需要正确配置路由,且 menus.js 中的 path 值应与路由配置匹配。

六、可优化点与注意事项

  1. 图标渲染: 当前搜索结果中的图标是一个固定的 SVG。实际项目中,可能需要根据 result.icon 的值动态渲染不同的图标(例如,使用 SVG 图标库、字体图标或自定义组件)。
  2. 搜索节流/防抖 (Debounce/Throttle): 对于 handleSearch 方法,如果用户输入非常快,可能会频繁触发搜索。可以考虑使用防抖函数 (lodash _.debounce 或自定义实现) 来优化性能,在用户停止输入一段时间后再执行搜索。
  3. 可访问性 (Accessibility): 可以进一步增强可访问性,例如为搜索结果列表和活动项添加适当的 ARIA 属性。
  4. 自定义样式: 虽然 Tailwind CSS 提供了强大的样式能力,但可能需要根据项目主题调整颜色、边框、圆角等。可以直接修改组件内的 Tailwind 类,或通过覆盖 Tailwind 配置来实现。
  5. 国际化: 如果项目需要支持多语言,菜单的 titlebreadcrumb 的生成逻辑需要考虑国际化方案。
  6. provide/inject 的使用场景: provide('menuSearch', searchTerm) 的设计暗示着有其他兄弟或子组件(如实际的菜单渲染组件)可能需要根据搜索词高亮自身。如果仅是本组件内部使用,则此 provide 不是必需的。