@TOC
前言
在现代单页应用 (SPA) 中,尤其是后台管理系统,菜单导航是核心功能之一。当菜单项数量庞大且层级较深时,一个高效的菜单搜索功能能够极大地提升用户体验。本文将详细介绍一个基于 Vue 3 (Composition API) 和 Tailwind CSS 实现的菜单搜索组件 MenuSearch.vue
。
图片预览:
搜索框形态
触发弹窗搜索形态:
一、技术栈
- Vue 3: 使用 Composition API 进行逻辑组织,利用其响应式系统。
- Tailwind CSS: 用于快速构建美观的 UI 界面,提供原子化的 CSS 类。
- Vue Router: 用于菜单项点击后的路由导航。
二、核心功能
- 实时搜索: 用户输入关键词时,实时从菜单数据中筛选匹配项。
- 扁平化处理: 将层级嵌套的菜单数据扁平化,并生成面包屑路径,方便搜索和展示。
- 弹窗展示: 点击搜索框或使用快捷键
Ctrl+K
时,以模态弹窗形式展示搜索界面和结果。 - 键盘导航: 支持使用
↑
、↓
键在搜索结果中选择,Enter
键确认导航,Esc
键关闭弹窗。 - 模糊匹配: 同时搜索菜单标题和面包屑路径。
- 清晰提示: 搜索无结果时给出提示,并在弹窗底部显示操作快捷键。
- 自动聚焦: 弹窗打开时,搜索框自动获得焦点。
三、菜单数据结构 (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" 提示。
- 显示图标 (当前为通用占位符 SVG,可根据
- 当
- 弹窗底部: 使用
<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
。 - 将搜索词和菜单项的
title
、breadcrumb
都转为小写进行不区分大小写的比较。 - 使用
filter
方法从allMenuItems
中筛选出包含搜索词的项。 - 重置
activeIndex
为0
。
- 当
navigateToMenu(menuItem)
:- 接收一个菜单项对象。
- 如果菜单项有效且有
path
,则使用router.push(menuItem.path)
进行页面跳转。 - 调用
closeSearchModal()
关闭弹窗。
closeSearchModal()
:- 设置
showSearchModal
为false
。 - 清空
searchTerm
和searchResults
。
- 设置
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>
标签(用于显示键盘按键)设置了特定的字体和大小,使其看起来更像键盘按键。
五、如何集成和使用
-
准备菜单数据: 确保在
@/config/menus.js
(或指定的路径) 中定义了符合格式的菜单数据。 -
引入组件: 在需要使用菜单搜索的父组件中引入
MenuSearch.vue
。<template> <div class="sidebar"> <!-- 其他侧边栏内容 --> <MenuSearch /> <!-- 菜单列表等 --> </div> </template> <script setup> import MenuSearch from '@/components/MenuSearch.vue'; // 假设组件路径 </script>
-
确保路由配置:
MenuSearch
组件通过vue-router
进行导航,因此您的项目中需要正确配置路由,且menus.js
中的path
值应与路由配置匹配。
六、可优化点与注意事项
- 图标渲染: 当前搜索结果中的图标是一个固定的 SVG。实际项目中,可能需要根据
result.icon
的值动态渲染不同的图标(例如,使用 SVG 图标库、字体图标或自定义组件)。 - 搜索节流/防抖 (Debounce/Throttle): 对于
handleSearch
方法,如果用户输入非常快,可能会频繁触发搜索。可以考虑使用防抖函数 (lodash_.debounce
或自定义实现) 来优化性能,在用户停止输入一段时间后再执行搜索。 - 可访问性 (Accessibility): 可以进一步增强可访问性,例如为搜索结果列表和活动项添加适当的 ARIA 属性。
- 自定义样式: 虽然 Tailwind CSS 提供了强大的样式能力,但可能需要根据项目主题调整颜色、边框、圆角等。可以直接修改组件内的 Tailwind 类,或通过覆盖 Tailwind 配置来实现。
- 国际化: 如果项目需要支持多语言,菜单的
title
和breadcrumb
的生成逻辑需要考虑国际化方案。 provide/inject
的使用场景:provide('menuSearch', searchTerm)
的设计暗示着有其他兄弟或子组件(如实际的菜单渲染组件)可能需要根据搜索词高亮自身。如果仅是本组件内部使用,则此provide
不是必需的。