| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- <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 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 SearchIcon from "~icons/ri/search-line";
- interface Props {
- /** 弹窗显隐 */
- value: boolean;
- }
- interface Emits {
- (e: "update:value", val: boolean): void;
- }
- const { device } = useNav();
- const emit = defineEmits<Emits>();
- const props = withDefaults(defineProps<Props>(), {});
- const router = useRouter();
- const { t, locale } = useI18n();
- const HISTORY_TYPE = "history";
- const COLLECT_TYPE = "collect";
- const LOCALEHISTORYKEY = "menu-search-history";
- const LOCALECOLLECTKEY = "menu-search-collect";
- const keyword = ref("");
- const resultRef = ref();
- const historyRef = ref();
- const scrollbarRef = ref();
- const activePath = ref("");
- 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(() => {
- return cloneDeep(usePermissionStoreHook().wholeMenus);
- });
- const show = computed({
- get() {
- return props.value;
- },
- set(val: boolean) {
- emit("update:value", val);
- }
- });
- 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 = [];
- function deep(arr) {
- arr.forEach(item => {
- res.push(item);
- item.children && deep(item.children);
- });
- }
- deep(arr);
- return res;
- }
- /** 查询 */
- function search() {
- const flatMenusData = flatTree(menusData.value);
- resultOptions.value = flatMenusData.filter(menu =>
- keyword.value
- ? transformI18n(menu.meta?.title)
- .toLocaleLowerCase()
- .includes(keyword.value.toLocaleLowerCase().trim()) ||
- (locale.value === "zh" &&
- !isAllEmpty(
- match(
- transformI18n(menu.meta?.title).toLocaleLowerCase(),
- keyword.value.toLocaleLowerCase().trim()
- )
- ))
- : false
- );
- activePath.value =
- resultOptions.value?.length > 0 ? resultOptions.value[0].path : "";
- }
- function handleClose() {
- show.value = false;
- /** 延时处理防止用户看到某些操作 */
- setTimeout(() => {
- resultOptions.value = [];
- historyPath.value = "";
- keyword.value = "";
- }, 200);
- }
- function scrollTo(index) {
- const ref = resultOptions.value.length ? resultRef.value : historyRef.value;
- const scrollTop = ref.handleScroll(index);
- scrollbarRef.value.setScrollTop(scrollTop);
- }
- /** 获取当前选项和路径 */
- 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 {
- 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 { 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 { 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);
- </script>
- <template>
- <el-dialog
- v-model="show"
- top="5vh"
- class="pure-search-dialog"
- :show-close="false"
- :width="device === 'mobile' ? '80vw' : '40vw'"
- :before-close="handleClose"
- :style="{
- borderRadius: '6px'
- }"
- append-to-body
- @opened="inputRef.focus()"
- @closed="inputRef.blur()"
- >
- <el-input
- ref="inputRef"
- v-model="keyword"
- size="large"
- clearable
- :placeholder="t('search.purePlaceholder')"
- @input="handleSearch"
- >
- <template #prefix>
- <IconifyIconOffline
- :icon="SearchIcon"
- class="text-primary w-[24px] h-[24px]"
- />
- </template>
- </el-input>
- <div class="search-content">
- <el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
- <el-empty v-if="showEmpty" :description="t('search.pureEmpty')" />
- <SearchHistory
- v-if="showSearchHistory"
- ref="historyRef"
- v-model:value="historyPath"
- :options="historyOptions"
- @click="handleEnter"
- @delete="handleDelete"
- @collect="handleCollect"
- @drag="handleDrag"
- />
- <SearchResult
- v-if="showSearchResult"
- ref="resultRef"
- v-model:value="activePath"
- :options="resultOptions"
- @click="handleEnter"
- />
- </el-scrollbar>
- </div>
- <template #footer>
- <SearchFooter :total="resultOptions.length" />
- </template>
- </el-dialog>
- </template>
- <style lang="scss" scoped>
- .search-content {
- margin-top: 12px;
- }
- </style>
|