mirror of
https://github.com/soybeanjs/soybean-admin.git
synced 2025-09-23 20:06:37 +08:00
feat(projects): 增加全局搜索菜单功能
This commit is contained in:
parent
90ddf9837c
commit
b9ce69130b
@ -13,6 +13,7 @@
|
|||||||
<header-menu />
|
<header-menu />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end h-full">
|
<div class="flex justify-end h-full">
|
||||||
|
<global-search />
|
||||||
<github-site />
|
<github-site />
|
||||||
<full-screen />
|
<full-screen />
|
||||||
<theme-mode />
|
<theme-mode />
|
||||||
@ -34,6 +35,7 @@ import {
|
|||||||
GithubSite
|
GithubSite
|
||||||
} from './components';
|
} from './components';
|
||||||
import GlobalLogo from '../GlobalLogo/index.vue';
|
import GlobalLogo from '../GlobalLogo/index.vue';
|
||||||
|
import GlobalSearch from '../GlobalSearch/index.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 显示logo */
|
/** 显示logo */
|
||||||
|
24
src/layouts/common/GlobalSearch/components/SearchFooter.vue
Normal file
24
src/layouts/common/GlobalSearch/components/SearchFooter.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-24px h-44px flex-y-center">
|
||||||
|
<span class="mr-14px">
|
||||||
|
<icon-ant-design:enter-outlined class="icon text-20px p-2px mr-3px" />
|
||||||
|
确认
|
||||||
|
</span>
|
||||||
|
<span class="mr-14px">
|
||||||
|
<icon-mdi:arrow-up-thin class="icon text-20px p-2px mr-5px" />
|
||||||
|
<icon-mdi:arrow-down-thin class="icon text-20px p-2px mr-3px" />
|
||||||
|
切换
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<icon-mdi:close class="icon text-20px p-2px mr-3px" />
|
||||||
|
关闭
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup></script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.icon {
|
||||||
|
box-shadow: inset 0 -2px #cdcde6, inset 0 0 1px 1px #fff, 0 1px 2px 1px #1e235a66;
|
||||||
|
}
|
||||||
|
</style>
|
127
src/layouts/common/GlobalSearch/components/SearchModal.vue
Normal file
127
src/layouts/common/GlobalSearch/components/SearchModal.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
:segmented="{ footer: 'soft' }"
|
||||||
|
:closable="false"
|
||||||
|
preset="card"
|
||||||
|
footer-style="padding: 0; margin: 0"
|
||||||
|
class="w-630px fixed top-50px left-1/2 transform -translate-x-1/2"
|
||||||
|
>
|
||||||
|
<n-input ref="inputRef" v-model:value="keyword" clearable placeholder="请输入关键词搜索" @input="handleSearch">
|
||||||
|
<template #prefix>
|
||||||
|
<icon-uil:search class="text-15px text-[#c2c2c2]" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
<div class="mt-20px">
|
||||||
|
<n-empty v-if="resultOptions.length === 0" description="暂无搜索结果" />
|
||||||
|
<search-result v-else v-model:value="activePath" :options="resultOptions" @enter="handleEnter" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<search-footer />
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, shallowRef, computed, watch, nextTick } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import { NModal, NInput, NEmpty } from 'naive-ui';
|
||||||
|
import { useDebounceFn, onKeyStroke } from '@vueuse/core';
|
||||||
|
import { menusList } from '@/router';
|
||||||
|
import { isUrl } from '@/utils';
|
||||||
|
import SearchResult from './SearchResult.vue';
|
||||||
|
import SearchFooter from './SearchFooter.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 弹窗显隐 */
|
||||||
|
value: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:value', val: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const keyword = ref('');
|
||||||
|
const activePath = ref('');
|
||||||
|
const resultOptions = shallowRef<RouteRecordRaw[]>([]);
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const handleSearch = useDebounceFn(search, 300);
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get() {
|
||||||
|
return props.value;
|
||||||
|
},
|
||||||
|
set(val: boolean) {
|
||||||
|
emit('update:value', val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(show, async val => {
|
||||||
|
if (val) {
|
||||||
|
/** 自动聚焦 */
|
||||||
|
await nextTick();
|
||||||
|
inputRef.value?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 查询 */
|
||||||
|
function search() {
|
||||||
|
resultOptions.value = menusList.filter(menu => keyword.value && menu.meta?.title.includes(keyword.value.trim()));
|
||||||
|
if (resultOptions.value?.length > 0) {
|
||||||
|
activePath.value = resultOptions.value[0].path;
|
||||||
|
} else {
|
||||||
|
activePath.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
resultOptions.value = [];
|
||||||
|
keyword.value = '';
|
||||||
|
show.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
} else {
|
||||||
|
activePath.value = resultOptions.value[index - 1].path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key enter */
|
||||||
|
function handleEnter() {
|
||||||
|
if (isUrl(activePath.value)) {
|
||||||
|
window.open(activePath.value, '__blank');
|
||||||
|
} else {
|
||||||
|
router.push(activePath.value);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyStroke('Escape', handleClose);
|
||||||
|
onKeyStroke('Enter', handleEnter);
|
||||||
|
onKeyStroke('ArrowUp', handleUp);
|
||||||
|
onKeyStroke('ArrowDown', handleDown);
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
62
src/layouts/common/GlobalSearch/components/SearchResult.vue
Normal file
62
src/layouts/common/GlobalSearch/components/SearchResult.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<n-scrollbar>
|
||||||
|
<div class="pb-12px">
|
||||||
|
<template v-for="item in options" :key="item.path">
|
||||||
|
<div
|
||||||
|
class="bg-[#e5e7eb] dark:bg-dark h-56px mt-8px px-14px rounded-4px cursor-pointer flex-y-center justify-between"
|
||||||
|
:style="{
|
||||||
|
background: item.path === active ? theme.themeColor : '',
|
||||||
|
color: item.path === active ? '#fff' : ''
|
||||||
|
}"
|
||||||
|
@click="handleTo"
|
||||||
|
@mouseenter="handleMouse(item)"
|
||||||
|
>
|
||||||
|
<Icon :icon="item.meta?.icon ?? 'mdi:bookmark-minus-outline'" />
|
||||||
|
<span class="flex-1 ml-5px">{{ item.meta?.title }}</span>
|
||||||
|
<icon-ant-design:enter-outlined class="icon text-20px p-2px mr-3px" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</n-scrollbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import { NScrollbar } from 'naive-ui';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { useThemeStore } from '@/store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
options: RouteRecordRaw[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:value', val: string): void;
|
||||||
|
(e: 'enter'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const active = computed({
|
||||||
|
get() {
|
||||||
|
return props.value;
|
||||||
|
},
|
||||||
|
set(val: string) {
|
||||||
|
emit('update:value', val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const theme = useThemeStore();
|
||||||
|
|
||||||
|
/** 鼠标移入 */
|
||||||
|
async function handleMouse(item: RouteRecordRaw) {
|
||||||
|
active.value = item.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTo() {
|
||||||
|
emit('enter');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
3
src/layouts/common/GlobalSearch/components/index.ts
Normal file
3
src/layouts/common/GlobalSearch/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import SearchModal from './SearchModal.vue';
|
||||||
|
|
||||||
|
export { SearchModal };
|
20
src/layouts/common/GlobalSearch/index.vue
Normal file
20
src/layouts/common/GlobalSearch/index.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<hover-container tooltip-content="搜索" class="w-40px h-full" @click="handleSearch">
|
||||||
|
<icon-uil:search class="text-20px text-[#666]" />
|
||||||
|
</hover-container>
|
||||||
|
<search-modal v-model:value="show" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useBoolean } from '@/hooks';
|
||||||
|
import { HoverContainer } from '@/components';
|
||||||
|
import { SearchModal } from './components';
|
||||||
|
|
||||||
|
const { bool: show, toggle } = useBoolean();
|
||||||
|
function handleSearch() {
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
@ -18,4 +18,4 @@ export async function setupRouter(app: App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { default as cacheRoutes } from './cache';
|
export { default as cacheRoutes } from './cache';
|
||||||
export { default as menus } from './menus';
|
export { menusList, menus } from './menus';
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { transformRouteToMenu } from '@/utils';
|
import { transformRouteToMenu, transformRouteToList } from '@/utils';
|
||||||
import customRoutes from '../modules';
|
import customRoutes from '../modules';
|
||||||
|
|
||||||
/** 菜单 */
|
/** 菜单 */
|
||||||
const menus = transformRouteToMenu(customRoutes);
|
const menus = transformRouteToMenu(customRoutes);
|
||||||
|
/** 菜单搜索列表 */
|
||||||
|
const menusList = transformRouteToList(customRoutes);
|
||||||
|
|
||||||
export default menus;
|
export { menus, menusList };
|
||||||
|
@ -48,6 +48,20 @@ export function transformRouteToMenu(routes: RouteRecordRaw[]) {
|
|||||||
return globalMenu;
|
return globalMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将路由转换成菜单列表 */
|
||||||
|
export function transformRouteToList(routes: RouteRecordRaw[], treeMap: RouteRecordRaw[] = []) {
|
||||||
|
if (routes && routes.length === 0) return [];
|
||||||
|
return routes.reduce((acc, cur) => {
|
||||||
|
if (!cur.meta?.notAsMenu) {
|
||||||
|
acc.push(cur);
|
||||||
|
}
|
||||||
|
if (cur.children && cur.children.length > 0) {
|
||||||
|
transformRouteToList(cur.children, treeMap);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, treeMap);
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断路由是否为Url链接 */
|
/** 判断路由是否为Url链接 */
|
||||||
export function isUrl(path: string): boolean {
|
export function isUrl(path: string): boolean {
|
||||||
const reg =
|
const reg =
|
||||||
|
Loading…
Reference in New Issue
Block a user