feat: 重构用户中心顶部搜索交互样式
- 重做全局搜索入口的展开、关闭和定位交互 - 优化搜索面板结果展示、键盘导航和浮层结构
This commit is contained in:
@@ -1,21 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { CSSProperties } from 'vue';
|
||||||
|
|
||||||
import type { MenuRecordRaw } from '@easyflow/types';
|
import type { MenuRecordRaw } from '@easyflow/types';
|
||||||
|
|
||||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import {
|
import { Search, X } from '@easyflow/icons';
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
CornerDownLeft,
|
|
||||||
MdiKeyboardEsc,
|
|
||||||
Search,
|
|
||||||
} from '@easyflow/icons';
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
import { isWindowsOs } from '@easyflow/utils';
|
import { isWindowsOs } from '@easyflow/utils';
|
||||||
|
|
||||||
import { useEasyFlowModal } from '@easyflow-core/popup-ui';
|
import { onClickOutside, useMagicKeys, whenever } from '@vueuse/core';
|
||||||
|
|
||||||
import { useMagicKeys, whenever } from '@vueuse/core';
|
|
||||||
|
|
||||||
import SearchPanel from './search-panel.vue';
|
import SearchPanel from './search-panel.vue';
|
||||||
|
|
||||||
@@ -32,39 +26,127 @@ const props = withDefaults(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const hasResults = ref(false);
|
||||||
|
const searchRootRef = ref<HTMLElement>();
|
||||||
|
const searchPanelRef = ref<HTMLElement>();
|
||||||
const searchInputRef = ref<HTMLInputElement>();
|
const searchInputRef = ref<HTMLInputElement>();
|
||||||
|
const panelStyle = ref<CSSProperties>({});
|
||||||
|
const shouldMountPanel = ref(false);
|
||||||
|
|
||||||
const [Modal, modalApi] = useEasyFlowModal({
|
const searchShellStyle: CSSProperties = {
|
||||||
onCancel() {
|
backgroundColor: 'hsl(var(--surface-panel) / 0.18)',
|
||||||
modalApi.close();
|
backgroundImage:
|
||||||
},
|
'linear-gradient(180deg, hsl(var(--glass-border) / 0.4), hsl(var(--glass-tint) / 0.2) 18%, hsl(var(--surface-panel) / 0.12) 100%)',
|
||||||
onOpenChange(isOpen: boolean) {
|
backdropFilter: 'blur(calc(var(--glass-blur) * 1.25)) saturate(178%)',
|
||||||
if (!isOpen) {
|
border: '1px solid hsl(var(--glass-border) / 0.42)',
|
||||||
keyword.value = '';
|
boxShadow:
|
||||||
}
|
'inset 0 1px 0 hsl(var(--glass-border) / 0.44), var(--shadow-toolbar), 0 12px 24px -18px hsl(var(--nav-ambient) / 0.18)',
|
||||||
},
|
};
|
||||||
});
|
const searchPanelStyle: CSSProperties = {
|
||||||
const open = modalApi.useStore((state) => state.isOpen);
|
backgroundColor: 'hsl(var(--surface-panel) / 0.22)',
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(180deg, hsl(var(--glass-border) / 0.42), hsl(var(--glass-tint) / 0.18) 16%, hsl(var(--surface-panel) / 0.16) 100%)',
|
||||||
|
backdropFilter: 'blur(calc(var(--glass-blur) * 1.9)) saturate(190%)',
|
||||||
|
border: '1px solid hsl(var(--glass-border) / 0.44)',
|
||||||
|
boxShadow:
|
||||||
|
'inset 0 1px 0 hsl(var(--glass-border) / 0.42), var(--shadow-float), 0 16px 32px -24px hsl(var(--nav-ambient) / 0.14)',
|
||||||
|
zIndex: 'calc(var(--popup-z-index) + 10)',
|
||||||
|
};
|
||||||
|
const expandedWidth = 'clamp(10rem, 14vw, 12rem)';
|
||||||
|
|
||||||
|
function updatePanelPosition() {
|
||||||
|
const root = searchRootRef.value;
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = root.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const desiredWidth = Math.min(420, Math.max(300, rect.width + 120));
|
||||||
|
const width = Math.min(desiredWidth, viewportWidth - 16);
|
||||||
|
const right = Math.max(8, viewportWidth - rect.right);
|
||||||
|
const left = Math.max(8, viewportWidth - right - width);
|
||||||
|
|
||||||
|
panelStyle.value = {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${rect.bottom + 10}px`,
|
||||||
|
width: `${Math.min(width, viewportWidth - left - 8)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearchInput() {
|
||||||
|
nextTick(() => {
|
||||||
|
searchInputRef.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
if (isOpen.value) {
|
||||||
|
focusSearchInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
modalApi.close();
|
isOpen.value = false;
|
||||||
keyword.value = '';
|
hasResults.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
shouldMountPanel.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
updatePanelPosition();
|
||||||
|
focusSearchInput();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
keyword.value = '';
|
||||||
|
shouldMountPanel.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onClickOutside(
|
||||||
|
searchRootRef,
|
||||||
|
() => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ignore: [searchPanelRef] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewportChange = () => {
|
||||||
|
if (isOpen.value && hasResults.value) {
|
||||||
|
updatePanelPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(keyword, () => {
|
||||||
|
if (isOpen.value && hasResults.value) {
|
||||||
|
nextTick(updatePanelPosition);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleResultsChange(count: number) {
|
||||||
|
hasResults.value = count > 0;
|
||||||
|
if (isOpen.value && shouldMountPanel.value) {
|
||||||
|
nextTick(updatePanelPosition);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = useMagicKeys();
|
const keys = useMagicKeys();
|
||||||
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
|
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
|
||||||
whenever(cmd!, () => {
|
whenever(cmd!, () => {
|
||||||
if (props.enableShortcutKey) {
|
if (props.enableShortcutKey) {
|
||||||
modalApi.open();
|
openSearch();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
whenever(open, () => {
|
|
||||||
nextTick(() => {
|
|
||||||
searchInputRef.value?.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const preventDefaultBrowserSearchHotKey = (event: KeyboardEvent) => {
|
const preventDefaultBrowserSearchHotKey = (event: KeyboardEvent) => {
|
||||||
if (event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
if (event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -79,79 +161,91 @@ const toggleKeydownListener = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleOpen = () => {
|
|
||||||
open.value ? modalApi.close() : modalApi.open();
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(() => props.enableShortcutKey, toggleKeydownListener);
|
watch(() => props.enableShortcutKey, toggleKeydownListener);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
toggleKeydownListener();
|
toggleKeydownListener();
|
||||||
|
window.addEventListener('resize', handleViewportChange);
|
||||||
|
window.addEventListener('scroll', handleViewportChange, true);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
|
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
|
||||||
|
window.removeEventListener('resize', handleViewportChange);
|
||||||
|
window.removeEventListener('scroll', handleViewportChange, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div ref="searchRootRef" class="relative flex items-center justify-end">
|
||||||
<Modal
|
|
||||||
:fullscreen-button="false"
|
|
||||||
class="w-[600px]"
|
|
||||||
header-class="py-2 border-b"
|
|
||||||
>
|
|
||||||
<template #title>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Search class="text-muted-foreground mr-2 size-4" />
|
|
||||||
<input
|
|
||||||
ref="searchInputRef"
|
|
||||||
v-model="keyword"
|
|
||||||
:placeholder="$t('ui.widgets.search.searchNavigate')"
|
|
||||||
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 pl-0 text-sm font-normal outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex w-full justify-start text-xs">
|
|
||||||
<div class="mr-2 flex items-center">
|
|
||||||
<CornerDownLeft class="mr-1 size-3" />
|
|
||||||
{{ $t('ui.widgets.search.select') }}
|
|
||||||
</div>
|
|
||||||
<div class="mr-2 flex items-center">
|
|
||||||
<ArrowUp class="mr-1 size-3" />
|
|
||||||
<ArrowDown class="mr-1 size-3" />
|
|
||||||
{{ $t('ui.widgets.search.navigate') }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<MdiKeyboardEsc class="mr-1 size-3" />
|
|
||||||
{{ $t('ui.widgets.search.close') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
<div
|
<div
|
||||||
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
|
:style="[searchShellStyle, { width: isOpen ? expandedWidth : '2.25rem' }]"
|
||||||
@click="toggleOpen()"
|
class="group flex h-9 items-center overflow-hidden rounded-2xl transition-[width,background-color,box-shadow,transform,border-color] duration-200 ease-out"
|
||||||
|
:class="
|
||||||
|
isOpen
|
||||||
|
? '-translate-y-0.5 cursor-text border-[hsl(var(--glass-border))/0.5]'
|
||||||
|
: 'cursor-pointer hover:-translate-y-0.5 hover:border-[hsl(var(--glass-border))/0.52]'
|
||||||
|
"
|
||||||
|
@click="openSearch"
|
||||||
>
|
>
|
||||||
<Search
|
<button
|
||||||
class="text-muted-foreground group-hover:text-foreground size-4 group-hover:opacity-100"
|
type="button"
|
||||||
|
class="hover:text-foreground flex h-full w-9 flex-shrink-0 items-center justify-center text-[hsl(var(--nav-item-muted-foreground))] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[hsl(var(--primary))/0.2]"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:aria-label="$t('ui.widgets.search.searchNavigate')"
|
||||||
|
@click.stop="toggleOpen"
|
||||||
|
>
|
||||||
|
<Search class="size-4" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="keyword"
|
||||||
|
:placeholder="$t('ui.widgets.search.searchNavigate')"
|
||||||
|
class="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent pr-2 text-sm font-medium text-[hsl(var(--text-strong))] outline-none transition-opacity duration-150"
|
||||||
|
:class="isOpen ? 'opacity-100' : 'pointer-events-none w-0 opacity-0'"
|
||||||
|
@keydown.esc.prevent.stop="handleClose"
|
||||||
/>
|
/>
|
||||||
<span
|
<button
|
||||||
class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
|
v-if="isOpen"
|
||||||
|
type="button"
|
||||||
|
class="hover:text-foreground mr-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-xl text-[hsl(var(--text-muted))] outline-none transition-colors hover:bg-[hsl(var(--nav-item-hover))/0.92] focus-visible:ring-2 focus-visible:ring-[hsl(var(--primary))/0.2]"
|
||||||
|
:aria-label="$t('ui.widgets.search.close')"
|
||||||
|
@click.stop="handleClose"
|
||||||
>
|
>
|
||||||
{{ $t('ui.widgets.search.title') }}
|
<X class="size-4" />
|
||||||
</span>
|
</button>
|
||||||
<span
|
|
||||||
v-if="enableShortcutKey"
|
|
||||||
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
|
|
||||||
>
|
|
||||||
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
|
|
||||||
<kbd>K</kbd>
|
|
||||||
</span>
|
|
||||||
<span v-else></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-1.5 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-1 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isOpen && shouldMountPanel"
|
||||||
|
ref="searchPanelRef"
|
||||||
|
:style="[searchPanelStyle, panelStyle]"
|
||||||
|
class="duration-180 fixed overflow-hidden rounded-[28px] transition-[opacity,transform] ease-out"
|
||||||
|
:class="
|
||||||
|
hasResults
|
||||||
|
? 'pointer-events-auto opacity-100'
|
||||||
|
: 'pointer-events-none opacity-0'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="max-h-[420px] px-2 py-2">
|
||||||
|
<SearchPanel
|
||||||
|
:keyword="keyword"
|
||||||
|
:menus="menus"
|
||||||
|
@close="handleClose"
|
||||||
|
@results-change="handleResultsChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MenuRecordRaw } from '@easyflow/types';
|
import type { MenuRecordRaw } from '@easyflow/types';
|
||||||
|
|
||||||
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { SearchX, X } from '@easyflow/icons';
|
import { SearchX } from '@easyflow/icons';
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
import { mapTree, traverseTreeValues, uniqueByField } from '@easyflow/utils';
|
import { mapTree, traverseTreeValues, uniqueByField } from '@easyflow/utils';
|
||||||
|
|
||||||
import { EasyFlowIcon, EasyFlowScrollbar } from '@easyflow-core/shadcn-ui';
|
import { EasyFlowIcon, EasyFlowScrollbar } from '@easyflow-core/shadcn-ui';
|
||||||
import { isHttpUrl } from '@easyflow-core/shared/utils';
|
import { isHttpUrl } from '@easyflow-core/shared/utils';
|
||||||
|
|
||||||
import { onKeyStroke, useLocalStorage, useThrottleFn } from '@vueuse/core';
|
import { onKeyStroke, useThrottleFn } from '@vueuse/core';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SearchPanel',
|
name: 'SearchPanel',
|
||||||
@@ -24,58 +24,39 @@ const props = withDefaults(
|
|||||||
menus: () => [],
|
menus: () => [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: []; resultsChange: [count: number] }>();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchHistory = useLocalStorage<MenuRecordRaw[]>(
|
|
||||||
`__search-history-${location.hostname}__`,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const activeIndex = ref(-1);
|
const activeIndex = ref(-1);
|
||||||
const searchItems = shallowRef<MenuRecordRaw[]>([]);
|
const searchItems = shallowRef<MenuRecordRaw[]>([]);
|
||||||
const searchResults = ref<MenuRecordRaw[]>([]);
|
const searchResults = ref<MenuRecordRaw[]>([]);
|
||||||
|
const displayResults = computed(() =>
|
||||||
|
uniqueByField(searchResults.value, 'path'),
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearch = useThrottleFn(search, 200);
|
const handleSearch = useThrottleFn(search, 200);
|
||||||
|
|
||||||
// 搜索函数,用于根据搜索关键词查找匹配的菜单项
|
|
||||||
function search(searchKey: string) {
|
function search(searchKey: string) {
|
||||||
// 去除搜索关键词的前后空格
|
const normalizedKey = searchKey.trim();
|
||||||
searchKey = searchKey.trim();
|
if (!normalizedKey) {
|
||||||
|
|
||||||
// 如果搜索关键词为空,清空搜索结果并返回
|
|
||||||
if (!searchKey) {
|
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
|
activeIndex.value = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用搜索关键词创建正则表达式
|
const reg = createSearchReg(normalizedKey);
|
||||||
const reg = createSearchReg(searchKey);
|
|
||||||
|
|
||||||
// 初始化结果数组
|
|
||||||
const results: MenuRecordRaw[] = [];
|
const results: MenuRecordRaw[] = [];
|
||||||
|
|
||||||
// 遍历搜索项
|
|
||||||
traverseTreeValues(searchItems.value, (item) => {
|
traverseTreeValues(searchItems.value, (item) => {
|
||||||
// 如果菜单项的名称匹配正则表达式,将其添加到结果数组中
|
|
||||||
if (reg.test(item.name?.toLowerCase())) {
|
if (reg.test(item.name?.toLowerCase())) {
|
||||||
results.push(item);
|
results.push(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新搜索结果
|
searchResults.value = uniqueByField(results, 'path');
|
||||||
searchResults.value = results;
|
activeIndex.value = searchResults.value.length > 0 ? 0 : -1;
|
||||||
|
|
||||||
// 如果有搜索结果,设置索引为 0
|
|
||||||
if (results.length > 0) {
|
|
||||||
activeIndex.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 赋值索引为 0
|
|
||||||
activeIndex.value = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the keyboard up and down keys move to an invisible place
|
|
||||||
// the scroll bar needs to scroll automatically
|
|
||||||
function scrollIntoView() {
|
function scrollIntoView() {
|
||||||
const element = document.querySelector(
|
const element = document.querySelector(
|
||||||
`[data-search-item="${activeIndex.value}"]`,
|
`[data-search-item="${activeIndex.value}"]`,
|
||||||
@@ -86,76 +67,87 @@ function scrollIntoView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// enter keyboard event
|
|
||||||
async function handleEnter() {
|
async function handleEnter() {
|
||||||
if (searchResults.value.length === 0) {
|
if (displayResults.value.length === 0 || activeIndex.value < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = searchResults.value;
|
await handleSelect(activeIndex.value);
|
||||||
const index = activeIndex.value;
|
}
|
||||||
if (result.length === 0 || index < 0) {
|
|
||||||
|
async function handleSelect(index: number) {
|
||||||
|
const to = displayResults.value[index];
|
||||||
|
if (!to) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const to = result[index];
|
emit('close');
|
||||||
if (to) {
|
await nextTick();
|
||||||
searchHistory.value = uniqueByField([...searchHistory.value, to], 'path');
|
if (isHttpUrl(to.path)) {
|
||||||
handleClose();
|
window.open(to.path, '_blank');
|
||||||
await nextTick();
|
} else {
|
||||||
if (isHttpUrl(to.path)) {
|
router.push({ path: to.path, replace: true });
|
||||||
window.open(to.path, '_blank');
|
|
||||||
} else {
|
|
||||||
router.push({ path: to.path, replace: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow key up
|
|
||||||
function handleUp() {
|
function handleUp() {
|
||||||
if (searchResults.value.length === 0) {
|
if (displayResults.value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeIndex.value--;
|
activeIndex.value--;
|
||||||
if (activeIndex.value < 0) {
|
if (activeIndex.value < 0) {
|
||||||
activeIndex.value = searchResults.value.length - 1;
|
activeIndex.value = displayResults.value.length - 1;
|
||||||
}
|
}
|
||||||
scrollIntoView();
|
scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow key down
|
|
||||||
function handleDown() {
|
function handleDown() {
|
||||||
if (searchResults.value.length === 0) {
|
if (displayResults.value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeIndex.value++;
|
activeIndex.value++;
|
||||||
if (activeIndex.value > searchResults.value.length - 1) {
|
if (activeIndex.value > displayResults.value.length - 1) {
|
||||||
activeIndex.value = 0;
|
activeIndex.value = 0;
|
||||||
}
|
}
|
||||||
scrollIntoView();
|
scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
// close search modal
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
searchResults.value = [];
|
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate when the mouse moves to a certain line
|
function handleMouseenter(index: number) {
|
||||||
function handleMouseenter(e: MouseEvent) {
|
activeIndex.value = index;
|
||||||
const index = (e.target as HTMLElement)?.dataset.index;
|
|
||||||
activeIndex.value = Number(index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(index: number) {
|
function getHighlightedChunks(text: string, keyword: string) {
|
||||||
if (props.keyword) {
|
const sourceChars = [...text];
|
||||||
searchResults.value.splice(index, 1);
|
const lowerChars = [...text.toLowerCase()];
|
||||||
} else {
|
const queryChars = [...keyword.trim().toLowerCase()];
|
||||||
searchHistory.value.splice(index, 1);
|
|
||||||
|
if (queryChars.length === 0) {
|
||||||
|
return [{ text, matched: false }];
|
||||||
}
|
}
|
||||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
|
||||||
scrollIntoView();
|
const chunks: Array<{ matched: boolean; text: string }> = [];
|
||||||
|
let queryIndex = 0;
|
||||||
|
|
||||||
|
sourceChars.forEach((char, index) => {
|
||||||
|
const matched =
|
||||||
|
queryIndex < queryChars.length &&
|
||||||
|
lowerChars[index] === queryChars[queryIndex];
|
||||||
|
if (matched) {
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
const previousChunk = chunks[chunks.length - 1];
|
||||||
|
if (previousChunk && previousChunk.matched === matched) {
|
||||||
|
previousChunk.text += char;
|
||||||
|
} else {
|
||||||
|
chunks.push({ matched, text: char });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储所有需要转义的特殊字符
|
|
||||||
const code = new Set([
|
const code = new Set([
|
||||||
'$',
|
'$',
|
||||||
'(',
|
'(',
|
||||||
@@ -173,20 +165,12 @@ const code = new Set([
|
|||||||
'}',
|
'}',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 转换函数,用于转义特殊字符
|
|
||||||
function transform(c: string) {
|
function transform(c: string) {
|
||||||
// 如果字符在特殊字符列表中,返回转义后的字符
|
|
||||||
// 如果不在,返回字符本身
|
|
||||||
return code.has(c) ? `\\${c}` : c;
|
return code.has(c) ? `\\${c}` : c;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建搜索正则表达式
|
|
||||||
function createSearchReg(key: string) {
|
function createSearchReg(key: string) {
|
||||||
// 将输入的字符串拆分为单个字符
|
|
||||||
// 对每个字符进行转义
|
|
||||||
// 然后用'.*'连接所有字符,创建正则表达式
|
|
||||||
const keys = [...key].map((item) => transform(item)).join('.*');
|
const keys = [...key].map((item) => transform(item)).join('.*');
|
||||||
// 返回创建的正则表达式
|
|
||||||
return new RegExp(`.*${keys}.*`);
|
return new RegExp(`.*${keys}.*`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +180,20 @@ watch(
|
|||||||
if (val) {
|
if (val) {
|
||||||
handleSearch(val);
|
handleSearch(val);
|
||||||
} else {
|
} else {
|
||||||
searchResults.value = [...searchHistory.value];
|
searchResults.value = [];
|
||||||
|
activeIndex.value = -1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => displayResults.value.length,
|
||||||
|
(count) => {
|
||||||
|
emit('resultsChange', count);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searchItems.value = mapTree(props.menus, (item) => {
|
searchItems.value = mapTree(props.menus, (item) => {
|
||||||
return {
|
return {
|
||||||
@@ -208,81 +201,67 @@ onMounted(() => {
|
|||||||
name: $t(item?.name),
|
name: $t(item?.name),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (searchHistory.value.length > 0) {
|
|
||||||
searchResults.value = searchHistory.value;
|
|
||||||
}
|
|
||||||
// enter search
|
|
||||||
onKeyStroke('Enter', handleEnter);
|
onKeyStroke('Enter', handleEnter);
|
||||||
// Monitor keyboard arrow keys
|
|
||||||
onKeyStroke('ArrowUp', handleUp);
|
onKeyStroke('ArrowUp', handleUp);
|
||||||
onKeyStroke('ArrowDown', handleDown);
|
onKeyStroke('ArrowDown', handleDown);
|
||||||
// esc close
|
|
||||||
onKeyStroke('Escape', handleClose);
|
onKeyStroke('Escape', handleClose);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<EasyFlowScrollbar>
|
<EasyFlowScrollbar>
|
||||||
<div class="!flex h-full justify-center px-2 sm:max-h-[450px]">
|
<div class="px-2 py-1 sm:max-h-[420px]">
|
||||||
<!-- 无搜索结果 -->
|
<ul v-if="displayResults.length > 0" class="space-y-2">
|
||||||
<div
|
|
||||||
v-if="keyword && searchResults.length === 0"
|
|
||||||
class="text-muted-foreground text-center"
|
|
||||||
>
|
|
||||||
<SearchX class="mx-auto mt-4 size-12" />
|
|
||||||
<p class="mb-10 mt-6 text-xs">
|
|
||||||
{{ $t('ui.widgets.search.noResults') }}
|
|
||||||
<span class="text-foreground text-sm font-medium">
|
|
||||||
"{{ keyword }}"
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- 历史搜索记录 & 没有搜索结果 -->
|
|
||||||
<div
|
|
||||||
v-if="!keyword && searchResults.length === 0"
|
|
||||||
class="text-muted-foreground text-center"
|
|
||||||
>
|
|
||||||
<p class="my-10 text-xs">
|
|
||||||
{{ $t('ui.widgets.search.noRecent') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-show="searchResults.length > 0" class="w-full">
|
|
||||||
<li
|
<li
|
||||||
v-if="searchHistory.length > 0 && !keyword"
|
v-for="(item, index) in displayResults"
|
||||||
class="text-muted-foreground mb-2 text-xs"
|
|
||||||
>
|
|
||||||
{{ $t('ui.widgets.search.recent') }}
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
v-for="(item, index) in uniqueByField(searchResults, 'path')"
|
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:class="
|
:class="
|
||||||
activeIndex === index
|
activeIndex === index
|
||||||
? 'active bg-primary text-primary-foreground'
|
? 'bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.22,hsl(var(--glass-tint))/0.1_18%,hsl(var(--surface-panel))/0.12)] shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.34,0_18px_30px_-24px_hsl(var(--primary)/0.12)] ring-1 ring-[hsl(var(--glass-border))/0.26]'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
:data-index="index"
|
:data-index="index"
|
||||||
:data-search-item="index"
|
:data-search-item="index"
|
||||||
class="bg-accent flex-center group mb-3 w-full cursor-pointer rounded-lg px-4 py-4"
|
class="group flex w-full cursor-pointer items-center gap-3 rounded-2xl bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.14,hsl(var(--glass-tint))/0.08_18%,hsl(var(--surface-panel))/0.08)] px-4 py-3.5 backdrop-blur-2xl transition-[transform,background-color,box-shadow] hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.18,hsl(var(--glass-tint))/0.12_18%,hsl(var(--surface-panel))/0.1)] hover:shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.28,0_16px_28px_-24px_hsl(var(--primary)/0.1)]"
|
||||||
@click="handleEnter"
|
@click="handleSelect(index)"
|
||||||
@mouseenter="handleMouseenter"
|
@mouseenter="handleMouseenter(index)"
|
||||||
>
|
>
|
||||||
<EasyFlowIcon
|
|
||||||
:icon="item.icon"
|
|
||||||
class="mr-2 size-5 flex-shrink-0"
|
|
||||||
fallback
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span class="flex-1">{{ item.name }}</span>
|
|
||||||
<div
|
<div
|
||||||
class="flex-center dark:hover:bg-accent hover:text-primary-foreground rounded-full p-1 hover:scale-110"
|
class="flex size-10 flex-shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,hsl(var(--glass-border))/0.24,hsl(var(--nav-item-active))/0.09_22%,hsl(var(--glass-tint))/0.14)] text-[hsl(var(--nav-item-active-foreground))] shadow-[inset_0_1px_0_hsl(var(--glass-border))/0.36]"
|
||||||
@click.stop="removeItem(index)"
|
|
||||||
>
|
>
|
||||||
<X class="size-4" />
|
<EasyFlowIcon :icon="item.icon" class="size-5" fallback />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
class="block truncate text-sm font-medium leading-6 text-[hsl(var(--text-strong))]"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(chunk, chunkIndex) in getHighlightedChunks(
|
||||||
|
item.name || '',
|
||||||
|
keyword,
|
||||||
|
)"
|
||||||
|
:key="`${item.path}-${chunkIndex}`"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
chunk.matched
|
||||||
|
? activeIndex === index
|
||||||
|
? 'rounded-md bg-[hsl(var(--primary))/0.1] px-0.5 font-semibold text-current'
|
||||||
|
: 'rounded-md bg-[hsl(var(--primary))/0.08] px-0.5 font-semibold text-[hsl(var(--primary))]'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ chunk.text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-else class="hidden">
|
||||||
|
<SearchX class="size-0" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</EasyFlowScrollbar>
|
</EasyFlowScrollbar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user