feat(管理端): 菜单搜索重构为命令面板式交互,支持键盘导航与防抖检索

This commit is contained in:
From-wh 2026-10-13 16:24:03 +08:00 committed by 吴昊天
parent 0283096af2
commit 46ab4571e5

View File

@ -1,101 +1,157 @@
<template>
<div class="layout-search-dialog">
<el-dialog
v-model="isShowSearch"
fullscreen
:modal="false"
destroy-on-close
:show-close="true"
@opened="onDialogOpened"
>
<el-autocomplete
v-model="menuQuery"
:fetch-suggestions="menuSearch"
:placeholder="$t('message.user.searchPlaceholder')"
prefix-icon="Search"
ref="layoutMenuAutocompleteRef"
@select="onHandleSelect"
@blur="onSearchBlur"
>
<template #default="{ item }">
<div><i :class="item.icon" class="mr10"></i>{{ $t(item.title) }}</div>
</template>
</el-autocomplete>
</el-dialog>
</div>
<teleport to="body">
<transition name="menu-search">
<div v-if="isShowSearch" class="menu-search-overlay" @click.self="closeSearch">
<div class="menu-search-panel" role="dialog" aria-modal="true" aria-label="菜单搜索">
<div class="menu-search-header" @click="focusInput">
<el-icon class="menu-search-logo"><Search /></el-icon>
<input
ref="inputRef"
v-model="menuQuery"
class="menu-search-input"
type="text"
:placeholder="$t('message.user.searchPlaceholder')"
@keydown.down.prevent="moveActive(1)"
@keydown.up.prevent="moveActive(-1)"
@keydown.enter.prevent="onHandleSelect(resultList[activeIndex])"
@keydown.esc.prevent="closeSearch"
/>
<transition name="menu-search-fade">
<span v-if="menuQuery" class="menu-search-clear" title="清空" @click.stop="clearQuery">
<el-icon><Close /></el-icon>
</span>
</transition>
<span class="menu-search-esc">Esc</span>
</div>
<div v-loading="loading" element-loading-background="transparent" class="menu-search-results">
<div v-if="resultList.length" ref="listRef" class="menu-search-list">
<div
v-for="(item, index) in resultList"
:key="item.path + index"
class="menu-search-item"
:data-active="index === activeIndex"
@mousedown.prevent
@mouseenter="activeIndex = index"
@click="onHandleSelect(item)"
>
<span class="menu-search-item-icon"><menu-icon :icon="item.icon" /></span>
<span class="menu-search-item-title">{{ $t(item.title) }}</span>
<span class="menu-search-item-path line1">{{ item.path }}</span>
</div>
</div>
<div v-else class="menu-search-empty">
<el-icon><Search /></el-icon>
<p>未找到相关菜单,换个关键词试试</p>
</div>
</div>
<div class="menu-search-footer">
<span><i>↑</i><i>↓</i> 切换</span>
<span><i>Enter</i> 打开</span>
<span><i>Esc</i> 关闭</span>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup>
import { ref } from 'vue';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { menusSearch } from '@/api/setting';
import { getAllSiderMenu } from '@/libs/system';
import { i18n } from '@/i18n/index.js';
import { useRoutesListStore } from '@/store/module/routesList';
import MenuIcon from '@/components/menuIcon/index.vue';
defineOptions({ name: 'layoutBreadcrumbSearch' });
const router = useRouter();
const routesListStore = useRoutesListStore();
const layoutMenuAutocompleteRef = ref(null);
const inputRef = ref(null);
const listRef = ref(null);
const isShowSearch = ref(false);
const menuQuery = ref('');
const activeIndex = ref(0);
const loading = ref(false);
const resultList = ref([]);
const tagsViewList = ref([]);
// 搜索弹窗打开
let searchTimer = null;
// 递增序号丢弃过期响应,避免快速输入时旧结果覆盖新结果
let requestSeq = 0;
// 搜索弹窗打开:重置状态并聚焦输入框
function openSearch() {
menuQuery.value = '';
isShowSearch.value = true;
activeIndex.value = 0;
initTageView();
}
// 弹窗打开动画结束后聚焦输入框(EP 的 focus-trap 会覆盖更早的 focus 调用)
function onDialogOpened() {
layoutMenuAutocompleteRef.value?.focus?.();
resultList.value = tagsViewList.value;
isShowSearch.value = true;
nextTick(() => inputRef.value?.focus());
}
// 搜索弹窗关闭
function closeSearch() {
setTimeout(() => {
isShowSearch.value = false;
}, 150);
isShowSearch.value = false;
}
// 菜单搜索数据过滤
function menuSearch(queryString, cb) {
if (!queryString) {
let results = queryString ? tagsViewList.value.filter(createFilter(queryString)) : tagsViewList.value;
cb(results);
} else {
let queryData = {
keyword: queryString,
};
menusSearch(queryData).then((res) => {
cb(res.data);
});
}
function clearQuery() {
menuQuery.value = '';
focusInput();
}
// 菜单搜索过滤
function createFilter(queryString) {
return (restaurant) => {
return (
restaurant.path.toLowerCase().indexOf(queryString.toLowerCase()) > -1 ||
restaurant.title.toLowerCase().indexOf(queryString.toLowerCase()) > -1 ||
i18n.global.t(restaurant.title).toLowerCase().indexOf(queryString.toLowerCase()) > -1
);
};
function focusInput() {
inputRef.value?.focus();
}
// 初始化菜单数据
// 初始化本地菜单数据(空关键词时全量展示)
function initTageView() {
if (tagsViewList.value.length > 0) return false;
tagsViewList.value = getAllSiderMenu(routesListStore.routesList);
}
// 关键词搜索:空关键词回落本地全量菜单,有关键词走后端检索(200ms 防抖)
watch(menuQuery, (val) => {
clearTimeout(searchTimer);
if (!val) {
requestSeq++;
loading.value = false;
resultList.value = tagsViewList.value;
activeIndex.value = 0;
return;
}
loading.value = true;
searchTimer = setTimeout(() => {
const seq = ++requestSeq;
menusSearch({ keyword: val })
.then((res) => {
if (seq !== requestSeq) return;
resultList.value = res.data || [];
activeIndex.value = 0;
})
.finally(() => {
if (seq === requestSeq) loading.value = false;
});
}, 200);
});
// 键盘高亮项变化时保持可见
watch(activeIndex, () => {
nextTick(() => {
listRef.value?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
});
});
function moveActive(step) {
const len = resultList.value.length;
if (!len) return;
activeIndex.value = (activeIndex.value + step + len) % len;
}
// 当前菜单选中时
function onHandleSelect(item) {
if (!item) return;
let { path, redirect } = item;
if (item.isLink && !item.isIframe) window.open(item.isLink);
else if (redirect) router.push(redirect);
@ -103,34 +159,240 @@ function onHandleSelect(item) {
closeSearch();
}
// input 失去焦点时
function onSearchBlur() {
closeSearch();
}
onBeforeUnmount(() => {
clearTimeout(searchTimer);
});
defineExpose({ openSearch, closeSearch });
</script>
<style scoped lang="scss">
.layout-search-dialog {
:deep(.el-dialog) {
box-shadow: unset !important;
border-radius: 0 !important;
background: rgba(0, 0, 0, 0.5);
}
:deep(.el-autocomplete) {
width: 560px;
position: absolute;
top: 100px;
left: 50%;
transform: translateX(-50%);
// 全屏遮罩:暗色压暗 + 背景虚化
.menu-search-overlay {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
justify-content: center;
align-items: flex-start;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px) saturate(1.4);
-webkit-backdrop-filter: blur(10px) saturate(1.4);
}
// 毛玻璃面板:半透明白(暗色主题自动跟随 --prev-bg-white)+ 高斯模糊
.menu-search-panel {
display: flex;
flex-direction: column;
width: 640px;
max-width: calc(100vw - 48px);
max-height: min(68vh, 640px);
margin-top: 13vh;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: color-mix(in srgb, var(--prev-bg-white) 86%, transparent);
backdrop-filter: blur(28px) saturate(1.8);
-webkit-backdrop-filter: blur(28px) saturate(1.8);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.2), 0 2px 10px rgba(0, 0, 0, 0.06);
}
// 输入区
.menu-search-header {
display: flex;
align-items: center;
gap: 12px;
height: 60px;
padding: 20px 20px;
border-bottom: 1px solid color-mix(in srgb, var(--prev-border-color-lighter) 72%, transparent);
cursor: text;
}
.menu-search-logo {
font-size: 20px;
color: var(--prev-color-text-secondary);
}
.menu-search-input {
flex: 1;
height: 100%;
border: none;
outline: none;
background: transparent;
font-size: 16px;
color: var(--prev-color-text-regular);
caret-color: var(--prev-color-primary);
&::placeholder {
color: var(--prev-color-text-placeholder);
}
}
:deep(.el-dialog__header) {
border: none !important;
.menu-search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 12px;
color: var(--prev-color-text-secondary);
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background: var(--prev-color-hover);
color: var(--prev-color-text-regular);
}
}
:deep(.el-input--small .el-input__inner) {
height: 36px;
line-height: 36px;
.menu-search-esc {
flex-shrink: 0;
padding: 1px 7px;
font-size: 11px;
line-height: 16px;
color: var(--prev-color-text-secondary);
border: 1px solid var(--prev-border-color-base);
border-bottom-width: 2px;
border-radius: 4px;
background: color-mix(in srgb, var(--prev-bg-white) 60%, transparent);
}
// 结果区
.menu-search-results {
flex: 1;
min-height: 140px;
padding: 8px;
overflow: hidden auto;
}
.menu-search-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.menu-search-item {
display: flex;
align-items: center;
gap: 12px;
height: 46px;
padding: 0 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.12s ease;
&[data-active='true'] {
background: var(--prev-color-primary-light-9);
.menu-search-item-title {
color: var(--prev-color-primary);
font-weight: 500;
}
.menu-search-item-icon {
color: var(--prev-color-primary);
background: var(--prev-bg-white);
}
.menu-search-item-path {
color: var(--prev-color-primary);
opacity: 0.65;
}
}
}
.menu-search-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 6px;
font-size: 16px;
color: var(--prev-color-text-regular);
background: var(--prev-bg-color);
transition: color 0.12s ease, background-color 0.12s ease;
:deep(.menu-icon),
:deep(.el-icon) {
margin-right: 0;
font-size: 16px;
}
}
.menu-search-item-title {
flex-shrink: 0;
font-size: 14px;
color: var(--prev-color-text-regular);
transition: color 0.12s ease;
}
.menu-search-item-path {
max-width: 45%;
margin-left: auto;
font-size: 12px;
color: var(--prev-color-text-placeholder);
transition: color 0.12s ease;
}
// 空状态
.menu-search-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 32px 0 40px;
color: var(--prev-color-text-placeholder);
.el-icon {
font-size: 34px;
}
p {
font-size: 13px;
}
}
// 底部快捷键提示
.menu-search-footer {
display: flex;
justify-content: flex-end;
gap: 16px;
padding: 10px 20px;
font-size: 12px;
color: var(--prev-color-text-secondary);
border-top: 1px solid color-mix(in srgb, var(--prev-border-color-lighter) 72%, transparent);
i {
display: inline-block;
min-width: 18px;
margin-right: 3px;
padding: 0 4px;
font-style: normal;
font-size: 11px;
line-height: 18px;
text-align: center;
border: 1px solid var(--prev-border-color-base);
border-bottom-width: 2px;
border-radius: 4px;
background: color-mix(in srgb, var(--prev-bg-white) 60%, transparent);
}
}
// 进出场动画:遮罩淡入淡出 + 面板轻微上浮缩放
.menu-search-enter-active {
transition: opacity 0.2s ease;
.menu-search-panel {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
}
.menu-search-leave-active {
transition: opacity 0.15s ease;
.menu-search-panel {
transition: transform 0.15s ease, opacity 0.15s ease;
}
}
.menu-search-enter-from,
.menu-search-leave-to {
opacity: 0;
}
.menu-search-enter-from .menu-search-panel {
transform: translateY(-16px) scale(0.97);
}
.menu-search-leave-to .menu-search-panel {
transform: translateY(-8px) scale(0.98);
opacity: 0;
}
.menu-search-fade-enter-active,
.menu-search-fade-leave-active {
transition: opacity 0.12s ease;
}
.menu-search-fade-enter-from,
.menu-search-fade-leave-to {
opacity: 0;
}
</style>