Skip to content

Commit 9ea8789

Browse files
committed
feat(components): add GlobalSearch components
1 parent 05db8c0 commit 9ea8789

File tree

8 files changed

+255
-0
lines changed

8 files changed

+255
-0
lines changed

src/layouts/modules/global-header/index.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useRouteStore } from '@/store/modules/route';
77
import HorizontalMenu from '../global-menu/base-menu.vue';
88
import GlobalLogo from '../global-logo/index.vue';
99
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
10+
import GlobalSearch from '../global-search/index.vue';
1011
import { useMixMenuContext } from '../../hooks/use-mix-menu';
1112
import ThemeButton from './components/theme-button.vue';
1213
import UserAvatar from './components/user-avatar.vue';
@@ -54,6 +55,7 @@ const headerMenus = computed(() => {
5455
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
5556
</div>
5657
<div class="h-full flex-y-center justify-end">
58+
<GlobalSearch />
5759
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
5860
<LangSwitch :lang="appStore.locale" :lang-options="appStore.localeOptions" @change-lang="appStore.changeLocale" />
5961
<ThemeSchemaSwitch
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script lang="ts" setup>
2+
defineOptions({ name: 'SearchFooter' });
3+
</script>
4+
5+
<template>
6+
<div class="h-44px flex-y-center px-24px">
7+
<span class="mr-14px flex-y-center">
8+
<icon-mdi-keyboard-return class="icon mr-6px p-2px text-20px" />
9+
<span>确认</span>
10+
</span>
11+
<span class="mr-14px flex-y-center">
12+
<icon-mdi-arrow-up-thin class="icon mr-5px p-2px text-20px" />
13+
<icon-mdi-arrow-down-thin class="icon mr-6px p-2px text-20px" />
14+
<span>切换</span>
15+
</span>
16+
<span class="flex-y-center">
17+
<icon-mdi-keyboard-esc class="icon mr-6px p-2px text-20px" />
18+
<span>关闭</span>
19+
</span>
20+
</div>
21+
</template>
22+
23+
<style lang="scss" scoped>
24+
.icon {
25+
box-shadow:
26+
inset 0 -2px #cdcde6,
27+
inset 0 0 1px 1px #fff,
28+
0 1px 2px 1px #1e235a66;
29+
}
30+
</style>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<script lang="ts" setup>
2+
import { computed, ref, shallowRef } from 'vue';
3+
import { useRouter } from 'vue-router';
4+
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
5+
import { useRouteStore } from '@/store/modules/route';
6+
import { useAppStore } from '@/store/modules/app';
7+
import { $t } from '@/locales';
8+
import SearchResult from './search-result.vue';
9+
import SearchFooter from './search-footer.vue';
10+
11+
defineOptions({ name: 'SearchModal' });
12+
13+
const appStore = useAppStore();
14+
const isMobile = computed(() => appStore.isMobile);
15+
const router = useRouter();
16+
const routeStore = useRouteStore();
17+
18+
const keyword = ref('');
19+
const activePath = ref('');
20+
const resultOptions = shallowRef<App.Global.Menu[]>([]);
21+
22+
const handleSearch = useDebounceFn(search, 300);
23+
24+
const modelShow = defineModel<boolean>('show', { required: true });
25+
26+
/** 查询 */
27+
function search() {
28+
resultOptions.value = routeStore.searchMenus.filter(menu => {
29+
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
30+
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase();
31+
return trimKeyword && title.includes(trimKeyword);
32+
});
33+
activePath.value = resultOptions.value[0]?.routePath ?? '';
34+
}
35+
36+
function handleClose() {
37+
modelShow.value = false;
38+
/** 延时处理防止用户看到某些操作 */
39+
setTimeout(() => {
40+
resultOptions.value = [];
41+
keyword.value = '';
42+
}, 200);
43+
}
44+
45+
/** key up */
46+
function handleUp() {
47+
const { length } = resultOptions.value;
48+
if (length === 0) return;
49+
const index = resultOptions.value.findIndex(item => item.routePath === activePath.value);
50+
if (index === 0) {
51+
activePath.value = resultOptions.value[length - 1].routePath;
52+
} else {
53+
activePath.value = resultOptions.value[index - 1].routePath;
54+
}
55+
}
56+
57+
/** key down */
58+
function handleDown() {
59+
const { length } = resultOptions.value;
60+
if (length === 0) return;
61+
const index = resultOptions.value.findIndex(item => item.routePath === activePath.value);
62+
if (index + 1 === length) {
63+
activePath.value = resultOptions.value[0].routePath;
64+
} else {
65+
activePath.value = resultOptions.value[index + 1].routePath;
66+
}
67+
}
68+
69+
/** key enter */
70+
function handleEnter(e: Event | undefined) {
71+
const { length } = resultOptions.value;
72+
if (length === 0 || activePath.value === '') return;
73+
e?.preventDefault();
74+
handleClose();
75+
router.push(activePath.value);
76+
}
77+
78+
onKeyStroke('Escape', handleClose);
79+
onKeyStroke('Enter', handleEnter);
80+
onKeyStroke('ArrowUp', handleUp);
81+
onKeyStroke('ArrowDown', handleDown);
82+
</script>
83+
84+
<template>
85+
<n-modal
86+
v-model:show="modelShow"
87+
:segmented="{ footer: 'soft' }"
88+
:closable="false"
89+
preset="card"
90+
auto-focus
91+
footer-style="padding: 0; margin: 0"
92+
class="fixed left-0 right-0"
93+
:class="[isMobile ? 'size-full top-0px rounded-0' : 'w-630px top-50px']"
94+
@after-leave="handleClose"
95+
>
96+
<n-input-group>
97+
<n-input v-model:value="keyword" clearable placeholder="请输入关键词搜索" @input="handleSearch">
98+
<template #prefix>
99+
<icon-uil-search class="text-15px text-#c2c2c2" />
100+
</template>
101+
</n-input>
102+
<n-button v-if="isMobile" type="primary" ghost @click="handleClose">取消</n-button>
103+
</n-input-group>
104+
105+
<div class="mt-20px">
106+
<n-empty v-if="resultOptions.length === 0" description="暂无搜索结果" />
107+
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
108+
</div>
109+
<template #footer>
110+
<SearchFooter v-if="!isMobile" />
111+
</template>
112+
</n-modal>
113+
</template>
114+
115+
<style lang="scss" scoped></style>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script lang="ts" setup>
2+
import { useThemeStore } from '@/store/modules/theme';
3+
import { $t } from '@/locales';
4+
5+
defineOptions({ name: 'SearchResult' });
6+
7+
interface Props {
8+
options: App.Global.Menu[];
9+
}
10+
11+
defineProps<Props>();
12+
13+
interface Emits {
14+
(e: 'enter'): void;
15+
}
16+
17+
const emit = defineEmits<Emits>();
18+
19+
const theme = useThemeStore();
20+
21+
const active = defineModel<string>('path', { required: true });
22+
23+
/** 鼠标移入 */
24+
async function handleMouse(item: App.Global.Menu) {
25+
active.value = item.routePath;
26+
}
27+
28+
function handleTo() {
29+
emit('enter');
30+
}
31+
</script>
32+
33+
<template>
34+
<n-scrollbar>
35+
<div class="pb-12px">
36+
<template v-for="item in options" :key="item.routePath">
37+
<div
38+
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
39+
:style="{
40+
background: item.routePath === active ? theme.themeColor : '',
41+
color: item.routePath === active ? '#fff' : ''
42+
}"
43+
@click="handleTo"
44+
@mouseenter="handleMouse(item)"
45+
>
46+
<component :is="item.icon" />
47+
<span class="ml-5px flex-1">
48+
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
49+
</span>
50+
<icon-ant-design-enter-outlined class="icon mr-3px p-2px text-20px" />
51+
</div>
52+
</template>
53+
</div>
54+
</n-scrollbar>
55+
</template>
56+
57+
<style lang="scss" scoped></style>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts" setup>
2+
import { useBoolean } from '@sa/hooks';
3+
import SearchModal from './components/search-modal.vue';
4+
5+
defineOptions({ name: 'GlobalSearch' });
6+
7+
const { bool: show, toggle } = useBoolean();
8+
function handleSearch() {
9+
toggle();
10+
}
11+
</script>
12+
13+
<template>
14+
<ButtonIcon tooltip-content="搜索" @click="handleSearch">
15+
<icon-uil-search class="text-20px" />
16+
</ButtonIcon>
17+
<SearchModal v-model:show="show" />
18+
</template>
19+
20+
<style lang="scss" scoped></style>

src/store/modules/route/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getSelectedMenuKeyPathByKey,
2020
isRouteExistByRouteName,
2121
sortRoutesByOrder,
22+
transformMenuToSearchMenus,
2223
updateLocaleOfGlobalMenus
2324
} from './shared';
2425

