123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- <script setup lang="ts">
- import type { MenuRecordRaw } from '@vben/types';
- import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
- import { useRouter } from 'vue-router';
- import { SearchX, X } from '@vben/icons';
- import { $t } from '@vben/locales';
- import { mapTree, traverseTreeValues, uniqueByField } from '@vben/utils';
- import { VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
- import { isHttpUrl } from '@vben-core/shared/utils';
- import { onKeyStroke, useLocalStorage, useThrottleFn } from '@vueuse/core';
- defineOptions({
- name: 'SearchPanel',
- });
- const props = withDefaults(
- defineProps<{ keyword: string; menus: MenuRecordRaw[] }>(),
- {
- keyword: '',
- menus: () => [],
- },
- );
- const emit = defineEmits<{ close: [] }>();
- const router = useRouter();
- const searchHistory = useLocalStorage<MenuRecordRaw[]>(
- `__search-history-${location.hostname}__`,
- [],
- );
- const activeIndex = ref(-1);
- const searchItems = shallowRef<MenuRecordRaw[]>([]);
- const searchResults = ref<MenuRecordRaw[]>([]);
- const handleSearch = useThrottleFn(search, 200);
- // 搜索函数,用于根据搜索关键词查找匹配的菜单项
- function search(searchKey: string) {
- // 去除搜索关键词的前后空格
- searchKey = searchKey.trim();
- // 如果搜索关键词为空,清空搜索结果并返回
- if (!searchKey) {
- searchResults.value = [];
- return;
- }
- // 使用搜索关键词创建正则表达式
- const reg = createSearchReg(searchKey);
- // 初始化结果数组
- const results: MenuRecordRaw[] = [];
- // 遍历搜索项
- traverseTreeValues(searchItems.value, (item) => {
- // 如果菜单项的名称匹配正则表达式,将其添加到结果数组中
- if (reg.test(item.name?.toLowerCase())) {
- results.push(item);
- }
- });
- // 更新搜索结果
- searchResults.value = results;
- // 如果有搜索结果,设置索引为 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() {
- const element = document.querySelector(
- `[data-search-item="${activeIndex.value}"]`,
- );
- if (element) {
- element.scrollIntoView({ block: 'nearest' });
- }
- }
- // enter keyboard event
- async function handleEnter() {
- if (searchResults.value.length === 0) {
- return;
- }
- const result = searchResults.value;
- const index = activeIndex.value;
- if (result.length === 0 || index < 0) {
- return;
- }
- const to = result[index];
- if (to) {
- searchHistory.value.push(to);
- handleClose();
- await nextTick();
- if (isHttpUrl(to.path)) {
- window.open(to.path, '_blank');
- } else {
- router.push({ path: to.path, replace: true });
- }
- }
- }
- // Arrow key up
- function handleUp() {
- if (searchResults.value.length === 0) {
- return;
- }
- activeIndex.value--;
- if (activeIndex.value < 0) {
- activeIndex.value = searchResults.value.length - 1;
- }
- scrollIntoView();
- }
- // Arrow key down
- function handleDown() {
- if (searchResults.value.length === 0) {
- return;
- }
- activeIndex.value++;
- if (activeIndex.value > searchResults.value.length - 1) {
- activeIndex.value = 0;
- }
- scrollIntoView();
- }
- // close search modal
- function handleClose() {
- searchResults.value = [];
- emit('close');
- }
- // Activate when the mouse moves to a certain line
- function handleMouseenter(e: MouseEvent) {
- const index = (e.target as HTMLElement)?.dataset.index;
- activeIndex.value = Number(index);
- }
- function removeItem(index: number) {
- if (props.keyword) {
- searchResults.value.splice(index, 1);
- } else {
- searchHistory.value.splice(index, 1);
- }
- activeIndex.value = Math.max(activeIndex.value - 1, 0);
- scrollIntoView();
- }
- // 存储所有需要转义的特殊字符
- const code = new Set([
- '$',
- '(',
- ')',
- '*',
- '+',
- '.',
- '?',
- '[',
- '\\',
- ']',
- '^',
- '{',
- '|',
- '}',
- ]);
- // 转换函数,用于转义特殊字符
- function transform(c: string) {
- // 如果字符在特殊字符列表中,返回转义后的字符
- // 如果不在,返回字符本身
- return code.has(c) ? `\\${c}` : c;
- }
- // 创建搜索正则表达式
- function createSearchReg(key: string) {
- // 将输入的字符串拆分为单个字符
- // 对每个字符进行转义
- // 然后用'.*'连接所有字符,创建正则表达式
- const keys = [...key].map((item) => transform(item)).join('.*');
- // 返回创建的正则表达式
- return new RegExp(`.*${keys}.*`);
- }
- watch(
- () => props.keyword,
- (val) => {
- if (val) {
- handleSearch(val);
- } else {
- searchResults.value = [...searchHistory.value];
- }
- },
- );
- onMounted(() => {
- searchItems.value = mapTree(props.menus, (item) => {
- return {
- ...item,
- name: $t(item?.name),
- };
- });
- if (searchHistory.value.length > 0) {
- searchResults.value = searchHistory.value;
- }
- // enter search
- onKeyStroke('Enter', handleEnter);
- // Monitor keyboard arrow keys
- onKeyStroke('ArrowUp', handleUp);
- onKeyStroke('ArrowDown', handleDown);
- // esc close
- onKeyStroke('Escape', handleClose);
- });
- </script>
- <template>
- <VbenScrollbar>
- <div class="!flex h-full justify-center px-2 sm:max-h-[450px]">
- <!-- 无搜索结果 -->
- <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
- v-if="searchHistory.length > 0 && !keyword"
- 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"
- :class="
- activeIndex === index
- ? 'active bg-primary text-primary-foreground'
- : ''
- "
- :data-index="index"
- :data-search-item="index"
- class="bg-accent flex-center group mb-3 w-full cursor-pointer rounded-lg px-4 py-4"
- @click="handleEnter"
- @mouseenter="handleMouseenter"
- >
- <VbenIcon
- :icon="item.icon"
- class="mr-2 size-5 flex-shrink-0"
- fallback
- />
- <span class="flex-1">{{ item.name }}</span>
- <div
- class="flex-center dark:hover:bg-accent hover:text-primary-foreground rounded-full p-1 hover:scale-110"
- @click.stop="removeItem(index)"
- >
- <X class="size-4" />
- </div>
- </li>
- </ul>
- </div>
- </VbenScrollbar>
- </template>
|