search-panel.vue 6.9 KB

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