search-panel.vue 7.1 KB

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