@@ -52,6 +53,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
5253

5354
/** Global menus */
5455
const menus = ref<App.Global.Menu[]>([]);
56+
const searchMenus = computed(() => transformMenuToSearchMenus(menus.value));
5557

5658
/** Get global menus */
5759
function getGlobalMenus(routes: ElegantConstRoute[]) {
@@ -275,6 +277,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
275277
resetStore,
276278
routeHome,
277279
menus,
280+
searchMenus,
278281
updateGlobalMenusByLocale,
279282
cacheRoutes,
280283
reCacheRoutesByKey,

src/store/modules/route/shared.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,22 @@ export function getBreadcrumbsByRoute(
310310

311311
return [];
312312
}
313+
314+
/**
315+
* Transform menu to searchMenus
316+
*
317+
* @param menus - menus
318+
* @param treeMap
319+
*/
320+
export function transformMenuToSearchMenus(menus: App.Global.Menu[], treeMap: App.Global.Menu[] = []) {
321+
if (menus && menus.length === 0) return [];
322+
return menus.reduce((acc, cur) => {
323+
if (!cur.children) {
324+
acc.push(cur);
325+
}
326+
if (cur.children && cur.children.length > 0) {
327+
transformMenuToSearchMenus(cur.children, treeMap);
328+
}
329+
return acc;
330+
}, treeMap);
331+
}

src/typings/components.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare module 'vue' {
1414
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
1515
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
1616
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
17+
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
1718
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
1819
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
1920
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
@@ -24,8 +25,13 @@ declare module 'vue' {
2425
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
2526
IconLocalBanner: typeof import('~icons/local/banner')['default']
2627
IconLocalLogo: typeof import('~icons/local/logo')['default']
28+
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
29+
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
2730
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
31+
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
32+
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
2833
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
34+
IconUilSearch: typeof import('~icons/uil/search')['default']
2935
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
3036
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
3137
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
@@ -43,6 +49,7 @@ declare module 'vue' {
4349
NDrawer: typeof import('naive-ui')['NDrawer']
4450
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
4551
NDropdown: typeof import('naive-ui')['NDropdown']
52+
NEmpty: typeof import('naive-ui')['NEmpty']
4653
NForm: typeof import('naive-ui')['NForm']
4754
NFormItem: typeof import('naive-ui')['NFormItem']
4855
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
@@ -56,11 +63,13 @@ declare module 'vue' {
5663
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
5764
NMenu: typeof import('naive-ui')['NMenu']
5865
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
66+
NModal: typeof import('naive-ui')['NModal']
5967
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
6068
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
6169
NPopover: typeof import('naive-ui')['NPopover']
6270
NRadio: typeof import('naive-ui')['NRadio']
6371
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
72+
NScrollbar: typeof import('naive-ui')['NScrollbar']
6473
NSelect: typeof import('naive-ui')['NSelect']
6574
NSpace: typeof import('naive-ui')['NSpace']
6675
NStatistic: typeof import('naive-ui')['NStatistic']

0 commit comments

Comments
 (0)