faet(projects): global-search add pinyin-pro history collect

This commit is contained in:
Ben1111 2024-08-06 14:08:47 +08:00
parent d825b6e260
commit 7b33ce64bd
No known key found for this signature in database
GPG Key ID: CE7CBC6B0B53C40F
8 changed files with 313 additions and 34 deletions

View File

@ -66,7 +66,6 @@
"naive-ui": "2.39.0", "naive-ui": "2.39.0",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "2.2.0", "pinia": "2.2.0",
"pinyin-pro": "3.23.1",
"print-js": "1.6.0", "print-js": "1.6.0",
"swiper": "11.1.5", "swiper": "11.1.5",
"tailwind-merge": "2.4.0", "tailwind-merge": "2.4.0",
@ -103,6 +102,7 @@
"eslint": "9.8.0", "eslint": "9.8.0",
"eslint-plugin-vue": "9.27.0", "eslint-plugin-vue": "9.27.0",
"lint-staged": "15.2.7", "lint-staged": "15.2.7",
"pinyin-pro": "3.23.1",
"sass": "1.77.8", "sass": "1.77.8",
"simple-git-hooks": "2.11.1", "simple-git-hooks": "2.11.1",
"tsx": "4.16.2", "tsx": "4.16.2",

View 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>

View File

@ -1,5 +1,6 @@
<script lang="ts" setup> <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 { useRouter } from 'vue-router';
import { onKeyStroke, useDebounceFn } from '@vueuse/core'; import { onKeyStroke, useDebounceFn } from '@vueuse/core';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
@ -7,9 +8,16 @@ import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SearchResult from './search-result.vue'; import SearchResult from './search-result.vue';
import SearchFooter from './search-footer.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' }); 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 router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
@ -18,21 +26,120 @@ const isMobile = computed(() => appStore.isMobile);
const keyword = ref(''); const keyword = ref('');
const activePath = ref(''); const activePath = ref('');
const historyPath = ref("");
const resultOptions = shallowRef<App.Global.Menu[]>([]); const resultOptions = shallowRef<App.Global.Menu[]>([]);
const historyOptions = shallowRef<App.Global.SearchHistoryOrCollect[]>([]);
const { locale } = useI18n()
const handleSearch = useDebounceFn(search, 300); const handleSearch = useDebounceFn(search, 300);
const visible = defineModel<boolean>('show', { required: true }); 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() { function search() {
resultOptions.value = routeStore.searchMenus.filter(menu => { resultOptions.value = routeStore.searchMenus.filter(menu => {
const trimKeyword = keyword.value.toLocaleLowerCase().trim(); const trimKeyword = keyword.value.toLocaleLowerCase().trim();
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase(); 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 ?? ''; 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() { function handleClose() {
// handle with setTimeout to prevent user from seeing some operations // handle with setTimeout to prevent user from seeing some operations
setTimeout(() => { setTimeout(() => {
@ -44,39 +151,44 @@ function handleClose() {
/** key up */ /** key up */
function handleUp() { function handleUp() {
const { length } = resultOptions.value; const { isResultOptions,options,currentPath } = getCuurentOptionsPath()
if (length === 0) return; if (options.length === 0) return;
const index = options.findIndex(item => item.routePath === currentPath);
const index = getActivePathIndex(); const prevIndex = (index - 1 + options.length) % options.length;
if (index === -1) return; if (isResultOptions) {
activePath.value = resultOptions.value[prevIndex].routePath;
const activeIndex = index === 0 ? length - 1 : index - 1; } else {
historyPath.value = historyOptions.value[prevIndex].routePath;
activePath.value = resultOptions.value[activeIndex].routePath; }
} }
/** key down */ /** key down */
function handleDown() { function handleDown() {
const { length } = resultOptions.value; const { isResultOptions,options,currentPath } = getCuurentOptionsPath()
if (length === 0) return; if (options.length === 0) return;
const index = options.findIndex(item => item.routePath === currentPath);
const index = getActivePathIndex(); const prevIndex = (index + 1 + options.length) % options.length;
if (index === -1) return; if (isResultOptions) {
activePath.value = resultOptions.value[prevIndex].routePath;
const activeIndex = index === length - 1 ? 0 : index + 1; } else {
historyPath.value = historyOptions.value[prevIndex].routePath;
activePath.value = resultOptions.value[activeIndex].routePath; }
} }
function getActivePathIndex() {
return resultOptions.value.findIndex(item => item.routePath === activePath.value);
}
/** key enter */ /** key enter */
function handleEnter() { 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(); handleClose();
router.push(activePath.value);
} }
function registerShortcut() { function registerShortcut() {
@ -87,6 +199,7 @@ function registerShortcut() {
} }
registerShortcut(); registerShortcut();
</script> </script>
<template> <template>
@ -102,7 +215,7 @@ registerShortcut();
@after-leave="handleClose" @after-leave="handleClose"
> >
<NInputGroup> <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> <template #prefix>
<icon-uil-search class="text-15px text-#c2c2c2" /> <icon-uil-search class="text-15px text-#c2c2c2" />
</template> </template>
@ -111,8 +224,9 @@ registerShortcut();
</NInputGroup> </NInputGroup>
<div class="mt-20px"> <div class="mt-20px">
<NEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" /> <NEmpty v-if="showEmpty" :description="$t('common.noData')" />
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" /> <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> </div>
<template #footer> <template #footer>
<SearchFooter v-if="!isMobile" /> <SearchFooter v-if="!isMobile" />

View File

@ -1,9 +1,18 @@
import { addAPIProvider, disableCache } from '@iconify/vue'; import { addAPIProvider, disableCache , addCollection } from '@iconify/vue';
/** Setup the iconify offline */ /** Setup the iconify offline */
export function setupIconifyOffline() { export function setupIconifyOffline() {
const { VITE_ICONIFY_URL } = import.meta.env; 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) { if (VITE_ICONIFY_URL) {
addAPIProvider('', { resources: [VITE_ICONIFY_URL] }); addAPIProvider('', { resources: [VITE_ICONIFY_URL] });

View File

@ -74,7 +74,8 @@ export const themeSettings: App.Theme.ThemeSetting = {
'base-text': 'rgb(224, 224, 224)' 'base-text': 'rgb(224, 224, 224)'
} }
} }
} },
menuSearchHistoryMaxValue: 6
}; };
/** /**

View File

@ -109,6 +109,8 @@ declare namespace App {
[K in keyof ThemeSettingToken]?: Partial<ThemeSettingToken[K]>; [K in keyof ThemeSettingToken]?: Partial<ThemeSettingToken[K]>;
}; };
}; };
/** menu history max value */
menuSearchHistoryMaxValue: number
} }
interface OtherColor { interface OtherColor {
@ -200,6 +202,11 @@ declare namespace App {
children?: Menu[]; children?: Menu[];
}; };
/** The global search history or collect */
type SearchHistoryOrCollect = Menu & {
type: 'history' | 'collect';
};
type Breadcrumb = Omit<Menu, 'children'> & { type Breadcrumb = Omit<Menu, 'children'> & {
options?: Breadcrumb[]; options?: Breadcrumb[];
}; };

View File

@ -29,6 +29,7 @@ declare module 'vue' {
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default'] IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default'] IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default'] IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIonClose: typeof import('~icons/ion/close')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default'] IconLocalActivity: typeof import('~icons/local/activity')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalCast: typeof import('~icons/local/cast')['default'] IconLocalCast: typeof import('~icons/local/cast')['default']

View File

@ -35,5 +35,9 @@ declare namespace StorageType {
layout: UnionKey.ThemeLayoutMode; layout: UnionKey.ThemeLayoutMode;
siderCollapse: boolean; siderCollapse: boolean;
}; };
/** The search history */
searchHistory: App.Global.SearchHistoryOrCollect[]
/** The search collect */
searchCollect: App.Global.SearchHistoryOrCollect[]
} }
} }