浏览代码

feat: 菜单搜索新增搜索历史和收藏功能 (#901)

* feat: 菜单搜索新增搜索历史和收藏功能
zepeng 1 年之前
父节点
当前提交
16122aec17

+ 1 - 0
public/platform-config.json

@@ -22,6 +22,7 @@
   "CachingAsyncRoutes": false,
   "TooltipEffect": "light",
   "ResponsiveStorageNameSpace": "responsive-",
+  "MenuSearchHistory": 6,
   "MapConfigure": {
     "amapKey": "97b3248d1553172e81f168cf94ea667e",
     "options": {

+ 2 - 2
src/layout/components/search/components/SearchFooter.vue

@@ -1,9 +1,9 @@
 <script setup lang="ts">
-import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
-import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
 import { useNav } from "@/layout/hooks/useNav";
 import mdiKeyboardEsc from "@/assets/svg/keyboard_esc.svg?component";
 import enterOutlined from "@/assets/svg/enter_outlined.svg?component";
+import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
+import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
 
 const props = withDefaults(defineProps<{ total: number }>(), {
   total: 0

+ 198 - 0
src/layout/components/search/components/SearchHistory.vue

@@ -0,0 +1,198 @@
+<script setup lang="ts">
+import Sortable from "sortablejs";
+import SearchHistoryItem from "./SearchHistoryItem.vue";
+import type { optionsItem, dragItem, Props } from "../types";
+import { useEpThemeStoreHook } from "@/store/modules/epTheme";
+import { useResizeObserver, isArray, delay } from "@pureadmin/utils";
+import { ref, watch, nextTick, computed, getCurrentInstance } from "vue";
+
+interface Emits {
+  (e: "update:value", val: string): void;
+  (e: "enter"): void;
+  (e: "collect", val: optionsItem): void;
+  (e: "delete", val: optionsItem): void;
+  (e: "drag", val: dragItem): void;
+}
+
+const historyRef = ref();
+const innerHeight = ref();
+/** 判断是否停止鼠标移入事件处理 */
+const stopMouseEvent = ref(false);
+
+const emit = defineEmits<Emits>();
+const instance = getCurrentInstance()!;
+const props = withDefaults(defineProps<Props>(), {});
+
+const itemStyle = computed(() => {
+  return item => {
+    return {
+      background:
+        item?.path === active.value ? useEpThemeStoreHook().epThemeColor : "",
+      color: item.path === active.value ? "#fff" : "",
+      fontSize: item.path === active.value ? "16px" : "14px"
+    };
+  };
+});
+
+const titleStyle = computed(() => {
+  return {
+    color: useEpThemeStoreHook().epThemeColor,
+    fontWeight: 500
+  };
+});
+
+const active = computed({
+  get() {
+    return props.value;
+  },
+  set(val: string) {
+    emit("update:value", val);
+  }
+});
+
+watch(
+  () => props.value,
+  newValue => {
+    if (newValue) {
+      if (stopMouseEvent.value) {
+        delay(100).then(() => (stopMouseEvent.value = false));
+      }
+    }
+  }
+);
+
+const historyList = computed(() => {
+  return props.options.filter(item => item.type === "history");
+});
+
+const collectList = computed(() => {
+  return props.options.filter(item => item.type === "collect");
+});
+
+function handleCollect(item) {
+  emit("collect", item);
+}
+
+function handleDelete(item) {
+  stopMouseEvent.value = true;
+  emit("delete", item);
+}
+
+/** 鼠标移入 */
+async function handleMouse(item) {
+  if (!stopMouseEvent.value) active.value = item.path;
+}
+
+function handleTo() {
+  emit("enter");
+}
+
+function resizeResult() {
+  // el-scrollbar max-height="calc(90vh - 140px)"
+  innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
+}
+
+useResizeObserver(historyRef, resizeResult);
+
+function handleScroll(index: number) {
+  const curInstance = instance?.proxy?.$refs[`historyItemRef${index}`];
+  if (!curInstance) return 0;
+  const curRef = isArray(curInstance)
+    ? (curInstance[0] as ElRef)
+    : (curInstance as ElRef);
+  const scrollTop = curRef.offsetTop + 128; // 128 两个history-item(56px+56px=112px)高度加上下margin(8px+8px=16px)
+  return scrollTop > innerHeight.value ? scrollTop - innerHeight.value : 0;
+}
+
+const handleChangeIndex = (evt): void => {
+  emit("drag", { oldIndex: evt.oldIndex, newIndex: evt.newIndex });
+};
+
+let sortableInstance = null;
+
+watch(
+  collectList,
+  val => {
+    if (val.length > 1) {
+      nextTick(() => {
+        const wrapper: HTMLElement =
+          document.querySelector(".collect-container");
+        if (!wrapper || sortableInstance) return;
+        sortableInstance = Sortable.create(wrapper, {
+          animation: 160,
+          onStart: event => {
+            event.item.style.cursor = "move";
+          },
+          onEnd: event => {
+            event.item.style.cursor = "pointer";
+          },
+          onUpdate: handleChangeIndex
+        });
+        resizeResult();
+      });
+    }
+  },
+  { deep: true, immediate: true }
+);
+
+defineExpose({ handleScroll });
+</script>
+
+<template>
+  <div ref="historyRef" class="history">
+    <template v-if="historyList.length">
+      <div :style="titleStyle">搜索历史</div>
+      <div
+        v-for="(item, index) in historyList"
+        :key="item.path"
+        :ref="'historyItemRef' + index"
+        class="history-item dark:bg-[#1d1d1d]"
+        :style="itemStyle(item)"
+        @click="handleTo"
+        @mouseenter="handleMouse(item)"
+      >
+        <SearchHistoryItem
+          :item="item"
+          @delete-item="handleDelete"
+          @collect-item="handleCollect"
+        />
+      </div>
+    </template>
+    <template v-if="collectList.length">
+      <div :style="titleStyle">
+        收藏{{ collectList.length > 1 ? "(可拖拽排序)" : "" }}
+      </div>
+      <div class="collect-container">
+        <div
+          v-for="(item, index) in collectList"
+          :key="item.path"
+          :ref="'historyItemRef' + (index + historyList.length)"
+          class="history-item dark:bg-[#1d1d1d]"
+          :style="itemStyle(item)"
+          @click="handleTo"
+          @mouseenter="handleMouse(item)"
+        >
+          <SearchHistoryItem :item="item" @delete-item="handleDelete" />
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.history {
+  padding-bottom: 12px;
+
+  &-item {
+    display: flex;
+    align-items: center;
+    height: 56px;
+    padding: 14px;
+    margin: 8px auto 10px;
+    cursor: pointer;
+    border: 0.1px solid #ccc;
+    border-radius: 4px;
+    transition: font-size 0.16s;
+  }
+}
+</style>

+ 53 - 0
src/layout/components/search/components/SearchHistoryItem.vue

@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import type { optionsItem } from "../types";
+import { transformI18n } from "@/plugins/i18n";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import Star from "@iconify-icons/ep/star";
+import Close from "@iconify-icons/ep/close";
+
+interface Props {
+  item: optionsItem;
+}
+
+interface Emits {
+  (e: "collectItem", val: optionsItem): void;
+  (e: "deleteItem", val: optionsItem): void;
+}
+
+const emit = defineEmits<Emits>();
+withDefaults(defineProps<Props>(), {});
+
+function handleCollect(item) {
+  emit("collectItem", item);
+}
+
+function handleDelete(item) {
+  emit("deleteItem", item);
+}
+</script>
+
+<template>
+  <component :is="useRenderIcon(item.meta?.icon)" />
+  <span class="history-item-title">
+    {{ transformI18n(item.meta?.title) }}
+  </span>
+  <IconifyIconOffline
+    v-show="item.type === 'history'"
+    :icon="Star"
+    class="w-[18px] h-[18px] mr-2 hover:text-[#d7d5d4]"
+    @click.stop="handleCollect(item)"
+  />
+  <IconifyIconOffline
+    :icon="Close"
+    class="w-[18px] h-[18px] hover:text-[#d7d5d4] cursor-pointer"
+    @click.stop="handleDelete(item)"
+  />
+</template>
+
+<style lang="scss" scoped>
+.history-item-title {
+  display: flex;
+  flex: 1;
+  margin-left: 5px;
+}
+</style>

+ 178 - 42
src/layout/components/search/components/SearchModal.vue

@@ -1,15 +1,18 @@
 <script setup lang="ts">
 import { match } from "pinyin-pro";
 import { useI18n } from "vue-i18n";
+import { getConfig } from "@/config";
 import { useRouter } from "vue-router";
 import SearchResult from "./SearchResult.vue";
 import SearchFooter from "./SearchFooter.vue";
 import { useNav } from "@/layout/hooks/useNav";
 import { transformI18n } from "@/plugins/i18n";
-import { ref, computed, shallowRef } from "vue";
-import { cloneDeep, isAllEmpty } from "@pureadmin/utils";
+import SearchHistory from "./SearchHistory.vue";
+import type { optionsItem, dragItem } from "../types";
+import { ref, computed, shallowRef, watch } from "vue";
 import { useDebounceFn, onKeyStroke } from "@vueuse/core";
 import { usePermissionStoreHook } from "@/store/modules/permission";
+import { cloneDeep, isAllEmpty, storageLocal } from "@pureadmin/utils";
 import Search from "@iconify-icons/ri/search-line";
 
 interface Props {
@@ -24,16 +27,26 @@ interface Emits {
 const { device } = useNav();
 const emit = defineEmits<Emits>();
 const props = withDefaults(defineProps<Props>(), {});
+
 const router = useRouter();
 const { locale } = useI18n();
 
+const HISTORY_TYPE = "history";
+const COLLECT_TYPE = "collect";
+const LOCALEHISTORYKEY = "menu-search-history";
+const LOCALECOLLECTKEY = "menu-search-collect";
+
 const keyword = ref("");
-const scrollbarRef = ref();
 const resultRef = ref();
+const historyRef = ref();
+const scrollbarRef = ref();
 const activePath = ref("");
-const inputRef = ref<HTMLInputElement | null>(null);
+const historyPath = ref("");
 const resultOptions = shallowRef([]);
+const historyOptions = shallowRef([]);
 const handleSearch = useDebounceFn(search, 300);
+const historyNum = getConfig().MenuSearchHistory;
+const inputRef = ref<HTMLInputElement | null>(null);
 
 /** 菜单树形结构 */
 const menusData = computed(() => {
@@ -49,6 +62,36 @@ const show = computed({
   }
 });
 
+watch(
+  () => props.value,
+  newValue => {
+    if (newValue) getHistory();
+  }
+);
+
+const showSearchResult = computed(() => {
+  return keyword.value && resultOptions.value.length > 0;
+});
+
+const showSearchHistory = computed(() => {
+  return !keyword.value && historyOptions.value.length > 0;
+});
+
+const showEmpty = computed(() => {
+  return (
+    (!keyword.value && historyOptions.value.length === 0) ||
+    (keyword.value && resultOptions.value.length === 0)
+  );
+});
+
+function getStorageItem(key) {
+  return storageLocal().getItem<optionsItem[]>(key) || [];
+}
+
+function setStorageItem(key, value) {
+  storageLocal().setItem(key, value);
+}
+
 /** 将菜单树形结构扁平化为一维数组,用于菜单查询 */
 function flatTree(arr) {
   const res = [];
@@ -79,11 +122,8 @@ function search() {
           ))
       : false
   );
-  if (resultOptions.value?.length > 0) {
-    activePath.value = resultOptions.value[0].path;
-  } else {
-    activePath.value = "";
-  }
+  activePath.value =
+    resultOptions.value?.length > 0 ? resultOptions.value[0].path : "";
 }
 
 function handleClose() {
@@ -91,54 +131,143 @@ function handleClose() {
   /** 延时处理防止用户看到某些操作 */
   setTimeout(() => {
     resultOptions.value = [];
+    historyPath.value = "";
     keyword.value = "";
   }, 200);
 }
 
 function scrollTo(index) {
-  const scrollTop = resultRef.value.handleScroll(index);
+  const ref = resultOptions.value.length ? resultRef.value : historyRef.value;
+  const scrollTop = ref.handleScroll(index);
   scrollbarRef.value.setScrollTop(scrollTop);
 }
 
-/** key up */
-function handleUp() {
-  const { length } = resultOptions.value;
-  if (length === 0) return;
-  const index = resultOptions.value.findIndex(
-    item => item.path === activePath.value
-  );
-  if (index === 0) {
-    activePath.value = resultOptions.value[length - 1].path;
-    scrollTo(resultOptions.value.length - 1);
+/** 获取当前选项和路径 */
+function getCurrentOptionsAndPath() {
+  const isResultOptions = resultOptions.value.length > 0;
+  const options = isResultOptions ? resultOptions.value : historyOptions.value;
+  const currentPath = isResultOptions ? activePath.value : historyPath.value;
+  return { options, currentPath, isResultOptions };
+}
+
+/** 更新路径并滚动到指定项 */
+function updatePathAndScroll(newIndex, isResultOptions) {
+  if (isResultOptions) {
+    activePath.value = resultOptions.value[newIndex].path;
   } else {
-    activePath.value = resultOptions.value[index - 1].path;
-    scrollTo(index - 1);
+    historyPath.value = historyOptions.value[newIndex].path;
   }
+  scrollTo(newIndex);
+}
+
+/** key up */
+function handleUp() {
+  const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
+  if (options.length === 0) return;
+  const index = options.findIndex(item => item.path === currentPath);
+  const prevIndex = (index - 1 + options.length) % options.length;
+  updatePathAndScroll(prevIndex, isResultOptions);
 }
 
 /** key down */
 function handleDown() {
-  const { length } = resultOptions.value;
-  if (length === 0) return;
-  const index = resultOptions.value.findIndex(
-    item => item.path === activePath.value
-  );
-  if (index + 1 === length) {
-    activePath.value = resultOptions.value[0].path;
-  } else {
-    activePath.value = resultOptions.value[index + 1].path;
-  }
-  scrollTo(index + 1);
+  const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
+  if (options.length === 0) return;
+  const index = options.findIndex(item => item.path === currentPath);
+  const nextIndex = (index + 1) % options.length;
+  updatePathAndScroll(nextIndex, isResultOptions);
 }
 
 /** key enter */
 function handleEnter() {
-  const { length } = resultOptions.value;
-  if (length === 0 || activePath.value === "") return;
-  router.push(activePath.value);
+  const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
+  if (options.length === 0 || currentPath === "") return;
+  const index = options.findIndex(item => item.path === currentPath);
+  if (index === -1) return;
+  if (isResultOptions) {
+    saveHistory();
+  } else {
+    updateHistory();
+  }
+  router.push(options[index].path);
   handleClose();
 }
 
+/** 删除历史记录 */
+function handleDelete(item) {
+  const key = item.type === HISTORY_TYPE ? LOCALEHISTORYKEY : LOCALECOLLECTKEY;
+  let list = getStorageItem(key);
+  list = list.filter(listItem => listItem.path !== item.path);
+  setStorageItem(key, list);
+  getHistory();
+}
+
+/** 收藏历史记录 */
+function handleCollect(item) {
+  let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
+  let searchCollectList = getStorageItem(LOCALECOLLECTKEY);
+  searchHistoryList = searchHistoryList.filter(
+    historyItem => historyItem.path !== item.path
+  );
+  setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
+  if (!searchCollectList.some(collectItem => collectItem.path === item.path)) {
+    searchCollectList.unshift({ ...item, type: COLLECT_TYPE });
+    setStorageItem(LOCALECOLLECTKEY, searchCollectList);
+  }
+  getHistory();
+}
+
+/** 存储搜索记录 */
+function saveHistory() {
+  const { path, meta } = resultOptions.value.find(
+    item => item.path === activePath.value
+  );
+  const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
+  const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
+  const isCollected = searchCollectList.some(item => item.path === path);
+  const existingIndex = searchHistoryList.findIndex(item => item.path === path);
+  if (!isCollected) {
+    if (existingIndex !== -1) searchHistoryList.splice(existingIndex, 1);
+    if (searchHistoryList.length >= historyNum) searchHistoryList.pop();
+    searchHistoryList.unshift({ path, meta, type: HISTORY_TYPE });
+    storageLocal().setItem(LOCALEHISTORYKEY, searchHistoryList);
+  }
+}
+
+/** 更新存储的搜索记录 */
+function updateHistory() {
+  let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
+  const historyIndex = searchHistoryList.findIndex(
+    item => item.path === historyPath.value
+  );
+  if (historyIndex !== -1) {
+    const [historyItem] = searchHistoryList.splice(historyIndex, 1);
+    searchHistoryList.unshift(historyItem);
+    setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
+  }
+}
+
+/** 获取本地历史记录 */
+function getHistory() {
+  const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
+  const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
+  historyOptions.value = [...searchHistoryList, ...searchCollectList];
+  historyPath.value = historyOptions.value[0]?.path;
+}
+
+/** 拖拽改变收藏顺序 */
+function handleDrag(item: dragItem) {
+  const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
+  const [reorderedItem] = searchCollectList.splice(item.oldIndex, 1);
+  searchCollectList.splice(item.newIndex, 0, reorderedItem);
+  storageLocal().setItem(LOCALECOLLECTKEY, searchCollectList);
+  historyOptions.value = [
+    ...getStorageItem(LOCALEHISTORYKEY),
+    ...getStorageItem(LOCALECOLLECTKEY)
+  ];
+  historyPath.value = reorderedItem.path;
+}
+
 onKeyStroke("Enter", handleEnter);
 onKeyStroke("ArrowUp", handleUp);
 onKeyStroke("ArrowDown", handleDown);
@@ -174,14 +303,21 @@ onKeyStroke("ArrowDown", handleDown);
         />
       </template>
     </el-input>
-    <div class="search-result-container">
+    <div class="search-content">
       <el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
-        <el-empty
-          v-if="resultOptions.length === 0"
-          description="暂无搜索结果"
+        <el-empty v-if="showEmpty" description="暂无搜索结果" />
+        <SearchHistory
+          v-if="showSearchHistory"
+          ref="historyRef"
+          v-model:value="historyPath"
+          :options="historyOptions"
+          @click="handleEnter"
+          @delete="handleDelete"
+          @collect="handleCollect"
+          @drag="handleDrag"
         />
         <SearchResult
-          v-else
+          v-if="showSearchResult"
           ref="resultRef"
           v-model:value="activePath"
           :options="resultOptions"
@@ -196,7 +332,7 @@ onKeyStroke("ArrowDown", handleDown);
 </template>
 
 <style lang="scss" scoped>
-.search-result-container {
+.search-content {
   margin-top: 12px;
 }
 </style>

+ 3 - 16
src/layout/components/search/components/SearchResult.vue

@@ -1,24 +1,11 @@
 <script setup lang="ts">
+import type { Props } from "../types";
 import { transformI18n } from "@/plugins/i18n";
 import { useResizeObserver } from "@pureadmin/utils";
 import { useEpThemeStoreHook } from "@/store/modules/epTheme";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { ref, computed, getCurrentInstance, onMounted } from "vue";
 import enterOutlined from "@/assets/svg/enter_outlined.svg?component";
-import Bookmark2Line from "@iconify-icons/ri/bookmark-2-line";
-
-interface optionsItem {
-  path: string;
-  meta?: {
-    icon?: string;
-    title?: string;
-  };
-}
-
-interface Props {
-  value: string;
-  options: Array<optionsItem>;
-}
 
 interface Emits {
   (e: "update:value", val: string): void;
@@ -27,9 +14,9 @@ interface Emits {
 
 const resultRef = ref();
 const innerHeight = ref();
-const props = withDefaults(defineProps<Props>(), {});
 const emit = defineEmits<Emits>();
 const instance = getCurrentInstance()!;
+const props = withDefaults(defineProps<Props>(), {});
 
 const itemStyle = computed(() => {
   return item => {
@@ -93,7 +80,7 @@ defineExpose({ handleScroll });
       @click="handleTo"
       @mouseenter="handleMouse(item)"
     >
-      <component :is="useRenderIcon(item.meta?.icon ?? Bookmark2Line)" />
+      <component :is="useRenderIcon(item.meta?.icon)" />
       <span class="result-item-title">
         {{ transformI18n(item.meta?.title) }}
       </span>

+ 20 - 0
src/layout/components/search/types.ts

@@ -0,0 +1,20 @@
+interface optionsItem {
+  path: string;
+  type: "history" | "collect";
+  meta: {
+    icon?: string;
+    title?: string;
+  };
+}
+
+interface dragItem {
+  oldIndex: number;
+  newIndex: number;
+}
+
+interface Props {
+  value: string;
+  options: Array<optionsItem>;
+}
+
+export type { optionsItem, dragItem, Props };

+ 16 - 0
src/utils/localforage/index.ts

@@ -85,6 +85,22 @@ class StorageProxy implements ProxyStorage {
         });
     });
   }
+
+  /**
+   * @description 获取数据仓库中所有的key
+   */
+  public async keys() {
+    return new Promise<string[]>((resolve, reject) => {
+      this.storage
+        .keys()
+        .then(keys => {
+          resolve(keys);
+        })
+        .catch(err => {
+          reject(err);
+        });
+    });
+  }
 }
 
 /**

+ 2 - 0
types/global.d.ts

@@ -96,6 +96,7 @@ declare global {
     CachingAsyncRoutes?: boolean;
     TooltipEffect?: Effect;
     ResponsiveStorageNameSpace?: string;
+    MenuSearchHistory?: number;
     MapConfigure?: {
       amapKey?: string;
       options: {
@@ -131,6 +132,7 @@ declare global {
     overallStyle?: string;
     showLogo?: boolean;
     showModel?: string;
+    menuSearchHistory?: number;
     mapConfigure?: {
       amapKey?: string;
       options: {