search-panel.vue 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. <script setup lang="ts">
  2. import type { MenuRecordRaw } from '@vben/types';
  3. import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
  4. import { useRouter } from 'vue-router';
  5. import { SearchX, X } from '@vben/icons';
  6. import { $t } from '@vben/locales';
  7. import { mapTree, traverseTreeValues, uniqueByField } from '@vben/utils';
  8. import { VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
  9. import { isHttpUrl } from '@vben-core/shared/utils';
  10. import {
  11. onKeyStroke,
  12. useEventListener,
  13. useLocalStorage,
  14. useThrottleFn,
  15. } from '@vueuse/core';
  16. defineOptions({
  17. name: 'SearchPanel',
  18. });
  19. const props = withDefaults(
  20. defineProps<{ keyword?: string; menus?: MenuRecordRaw[] }>(),
  21. {
  22. keyword: '',
  23. menus: () => [],
  24. },
  25. );
  26. const emit = defineEmits<{ close: [] }>();
  27. const router = useRouter();
  28. const searchHistory = useLocalStorage<MenuRecordRaw[]>(
  29. `__search-history-${location.hostname}__`,
  30. [],
  31. );
  32. const activeIndex = ref(-1);
  33. const searchItems = shallowRef<MenuRecordRaw[]>([]);
  34. const searchResults = ref<MenuRecordRaw[]>([]);
  35. const isNavigating = ref(false);
  36. const handleSearch = useThrottleFn(search, 200);
  37. // 搜索函数,用于根据搜索关键词查找匹配的菜单项
  38. function search(searchKey: string) {
  39. // 去除搜索关键词的前后空格
  40. searchKey = searchKey.trim();
  41. // 如果搜索关键词为空,清空搜索结果并返回
  42. if (!searchKey) {
  43. searchResults.value = [];
  44. return;
  45. }
  46. // 将搜索关键词转换为小写,确保大小写不敏感的搜索
  47. searchKey = searchKey.toLowerCase();
  48. // 使用搜索关键词创建正则表达式
  49. const reg = createSearchReg(searchKey);
  50. // 初始化结果数组
  51. const results: MenuRecordRaw[] = [];
  52. // 遍历搜索项
  53. traverseTreeValues(searchItems.value, (item) => {
  54. // 如果菜单项的名称匹配正则表达式,将其添加到结果数组中
  55. if (reg.test(item.name?.toLowerCase())) {
  56. results.push(item);
  57. }
  58. });
  59. // 更新搜索结果
  60. searchResults.value = results;
  61. // 如果有搜索结果,设置索引为 0
  62. if (results.length > 0) {
  63. activeIndex.value = 0;
  64. }
  65. // 赋值索引为 0
  66. activeIndex.value = 0;
  67. }
  68. // When the keyboard up and down keys move to an invisible place
  69. // the scroll bar needs to scroll automatically
  70. function scrollIntoView() {
  71. const element = document.querySelector(
  72. `[data-search-item="${activeIndex.value}"]`,
  73. );
  74. if (element) {
  75. element.scrollIntoView({ block: 'nearest' });
  76. }
  77. }
  78. // enter keyboard event
  79. async function handleEnter() {
  80. if (searchResults.value.length === 0) {
  81. return;
  82. }
  83. const result = searchResults.value;
  84. const index = activeIndex.value;
  85. if (result.length === 0 || index < 0) {
  86. return;
  87. }
  88. const to = result[index];
  89. if (to) {
  90. searchHistory.value = uniqueByField([...searchHistory.value, to], 'path');
  91. handleClose();
  92. await nextTick();
  93. if (isHttpUrl(to.path)) {
  94. window.open(to.path, '_blank');
  95. } else {
  96. router.push({ path: to.path, replace: true });
  97. }
  98. }
  99. }
  100. // Arrow key up
  101. function handleUp() {
  102. if (searchResults.value.length === 0) {
  103. return;
  104. }
  105. isNavigating.value = true;
  106. activeIndex.value--;
  107. if (activeIndex.value < 0) {
  108. activeIndex.value = searchResults.value.length - 1;
  109. }
  110. scrollIntoView();
  111. }
  112. // Arrow key down
  113. function handleDown() {
  114. if (searchResults.value.length === 0) {
  115. return;
  116. }
  117. isNavigating.value = true;
  118. activeIndex.value++;
  119. if (activeIndex.value > searchResults.value.length - 1) {
  120. activeIndex.value = 0;
  121. }
  122. scrollIntoView();
  123. }
  124. // close search modal
  125. function handleClose() {
  126. searchResults.value = [];
  127. emit('close');
  128. }
  129. // Activate when the mouse moves to a certain line
  130. function handleMouseenter(e: MouseEvent) {
  131. if (isNavigating.value) return;
  132. const index = (e.target as HTMLElement)?.dataset.index;
  133. activeIndex.value = Number(index);
  134. }
  135. function removeItem(index: number) {
  136. if (props.keyword) {
  137. searchResults.value.splice(index, 1);
  138. } else {
  139. searchHistory.value.splice(index, 1);
  140. }
  141. activeIndex.value = Math.max(activeIndex.value - 1, 0);
  142. scrollIntoView();
  143. }
  144. // 存储所有需要转义的特殊字符
  145. const code = new Set([
  146. '$',
  147. '(',
  148. ')',
  149. '*',
  150. '+',
  151. '.',
  152. '?',
  153. '[',
  154. '\\',
  155. ']',
  156. '^',
  157. '{',
  158. '|',
  159. '}',
  160. ]);
  161. // 转换函数,用于转义特殊字符
  162. function transform(c: string) {
  163. // 如果字符在特殊字符列表中,返回转义后的字符
  164. // 如果不在,返回字符本身
  165. return code.has(c) ? `\\${c}` : c;
  166. }
  167. // 创建搜索正则表达式
  168. function createSearchReg(key: string) {
  169. // 将输入的字符串拆分为单个字符
  170. // 对每个字符进行转义
  171. // 然后用'.*'连接所有字符,创建正则表达式
  172. const keys = [...key].map((item) => transform(item)).join('.*');
  173. // 返回创建的正则表达式
  174. return new RegExp(`.*${keys}.*`);
  175. }
  176. watch(
  177. () => props.keyword,
  178. (val) => {
  179. if (val) {
  180. handleSearch(val);
  181. } else {
  182. searchResults.value = searchHistory.value;
  183. }
  184. },
  185. );
  186. onMounted(() => {
  187. searchItems.value = mapTree(props.menus, (item) => {
  188. return {
  189. ...item,
  190. name: $t(item?.name),
  191. };
  192. });
  193. if (searchHistory.value.length > 0) {
  194. searchResults.value = searchHistory.value;
  195. }
  196. // enter search
  197. onKeyStroke('Enter', handleEnter);
  198. // Monitor keyboard arrow keys
  199. onKeyStroke('ArrowUp', handleUp);
  200. onKeyStroke('ArrowDown', handleDown);
  201. // esc close
  202. onKeyStroke('Escape', handleClose);
  203. });
  204. useEventListener('mousemove', () => {
  205. isNavigating.value = false;
  206. });
  207. </script>
  208. <template>
  209. <VbenScrollbar>
  210. <div class="flex! h-full justify-center px-2 sm:max-h-112.5">
  211. <!-- 无搜索结果 -->
  212. <div
  213. v-if="keyword && searchResults.length === 0"
  214. class="text-center text-muted-foreground"
  215. >
  216. <SearchX class="mx-auto mt-4 size-12" />
  217. <p class="mt-6 mb-10 text-xs">
  218. {{ $t('ui.widgets.search.noResults') }}
  219. <span class="text-sm font-medium text-foreground">
  220. "{{ keyword }}"
  221. </span>
  222. </p>
  223. </div>
  224. <!-- 历史搜索记录 & 没有搜索结果 -->
  225. <div
  226. v-if="!keyword && searchResults.length === 0"
  227. class="text-center text-muted-foreground"
  228. >
  229. <p class="my-10 text-xs">
  230. {{ $t('ui.widgets.search.noRecent') }}
  231. </p>
  232. </div>
  233. <ul v-show="searchResults.length > 0" class="w-full">
  234. <li
  235. v-if="searchHistory.length > 0 && !keyword"
  236. class="mb-2 text-xs text-muted-foreground"
  237. >
  238. {{ $t('ui.widgets.search.recent') }}
  239. </li>
  240. <li
  241. v-for="(item, index) in uniqueByField(searchResults, 'path')"
  242. :key="item.path"
  243. :class="
  244. activeIndex === index
  245. ? 'active bg-primary text-primary-foreground'
  246. : ''
  247. "
  248. :data-index="index"
  249. :data-search-item="index"
  250. class="group mb-3 flex-center w-full cursor-pointer rounded-lg bg-accent p-4"
  251. @click="handleEnter"
  252. @mouseenter="handleMouseenter"
  253. >
  254. <VbenIcon :icon="item.icon" class="mr-2 size-5 shrink-0" fallback />
  255. <span class="flex-1">{{ item.name }}</span>
  256. <div
  257. class="flex-center rounded-full p-1 hover:scale-110 hover:text-primary-foreground dark:hover:bg-accent"
  258. @click.stop="removeItem(index)"
  259. >
  260. <X class="size-4" />
  261. </div>
  262. </li>
  263. </ul>
  264. </div>
  265. </VbenScrollbar>
  266. </template>