SearchModal.vue 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. <script setup lang="ts">
  2. import { match } from "pinyin-pro";
  3. import { useI18n } from "vue-i18n";
  4. import { getConfig } from "@/config";
  5. import { useRouter } from "vue-router";
  6. import SearchResult from "./SearchResult.vue";
  7. import SearchFooter from "./SearchFooter.vue";
  8. import { useNav } from "@/layout/hooks/useNav";
  9. import { transformI18n } from "@/plugins/i18n";
  10. import SearchHistory from "./SearchHistory.vue";
  11. import type { optionsItem, dragItem } from "../types";
  12. import { ref, computed, shallowRef, watch } from "vue";
  13. import { useDebounceFn, onKeyStroke } from "@vueuse/core";
  14. import { usePermissionStoreHook } from "@/store/modules/permission";
  15. import { cloneDeep, isAllEmpty, storageLocal } from "@pureadmin/utils";
  16. import SearchIcon from "~icons/ri/search-line";
  17. interface Props {
  18. /** 弹窗显隐 */
  19. value: boolean;
  20. }
  21. interface Emits {
  22. (e: "update:value", val: boolean): void;
  23. }
  24. const { device } = useNav();
  25. const emit = defineEmits<Emits>();
  26. const props = withDefaults(defineProps<Props>(), {});
  27. const router = useRouter();
  28. const { t, locale } = useI18n();
  29. const HISTORY_TYPE = "history";
  30. const COLLECT_TYPE = "collect";
  31. const LOCALEHISTORYKEY = "menu-search-history";
  32. const LOCALECOLLECTKEY = "menu-search-collect";
  33. const keyword = ref("");
  34. const resultRef = ref();
  35. const historyRef = ref();
  36. const scrollbarRef = ref();
  37. const activePath = ref("");
  38. const historyPath = ref("");
  39. const resultOptions = shallowRef([]);
  40. const historyOptions = shallowRef([]);
  41. const handleSearch = useDebounceFn(search, 300);
  42. const historyNum = getConfig().MenuSearchHistory;
  43. const inputRef = ref<HTMLInputElement | null>(null);
  44. /** 菜单树形结构 */
  45. const menusData = computed(() => {
  46. return cloneDeep(usePermissionStoreHook().wholeMenus);
  47. });
  48. const show = computed({
  49. get() {
  50. return props.value;
  51. },
  52. set(val: boolean) {
  53. emit("update:value", val);
  54. }
  55. });
  56. watch(
  57. () => props.value,
  58. newValue => {
  59. if (newValue) getHistory();
  60. }
  61. );
  62. const showSearchResult = computed(() => {
  63. return keyword.value && resultOptions.value.length > 0;
  64. });
  65. const showSearchHistory = computed(() => {
  66. return !keyword.value && historyOptions.value.length > 0;
  67. });
  68. const showEmpty = computed(() => {
  69. return (
  70. (!keyword.value && historyOptions.value.length === 0) ||
  71. (keyword.value && resultOptions.value.length === 0)
  72. );
  73. });
  74. function getStorageItem(key) {
  75. return storageLocal().getItem<optionsItem[]>(key) || [];
  76. }
  77. function setStorageItem(key, value) {
  78. storageLocal().setItem(key, value);
  79. }
  80. /** 将菜单树形结构扁平化为一维数组,用于菜单查询 */
  81. function flatTree(arr) {
  82. const res = [];
  83. function deep(arr) {
  84. arr.forEach(item => {
  85. res.push(item);
  86. item.children && deep(item.children);
  87. });
  88. }
  89. deep(arr);
  90. return res;
  91. }
  92. /** 查询 */
  93. function search() {
  94. const flatMenusData = flatTree(menusData.value);
  95. resultOptions.value = flatMenusData.filter(menu =>
  96. keyword.value
  97. ? transformI18n(menu.meta?.title)
  98. .toLocaleLowerCase()
  99. .includes(keyword.value.toLocaleLowerCase().trim()) ||
  100. (locale.value === "zh" &&
  101. !isAllEmpty(
  102. match(
  103. transformI18n(menu.meta?.title).toLocaleLowerCase(),
  104. keyword.value.toLocaleLowerCase().trim()
  105. )
  106. ))
  107. : false
  108. );
  109. activePath.value =
  110. resultOptions.value?.length > 0 ? resultOptions.value[0].path : "";
  111. }
  112. function handleClose() {
  113. show.value = false;
  114. /** 延时处理防止用户看到某些操作 */
  115. setTimeout(() => {
  116. resultOptions.value = [];
  117. historyPath.value = "";
  118. keyword.value = "";
  119. }, 200);
  120. }
  121. function scrollTo(index) {
  122. const ref = resultOptions.value.length ? resultRef.value : historyRef.value;
  123. const scrollTop = ref.handleScroll(index);
  124. scrollbarRef.value.setScrollTop(scrollTop);
  125. }
  126. /** 获取当前选项和路径 */
  127. function getCurrentOptionsAndPath() {
  128. const isResultOptions = resultOptions.value.length > 0;
  129. const options = isResultOptions ? resultOptions.value : historyOptions.value;
  130. const currentPath = isResultOptions ? activePath.value : historyPath.value;
  131. return { options, currentPath, isResultOptions };
  132. }
  133. /** 更新路径并滚动到指定项 */
  134. function updatePathAndScroll(newIndex, isResultOptions) {
  135. if (isResultOptions) {
  136. activePath.value = resultOptions.value[newIndex].path;
  137. } else {
  138. historyPath.value = historyOptions.value[newIndex].path;
  139. }
  140. scrollTo(newIndex);
  141. }
  142. /** key up */
  143. function handleUp() {
  144. const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
  145. if (options.length === 0) return;
  146. const index = options.findIndex(item => item.path === currentPath);
  147. const prevIndex = (index - 1 + options.length) % options.length;
  148. updatePathAndScroll(prevIndex, isResultOptions);
  149. }
  150. /** key down */
  151. function handleDown() {
  152. const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
  153. if (options.length === 0) return;
  154. const index = options.findIndex(item => item.path === currentPath);
  155. const nextIndex = (index + 1) % options.length;
  156. updatePathAndScroll(nextIndex, isResultOptions);
  157. }
  158. /** key enter */
  159. function handleEnter() {
  160. const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
  161. if (options.length === 0 || currentPath === "") return;
  162. const index = options.findIndex(item => item.path === currentPath);
  163. if (index === -1) return;
  164. if (isResultOptions) {
  165. saveHistory();
  166. } else {
  167. updateHistory();
  168. }
  169. router.push(options[index].path);
  170. handleClose();
  171. }
  172. /** 删除历史记录 */
  173. function handleDelete(item) {
  174. const key = item.type === HISTORY_TYPE ? LOCALEHISTORYKEY : LOCALECOLLECTKEY;
  175. let list = getStorageItem(key);
  176. list = list.filter(listItem => listItem.path !== item.path);
  177. setStorageItem(key, list);
  178. getHistory();
  179. }
  180. /** 收藏历史记录 */
  181. function handleCollect(item) {
  182. let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
  183. let searchCollectList = getStorageItem(LOCALECOLLECTKEY);
  184. searchHistoryList = searchHistoryList.filter(
  185. historyItem => historyItem.path !== item.path
  186. );
  187. setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
  188. if (!searchCollectList.some(collectItem => collectItem.path === item.path)) {
  189. searchCollectList.unshift({ ...item, type: COLLECT_TYPE });
  190. setStorageItem(LOCALECOLLECTKEY, searchCollectList);
  191. }
  192. getHistory();
  193. }
  194. /** 存储搜索记录 */
  195. function saveHistory() {
  196. const { path, meta } = resultOptions.value.find(
  197. item => item.path === activePath.value
  198. );
  199. const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
  200. const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
  201. const isCollected = searchCollectList.some(item => item.path === path);
  202. const existingIndex = searchHistoryList.findIndex(item => item.path === path);
  203. if (!isCollected) {
  204. if (existingIndex !== -1) searchHistoryList.splice(existingIndex, 1);
  205. if (searchHistoryList.length >= historyNum) searchHistoryList.pop();
  206. searchHistoryList.unshift({ path, meta, type: HISTORY_TYPE });
  207. storageLocal().setItem(LOCALEHISTORYKEY, searchHistoryList);
  208. }
  209. }
  210. /** 更新存储的搜索记录 */
  211. function updateHistory() {
  212. let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
  213. const historyIndex = searchHistoryList.findIndex(
  214. item => item.path === historyPath.value
  215. );
  216. if (historyIndex !== -1) {
  217. const [historyItem] = searchHistoryList.splice(historyIndex, 1);
  218. searchHistoryList.unshift(historyItem);
  219. setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
  220. }
  221. }
  222. /** 获取本地历史记录 */
  223. function getHistory() {
  224. const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
  225. const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
  226. historyOptions.value = [...searchHistoryList, ...searchCollectList];
  227. historyPath.value = historyOptions.value[0]?.path;
  228. }
  229. /** 拖拽改变收藏顺序 */
  230. function handleDrag(item: dragItem) {
  231. const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
  232. const [reorderedItem] = searchCollectList.splice(item.oldIndex, 1);
  233. searchCollectList.splice(item.newIndex, 0, reorderedItem);
  234. storageLocal().setItem(LOCALECOLLECTKEY, searchCollectList);
  235. historyOptions.value = [
  236. ...getStorageItem(LOCALEHISTORYKEY),
  237. ...getStorageItem(LOCALECOLLECTKEY)
  238. ];
  239. historyPath.value = reorderedItem.path;
  240. }
  241. onKeyStroke("Enter", handleEnter);
  242. onKeyStroke("ArrowUp", handleUp);
  243. onKeyStroke("ArrowDown", handleDown);
  244. </script>
  245. <template>
  246. <el-dialog
  247. v-model="show"
  248. top="5vh"
  249. class="pure-search-dialog"
  250. :show-close="false"
  251. :width="device === 'mobile' ? '80vw' : '40vw'"
  252. :before-close="handleClose"
  253. :style="{
  254. borderRadius: '6px'
  255. }"
  256. append-to-body
  257. @opened="inputRef.focus()"
  258. @closed="inputRef.blur()"
  259. >
  260. <el-input
  261. ref="inputRef"
  262. v-model="keyword"
  263. size="large"
  264. clearable
  265. :placeholder="t('search.purePlaceholder')"
  266. @input="handleSearch"
  267. >
  268. <template #prefix>
  269. <IconifyIconOffline
  270. :icon="SearchIcon"
  271. class="text-primary w-[24px] h-[24px]"
  272. />
  273. </template>
  274. </el-input>
  275. <div class="search-content">
  276. <el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
  277. <el-empty v-if="showEmpty" :description="t('search.pureEmpty')" />
  278. <SearchHistory
  279. v-if="showSearchHistory"
  280. ref="historyRef"
  281. v-model:value="historyPath"
  282. :options="historyOptions"
  283. @click="handleEnter"
  284. @delete="handleDelete"
  285. @collect="handleCollect"
  286. @drag="handleDrag"
  287. />
  288. <SearchResult
  289. v-if="showSearchResult"
  290. ref="resultRef"
  291. v-model:value="activePath"
  292. :options="resultOptions"
  293. @click="handleEnter"
  294. />
  295. </el-scrollbar>
  296. </div>
  297. <template #footer>
  298. <SearchFooter :total="resultOptions.length" />
  299. </template>
  300. </el-dialog>
  301. </template>
  302. <style lang="scss" scoped>
  303. .search-content {
  304. margin-top: 12px;
  305. }
  306. </style>