|
|
@@ -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>
|