Jelajahi Sumber

feat(layout): 添加顶部菜单搜索功能

- 在顶栏右侧添加搜索图标,支持点击或快捷键 Ctrl+K/Cmd+K 打开搜索对话框
- 实现菜单标题和路径的模糊搜索,支持键盘上下导航和回车选择
- 搜索结果高亮显示匹配文本,点击可快速跳转至对应页面
ylong 1 bulan lalu
induk
melakukan
1fd49f3d93
2 mengubah file dengan 318 tambahan dan 0 penghapusan
  1. 313 0
      src/layout/components/header-search.vue
  2. 5 0
      src/layout/index.vue

+ 313 - 0
src/layout/components/header-search.vue

@@ -0,0 +1,313 @@
+<!-- 顶部菜单搜索 -->
+<template>
+    <div class="header-search-icon" @click.stop="openSearch">
+        <el-icon style="transform: scale(1.18)">
+            <SearchOutlined />
+        </el-icon>
+    </div>
+
+    <el-dialog
+        v-model="visible"
+        width="600px"
+        :show-close="false"
+        :modal="true"
+        destroy-on-close
+        class="menu-search-dialog"
+        top="15vh"
+        @closed="handleClosed"
+        append-to-body
+    >
+        <div class="search-container">
+            <div class="search-input-wrapper">
+                <el-icon class="search-icon"><Search /></el-icon>
+                <input
+                    ref="inputRef"
+                    v-model="searchVal"
+                    class="search-input"
+                    placeholder="搜索菜单..."
+                    @input="handleInput"
+                    @keydown.down.prevent="navigate(1)"
+                    @keydown.up.prevent="navigate(-1)"
+                    @keydown.enter.prevent="selectCurrent"
+                />
+            </div>
+            
+            <div class="search-results" v-show="options.length > 0 || searchVal">
+                <div v-if="options.length === 0" class="no-data">无匹配菜单</div>
+                <div v-else class="result-list">
+                    <div
+                        v-for="(item, index) in options"
+                        :key="item.path"
+                        class="result-item"
+                        :class="{ 'is-active': index === activeIndex }"
+                        @mouseenter="activeIndex = index"
+                        @click="selectItem(item)"
+                    >
+                        <div class="result-item-content">
+                            <span class="item-title" v-html="highlightText(item.title, searchVal)"></span>
+                            <span class="item-path" v-html="highlightText(item.path, searchVal)"></span>
+                        </div>
+                        <el-icon class="enter-icon"><Back /></el-icon>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </el-dialog>
+</template>
+
+<script setup>
+import { ref, nextTick, computed, onMounted, onUnmounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { SearchOutlined } from '@/components/icons';
+import { Search, Back } from '@element-plus/icons-vue';
+import { useUserStore } from '@/store/modules/user';
+
+const router = useRouter();
+const userStore = useUserStore();
+
+const visible = ref(false);
+const searchVal = ref('');
+const options = ref([]);
+const inputRef = ref(null);
+const activeIndex = ref(0);
+
+// Flatten menus
+const flattenMenus = (menus, parentTitle = '') => {
+    let result = [];
+    if (!menus) return result;
+    menus.forEach(menu => {
+        const title = parentTitle ? `${parentTitle} / ${menu.meta?.title}` : menu.meta?.title;
+        // Only leaf nodes or actionable menus
+        if (!menu.children || menu.children.length === 0) {
+            if (!menu.meta?.hide) {
+                result.push({
+                    title: title || menu.path,
+                    path: menu.path
+                });
+            }
+        }
+        if (menu.children && menu.children.length > 0) {
+            result = result.concat(flattenMenus(menu.children, title));
+        }
+    });
+    return result;
+};
+
+const allMenus = computed(() => flattenMenus(userStore.menus || []));
+
+const openSearch = () => {
+    visible.value = true;
+    searchVal.value = '';
+    options.value = [];
+    activeIndex.value = 0;
+    nextTick(() => {
+        inputRef.value?.focus();
+    });
+};
+
+const handleClosed = () => {
+    searchVal.value = '';
+    options.value = [];
+    activeIndex.value = 0;
+};
+
+const handleInput = () => {
+    const query = searchVal.value.trim();
+    if (query) {
+        const lowerQuery = query.toLowerCase();
+        options.value = allMenus.value.filter(item => {
+            return item.title.toLowerCase().includes(lowerQuery) || item.path.toLowerCase().includes(lowerQuery);
+        });
+        activeIndex.value = 0; // Reset active index on new search
+    } else {
+        options.value = [];
+    }
+};
+
+const navigate = (direction) => {
+    if (options.value.length === 0) return;
+    activeIndex.value += direction;
+    if (activeIndex.value < 0) {
+        activeIndex.value = options.value.length - 1;
+    } else if (activeIndex.value >= options.value.length) {
+        activeIndex.value = 0;
+    }
+    
+    // Auto scroll
+    nextTick(() => {
+        const activeEl = document.querySelector('.result-item.is-active');
+        if (activeEl) {
+            activeEl.scrollIntoView({ block: 'nearest' });
+        }
+    });
+};
+
+const selectCurrent = () => {
+    if (options.value.length > 0 && activeIndex.value >= 0) {
+        selectItem(options.value[activeIndex.value]);
+    }
+};
+
+const selectItem = (item) => {
+    if (item && item.path) {
+        router.push(item.path);
+        visible.value = false;
+    }
+};
+
+const highlightText = (text, query) => {
+    if (!query || !text) return text;
+    // Escape regex special characters
+    const safeQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const regex = new RegExp(`(${safeQuery})`, 'gi');
+    return text.replace(regex, '<span class="highlight">$1</span>');
+};
+
+// Keyboard shortcut Ctrl+K / Cmd+K
+const handleKeydown = (e) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+        e.preventDefault();
+        openSearch();
+    }
+};
+
+onMounted(() => {
+    window.addEventListener('keydown', handleKeydown);
+});
+
+onUnmounted(() => {
+    window.removeEventListener('keydown', handleKeydown);
+});
+</script>
+
+<style scoped>
+.header-search-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+    cursor: pointer;
+}
+
+.search-container {
+    display: flex;
+    flex-direction: column;
+}
+
+.search-input-wrapper {
+    display: flex;
+    align-items: center;
+    padding: 0 16px;
+    height: 60px;
+    border-bottom: 1px solid var(--el-border-color-light);
+}
+
+.search-icon {
+    font-size: 22px;
+    color: var(--el-text-color-secondary);
+    margin-right: 12px;
+}
+
+.search-input {
+    flex: 1;
+    height: 100%;
+    border: none;
+    outline: none;
+    font-size: 18px;
+    color: var(--el-text-color-primary);
+    background: transparent;
+}
+
+.search-input::placeholder {
+    color: var(--el-text-color-placeholder);
+}
+
+.search-results {
+    max-height: 400px;
+    overflow-y: auto;
+    padding: 12px 0;
+}
+
+.no-data {
+    padding: 30px 0;
+    text-align: center;
+    color: var(--el-text-color-secondary);
+    font-size: 14px;
+}
+
+.result-list {
+    display: flex;
+    flex-direction: column;
+    padding: 0 12px;
+}
+
+.result-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 16px;
+    margin-bottom: 8px;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s;
+    background-color: var(--el-fill-color-blank);
+    border: 1px solid var(--el-border-color-lighter);
+}
+
+.result-item:last-child {
+    margin-bottom: 0;
+}
+
+.result-item.is-active,
+.result-item:hover {
+    background-color: var(--el-color-primary-light-9);
+    border-color: var(--el-color-primary-light-5);
+}
+
+.result-item-content {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.item-title {
+    font-size: 14px;
+    font-weight: 500;
+    color: var(--el-text-color-primary);
+}
+
+.item-path {
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+}
+
+.enter-icon {
+    opacity: 0;
+    color: var(--el-color-primary);
+    font-size: 16px;
+    transform: scaleX(-1); /* Make it look like an enter key */
+}
+
+.result-item.is-active .enter-icon {
+    opacity: 1;
+}
+
+/* Make highlight global so v-html can use it */
+:deep(.highlight) {
+    color: var(--el-color-primary);
+    font-weight: bold;
+}
+</style>
+
+<style>
+.menu-search-dialog {
+    border-radius: 12px !important;
+    overflow: hidden;
+}
+.menu-search-dialog .el-dialog__header {
+    display: none;
+}
+.menu-search-dialog .el-dialog__body {
+    padding: 0;
+}
+</style>

+ 5 - 0
src/layout/index.vue

@@ -74,6 +74,10 @@
         </template>
         <!-- 顶栏右侧按钮 -->
         <template #right>
+            <!-- 菜单搜索 -->
+            <layout-tool class="hidden-sm-and-down">
+                <header-search />
+            </layout-tool>
             <!-- 全屏切换 -->
             <layout-tool class="hidden-sm-and-down" @click="toggleFullscreen">
                 <el-icon style="transform: scale(1.18)">
@@ -223,6 +227,7 @@
     import { useMobileDevice } from '@/utils/use-mobile';
     import { usePageTab } from '@/utils/use-page-tab';
     import RouterLayout from '@/components/RouterLayout/index.vue';
+    import HeaderSearch from './components/header-search.vue';
     import HeaderUser from './components/header-user.vue';
     import HeaderNotice from './components/header-notice.vue';
     import PageFooter from './components/page-footer.vue';