mirror of
https://github.com/soybeanjs/soybean-admin.git
synced 2025-09-29 14:46:41 +08:00
faet(projects): global-search add pinyin-pro history collect
This commit is contained in:
parent
d825b6e260
commit
7b33ce64bd
@ -66,7 +66,6 @@
|
||||
"naive-ui": "2.39.0",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "2.2.0",
|
||||
"pinyin-pro": "3.23.1",
|
||||
"print-js": "1.6.0",
|
||||
"swiper": "11.1.5",
|
||||
"tailwind-merge": "2.4.0",
|
||||
@ -103,6 +102,7 @@
|
||||
"eslint": "9.8.0",
|
||||
"eslint-plugin-vue": "9.27.0",
|
||||
"lint-staged": "15.2.7",
|
||||
"pinyin-pro": "3.23.1",
|
||||
"sass": "1.77.8",
|
||||
"simple-git-hooks": "2.11.1",
|
||||
"tsx": "4.16.2",
|
||||
|
143
src/layouts/modules/global-search/components/search-history.vue
Normal file
143
src/layouts/modules/global-search/components/search-history.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<script lang="ts" setup>
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import { computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
defineOptions({ name: 'SearchHistory' });
|
||||
|
||||
interface Props {
|
||||
options: App.Global.SearchHistoryOrCollect[];
|
||||
}
|
||||
|
||||
const HISTORY_TYPE = "history";
|
||||
const COLLECT_TYPE = "collect";
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: "collect", val: App.Global.SearchHistoryOrCollect): void;
|
||||
(e: 'enter'): void;
|
||||
(e: 'delete', val: App.Global.SearchHistoryOrCollect): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const active = defineModel<string>('path', { required: true });
|
||||
|
||||
async function handleMouseEnter(item: App.Global.SearchHistoryOrCollect) {
|
||||
active.value = item.routePath;
|
||||
}
|
||||
|
||||
const titleStyle = computed(() => {
|
||||
return {
|
||||
color: theme.themeColor,
|
||||
fontWeight: 500,
|
||||
}
|
||||
});
|
||||
|
||||
const historyList = computed (() => {
|
||||
return props.options.filter(item => item.type === HISTORY_TYPE)
|
||||
});
|
||||
|
||||
const collectList = computed(() => {
|
||||
return props.options.filter(item => item.type === COLLECT_TYPE)
|
||||
});
|
||||
|
||||
function handleTo() {
|
||||
emit('enter');
|
||||
}
|
||||
|
||||
function handleDelete(item: App.Global.SearchHistoryOrCollect) {
|
||||
emit('delete', item);
|
||||
}
|
||||
|
||||
function handleCollect(item: App.Global.SearchHistoryOrCollect) {
|
||||
emit('collect', item);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NScrollbar>
|
||||
<div class="pb-12px">
|
||||
<template v-if="historyList.length">
|
||||
<div :style="titleStyle">搜索历史</div>
|
||||
<div v-for="item in historyList" :key="item.routePath">
|
||||
<div
|
||||
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
|
||||
:style="{
|
||||
background: item.routePath === active ? theme.themeColor : '',
|
||||
color: item.routePath === active ? '#fff' : ''
|
||||
}"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouseEnter(item)"
|
||||
>
|
||||
<component :is="item.icon" />
|
||||
<span class="ml-5px flex-1">
|
||||
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
|
||||
</span>
|
||||
<button class="w-24px h-24px action-button" @click="handleCollect(item)">
|
||||
<Icon class="w-20px h-20px icon" icon="soybean:star" />
|
||||
</button>
|
||||
<button class="w-24px h-24px action-button" @click="handleDelete(item)">
|
||||
<icon-ion-close class="w-20px h-20px"></icon-ion-close>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="collectList.length">
|
||||
<div :style="titleStyle">收藏记录</div>
|
||||
<div v-for="item in collectList" :key="item.routePath">
|
||||
<div
|
||||
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
|
||||
:style="{
|
||||
background: item.routePath === active ? theme.themeColor : '',
|
||||
color: item.routePath === active ? '#fff' : ''
|
||||
}"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouseEnter(item)"
|
||||
>
|
||||
<Icon class="w-20px h-20px" icon="soybean:star" />
|
||||
<component :is="item.icon" />
|
||||
<span class="ml-5px flex-1">
|
||||
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
|
||||
</span>
|
||||
<button class="w-24px h-24px action-button" @click="handleDelete(item)">
|
||||
<icon-ion-close class="w-20px h-20px"></icon-ion-close>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.action-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
line-height: 20px;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
}
|
||||
.action-button:hover {
|
||||
background-color: #0003;
|
||||
transition: background-color .1s ease-in;
|
||||
color:#fff;
|
||||
::v-deep .icon path {
|
||||
fill:currentColor;
|
||||
stroke: currentColor;
|
||||
fill-rule: evenodd;
|
||||
stroke-linecap: round;
|
||||
transition: fill 0.3s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { computed, ref, shallowRef,watch } from 'vue';
|
||||
import { match } from "pinyin-pro";
|
||||
import { useRouter } from 'vue-router';
|
||||
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
@ -7,9 +8,16 @@ import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import SearchResult from './search-result.vue';
|
||||
import SearchFooter from './search-footer.vue';
|
||||
import SearchHistory from './search-history.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { themeSettings } from '@/theme/settings';
|
||||
|
||||
defineOptions({ name: 'SearchModal' });
|
||||
|
||||
const SOYBEAN_LOCAL_STG_HISTORY_KEY = 'searchHistory'
|
||||
const SOYBEAN_LOCAL_STG_COLLECT_KEY = 'searchCollect'
|
||||
const HISTORY_TYPE = "history";
|
||||
const COLLECT_TYPE = "collect";
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const routeStore = useRouteStore();
|
||||
@ -18,21 +26,120 @@ const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const keyword = ref('');
|
||||
const activePath = ref('');
|
||||
const historyPath = ref("");
|
||||
const resultOptions = shallowRef<App.Global.Menu[]>([]);
|
||||
|
||||
const historyOptions = shallowRef<App.Global.SearchHistoryOrCollect[]>([]);
|
||||
const { locale } = useI18n()
|
||||
const handleSearch = useDebounceFn(search, 300);
|
||||
|
||||
const visible = defineModel<boolean>('show', { required: true });
|
||||
|
||||
const isShowSearchResult = computed(() => {
|
||||
return keyword.value || historyOptions.value.length > 0;
|
||||
});
|
||||
|
||||
const isShowSearchHistory = 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 search() {
|
||||
resultOptions.value = routeStore.searchMenus.filter(menu => {
|
||||
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
|
||||
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase();
|
||||
return trimKeyword && title.includes(trimKeyword);
|
||||
const pinyinPro = locale.value === 'zh-CN' && match(title, keyword.value.toLocaleLowerCase().trim())?.every(arg => arg !== null)
|
||||
const result = keyword.value ? (title.includes(trimKeyword) || pinyinPro) : false
|
||||
return result;
|
||||
});
|
||||
activePath.value = resultOptions.value[0]?.routePath ?? '';
|
||||
}
|
||||
|
||||
watch(visible, () => {
|
||||
getHistory()
|
||||
})
|
||||
|
||||
/** get current options */
|
||||
function getCuurentOptionsPath() {
|
||||
const isResultOptions = resultOptions.value.length > 0;
|
||||
const options = isResultOptions? resultOptions.value : historyOptions.value
|
||||
const currentPath = isResultOptions ? activePath.value : historyPath.value
|
||||
return {isResultOptions,options,currentPath}
|
||||
}
|
||||
|
||||
/** get localStg history */
|
||||
function getHistory() {
|
||||
const searchHistoryMenus = localStg.get(SOYBEAN_LOCAL_STG_HISTORY_KEY) || []
|
||||
const searchCollectMenus = localStg.get(SOYBEAN_LOCAL_STG_COLLECT_KEY) || []
|
||||
historyOptions.value = [...searchHistoryMenus,...searchCollectMenus]
|
||||
historyPath.value = historyOptions.value?.[0]?.routePath
|
||||
}
|
||||
|
||||
/** update localStg history */
|
||||
function updateHistory() {
|
||||
let searchHistoryMenus = localStg.get(SOYBEAN_LOCAL_STG_HISTORY_KEY) || [];
|
||||
const historyIndex = searchHistoryMenus.findIndex(item => item.routePath === historyPath.value)
|
||||
if (!~historyIndex) {
|
||||
const [historyItem] = searchHistoryMenus.splice(historyIndex,1)
|
||||
searchHistoryMenus.unshift(historyItem)
|
||||
localStg.set(SOYBEAN_LOCAL_STG_HISTORY_KEY, searchHistoryMenus)
|
||||
}
|
||||
}
|
||||
|
||||
/** save history */
|
||||
function saveHistory() {
|
||||
const res = resultOptions.value.find(item => item.routePath === activePath.value)
|
||||
const searchHistoryMenus = localStg.get(SOYBEAN_LOCAL_STG_HISTORY_KEY) || []
|
||||
const searchCollectMenus = localStg.get(SOYBEAN_LOCAL_STG_COLLECT_KEY) || []
|
||||
const isCollected = searchCollectMenus.some(item => item.routePath === res?.routePath)
|
||||
const existingIndex = searchHistoryMenus.findIndex(item => item.routePath === res?.routePath);
|
||||
if(!isCollected && res) {
|
||||
if(existingIndex !== -1) {
|
||||
searchHistoryMenus.splice(existingIndex,1)
|
||||
}
|
||||
if(searchHistoryMenus.length < themeSettings.menuSearchHistoryMaxValue) {
|
||||
searchHistoryMenus.unshift({...res, type: HISTORY_TYPE});
|
||||
} else {
|
||||
searchHistoryMenus.pop()
|
||||
}
|
||||
localStg.set(SOYBEAN_LOCAL_STG_HISTORY_KEY, searchHistoryMenus)
|
||||
}
|
||||
}
|
||||
|
||||
/** handle delete */
|
||||
function handleDelete(options: App.Global.SearchHistoryOrCollect) {
|
||||
let searchHistoryMenus = localStg.get(SOYBEAN_LOCAL_STG_HISTORY_KEY) || []
|
||||
let searchCollectMenus = localStg.get(SOYBEAN_LOCAL_STG_COLLECT_KEY) || []
|
||||
if(options.type === HISTORY_TYPE) {
|
||||
searchHistoryMenus = searchHistoryMenus.filter(item => item.routePath !== options.routePath)
|
||||
localStg.set(SOYBEAN_LOCAL_STG_HISTORY_KEY,searchHistoryMenus)
|
||||
} else {
|
||||
searchCollectMenus = searchCollectMenus.filter(item => item.routePath !== options.routePath)
|
||||
localStg.set(SOYBEAN_LOCAL_STG_COLLECT_KEY,searchCollectMenus)
|
||||
}
|
||||
getHistory()
|
||||
}
|
||||
|
||||
/** handle collect */
|
||||
function handleCollect(options: App.Global.SearchHistoryOrCollect) {
|
||||
let searchHistoryMenus = localStg.get(SOYBEAN_LOCAL_STG_HISTORY_KEY) || []
|
||||
let searchCollectMenus = localStg.get(SOYBEAN_LOCAL_STG_COLLECT_KEY) || []
|
||||
searchHistoryMenus = searchHistoryMenus.filter(item => item.routePath !== options.routePath)
|
||||
localStg.set(SOYBEAN_LOCAL_STG_COLLECT_KEY,searchHistoryMenus)
|
||||
const isCollected = searchCollectMenus.some(item => item.routePath === options.routePath)
|
||||
if(!isCollected) {
|
||||
searchCollectMenus.unshift({...options, type: COLLECT_TYPE});
|
||||
localStg.set(SOYBEAN_LOCAL_STG_COLLECT_KEY,searchCollectMenus)
|
||||
}
|
||||
getHistory()
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// handle with setTimeout to prevent user from seeing some operations
|
||||
setTimeout(() => {
|
||||
@ -44,39 +151,44 @@ function handleClose() {
|
||||
|
||||
/** key up */
|
||||
function handleUp() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0) return;
|
||||
|
||||
const index = getActivePathIndex();
|
||||
if (index === -1) return;
|
||||
|
||||
const activeIndex = index === 0 ? length - 1 : index - 1;
|
||||
|
||||
activePath.value = resultOptions.value[activeIndex].routePath;
|
||||
const { isResultOptions,options,currentPath } = getCuurentOptionsPath()
|
||||
if (options.length === 0) return;
|
||||
const index = options.findIndex(item => item.routePath === currentPath);
|
||||
const prevIndex = (index - 1 + options.length) % options.length;
|
||||
if (isResultOptions) {
|
||||
activePath.value = resultOptions.value[prevIndex].routePath;
|
||||
} else {
|
||||
historyPath.value = historyOptions.value[prevIndex].routePath;
|
||||
}
|
||||
}
|
||||
|
||||
/** key down */
|
||||
function handleDown() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0) return;
|
||||
|
||||
const index = getActivePathIndex();
|
||||
if (index === -1) return;
|
||||
|
||||
const activeIndex = index === length - 1 ? 0 : index + 1;
|
||||
|
||||
activePath.value = resultOptions.value[activeIndex].routePath;
|
||||
const { isResultOptions,options,currentPath } = getCuurentOptionsPath()
|
||||
if (options.length === 0) return;
|
||||
const index = options.findIndex(item => item.routePath === currentPath);
|
||||
const prevIndex = (index + 1 + options.length) % options.length;
|
||||
if (isResultOptions) {
|
||||
activePath.value = resultOptions.value[prevIndex].routePath;
|
||||
} else {
|
||||
historyPath.value = historyOptions.value[prevIndex].routePath;
|
||||
}
|
||||
}
|
||||
|
||||
function getActivePathIndex() {
|
||||
return resultOptions.value.findIndex(item => item.routePath === activePath.value);
|
||||
}
|
||||
|
||||
/** key enter */
|
||||
function handleEnter() {
|
||||
if (resultOptions.value?.length === 0 || activePath.value === '') return;
|
||||
const { isResultOptions, options, currentPath } = getCuurentOptionsPath()
|
||||
if (options?.length === 0 || currentPath === '') return;
|
||||
const index = options.findIndex((item) => item.routePath === currentPath);
|
||||
if (index === -1) return;
|
||||
if (isResultOptions) {
|
||||
saveHistory();
|
||||
} else {
|
||||
updateHistory();
|
||||
}
|
||||
router.push(options[index].routePath);
|
||||
handleClose();
|
||||
router.push(activePath.value);
|
||||
}
|
||||
|
||||
function registerShortcut() {
|
||||
@ -87,6 +199,7 @@ function registerShortcut() {
|
||||
}
|
||||
|
||||
registerShortcut();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -102,7 +215,7 @@ registerShortcut();
|
||||
@after-leave="handleClose"
|
||||
>
|
||||
<NInputGroup>
|
||||
<NInput v-model:value="keyword" clearable :placeholder="$t('common.keywordSearch')" @input="handleSearch">
|
||||
<NInput v-model:value="keyword" :spellcheck="false" clearable :placeholder="$t('common.keywordSearch')" @input="handleSearch">
|
||||
<template #prefix>
|
||||
<icon-uil-search class="text-15px text-#c2c2c2" />
|
||||
</template>
|
||||
@ -111,8 +224,9 @@ registerShortcut();
|
||||
</NInputGroup>
|
||||
|
||||
<div class="mt-20px">
|
||||
<NEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" />
|
||||
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
|
||||
<NEmpty v-if="showEmpty" :description="$t('common.noData')" />
|
||||
<SearchHistory v-if="isShowSearchHistory" v-model:path="historyPath" :options="historyOptions" @enter="handleEnter" @collect="handleCollect" @delete="handleDelete" />
|
||||
<SearchResult v-if="isShowSearchResult" v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<SearchFooter v-if="!isMobile" />
|
||||
|
@ -1,9 +1,18 @@
|
||||
import { addAPIProvider, disableCache } from '@iconify/vue';
|
||||
import { addAPIProvider, disableCache , addCollection } from '@iconify/vue';
|
||||
|
||||
/** Setup the iconify offline */
|
||||
export function setupIconifyOffline() {
|
||||
const { VITE_ICONIFY_URL } = import.meta.env;
|
||||
|
||||
addCollection({
|
||||
prefix: 'soybean',
|
||||
icons:{
|
||||
star:{
|
||||
body: '<path d="M10 14.2L5 17l1-5.6-4-4 5.5-.7 2.5-5 2.5 5 5.6.8-4 4 .9 5.5z" stroke="currentColor" fill="none" fill-rule="evenodd" stroke-linejoin="round"></path>',
|
||||
width: 20,
|
||||
height: 20
|
||||
}
|
||||
}
|
||||
})
|
||||
if (VITE_ICONIFY_URL) {
|
||||
addAPIProvider('', { resources: [VITE_ICONIFY_URL] });
|
||||
|
||||
|
@ -74,7 +74,8 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
'base-text': 'rgb(224, 224, 224)'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
menuSearchHistoryMaxValue: 6
|
||||
};
|
||||
|
||||
/**
|
||||
|
7
src/typings/app.d.ts
vendored
7
src/typings/app.d.ts
vendored
@ -109,6 +109,8 @@ declare namespace App {
|
||||
[K in keyof ThemeSettingToken]?: Partial<ThemeSettingToken[K]>;
|
||||
};
|
||||
};
|
||||
/** menu history max value */
|
||||
menuSearchHistoryMaxValue: number
|
||||
}
|
||||
|
||||
interface OtherColor {
|
||||
@ -200,6 +202,11 @@ declare namespace App {
|
||||
children?: Menu[];
|
||||
};
|
||||
|
||||
/** The global search history or collect */
|
||||
type SearchHistoryOrCollect = Menu & {
|
||||
type: 'history' | 'collect';
|
||||
};
|
||||
|
||||
type Breadcrumb = Omit<Menu, 'children'> & {
|
||||
options?: Breadcrumb[];
|
||||
};
|
||||
|
1
src/typings/components.d.ts
vendored
1
src/typings/components.d.ts
vendored
@ -29,6 +29,7 @@ declare module 'vue' {
|
||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
|
||||
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||
IconIonClose: typeof import('~icons/ion/close')['default']
|
||||
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||
IconLocalCast: typeof import('~icons/local/cast')['default']
|
||||
|
4
src/typings/storage.d.ts
vendored
4
src/typings/storage.d.ts
vendored
@ -35,5 +35,9 @@ declare namespace StorageType {
|
||||
layout: UnionKey.ThemeLayoutMode;
|
||||
siderCollapse: boolean;
|
||||
};
|
||||
/** The search history */
|
||||
searchHistory: App.Global.SearchHistoryOrCollect[]
|
||||
/** The search collect */
|
||||
searchCollect: App.Global.SearchHistoryOrCollect[]
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user