commit
852ddb64ad
@ -9,6 +9,7 @@
|
||||
<header-menu />
|
||||
</div>
|
||||
<div class="flex justify-end h-full">
|
||||
<global-search />
|
||||
<github-site />
|
||||
<full-screen />
|
||||
<theme-mode />
|
||||
@ -22,6 +23,7 @@ import { DarkModeContainer } from '@/components';
|
||||
import { useThemeStore } from '@/store';
|
||||
import type { GlobalHeaderProps } from '@/interface';
|
||||
import GlobalLogo from '../GlobalLogo/index.vue';
|
||||
import GlobalSearch from '../GlobalSearch/index.vue';
|
||||
import {
|
||||
MenuCollapse,
|
||||
GlobalBreadcrumb,
|
||||
|
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>
|
136
src/layouts/common/GlobalSearch/components/SearchModal.vue
Normal file
136
src/layouts/common/GlobalSearch/components/SearchModal.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<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"
|
||||
@after-leave="handleClose"
|
||||
>
|
||||
<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 { NModal, NInput, NEmpty } from 'naive-ui';
|
||||
import { useDebounceFn, onKeyStroke } from '@vueuse/core';
|
||||
import { useRouteStore } from '@/store';
|
||||
import type { RouteList } from './types';
|
||||
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 routeStore = useRouteStore();
|
||||
const keyword = ref('');
|
||||
const activePath = ref('');
|
||||
const resultOptions = shallowRef<RouteList[]>([]);
|
||||
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 = routeStore.menusList.filter(
|
||||
menu => keyword.value && menu.meta?.title.toLocaleLowerCase().includes(keyword.value.toLocaleLowerCase().trim())
|
||||
);
|
||||
if (resultOptions.value?.length > 0) {
|
||||
activePath.value = resultOptions.value[0].path;
|
||||
} else {
|
||||
activePath.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
show.value = false;
|
||||
/** 延时处理防止用户看到某些操作 */
|
||||
setTimeout(() => {
|
||||
resultOptions.value = [];
|
||||
keyword.value = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0 || activePath.value === '') return;
|
||||
const item = resultOptions.value.find(item => item.path === activePath.value);
|
||||
if (item?.meta?.href) {
|
||||
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 { NScrollbar } from 'naive-ui';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useThemeStore } from '@/store';
|
||||
import type { RouteList } from './types';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: RouteList[];
|
||||
}
|
||||
|
||||
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: RouteList) {
|
||||
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 };
|
1
src/layouts/common/GlobalSearch/components/types.ts
Normal file
1
src/layouts/common/GlobalSearch/components/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type RouteList = AuthRoute.Route;
|
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 { HoverContainer } from '@/components';
|
||||
import { useBoolean } from '@/hooks';
|
||||
import { SearchModal } from './components';
|
||||
|
||||
const { bool: show, toggle } = useBoolean();
|
||||
function handleSearch() {
|
||||
toggle();
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
@ -1,7 +1,13 @@
|
||||
import type { Router } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import { fetchUserRoutes } from '@/service';
|
||||
import { getUserInfo, transformAuthRouteToMenu, transformAuthRoutesToVueRoutes, getCacheRoutes } from '@/utils';
|
||||
import {
|
||||
getUserInfo,
|
||||
transformAuthRouteToMenu,
|
||||
transformAuthRoutesToVueRoutes,
|
||||
transformRouteToList,
|
||||
getCacheRoutes
|
||||
} from '@/utils';
|
||||
import type { GlobalMenuOption } from '@/interface';
|
||||
import { useTabStore } from '../tab';
|
||||
|
||||
@ -12,6 +18,7 @@ interface RouteState {
|
||||
routeHomeName: AuthRoute.RouteKey;
|
||||
/** 菜单 */
|
||||
menus: GlobalMenuOption[];
|
||||
menusList: AuthRoute.Route[];
|
||||
/** 缓存的路由名称 */
|
||||
cacheRoutes: string[];
|
||||
}
|
||||
@ -21,6 +28,7 @@ export const useRouteStore = defineStore('route-store', {
|
||||
isAddedDynamicRoute: false,
|
||||
routeHomeName: 'dashboard_analysis',
|
||||
menus: [],
|
||||
menusList: [],
|
||||
cacheRoutes: []
|
||||
}),
|
||||
actions: {
|
||||
@ -37,6 +45,7 @@ export const useRouteStore = defineStore('route-store', {
|
||||
if (data) {
|
||||
this.routeHomeName = data.home;
|
||||
this.menus = transformAuthRouteToMenu(data.routes);
|
||||
this.menusList = transformRouteToList(data.routes);
|
||||
|
||||
const vueRoutes = transformAuthRoutesToVueRoutes(data.routes);
|
||||
vueRoutes.forEach(route => {
|
||||
|
@ -13,6 +13,20 @@ export function transformAuthRoutesToVueRoutes(routes: AuthRoute.Route[]) {
|
||||
return routes.map(route => transformAuthRouteToVueRoute(route)).flat(1);
|
||||
}
|
||||
|
||||
/** 将路由转换成菜单列表 */
|
||||
export function transformRouteToList(routes: AuthRoute.Route[], treeMap: AuthRoute.Route[] = []) {
|
||||
if (routes && routes.length === 0) return [];
|
||||
return routes.reduce((acc, cur) => {
|
||||
if (!cur.meta?.hide) {
|
||||
acc.push(cur);
|
||||
}
|
||||
if (cur.children && cur.children.length > 0) {
|
||||
transformRouteToList(cur.children, treeMap);
|
||||
}
|
||||
return acc;
|
||||
}, treeMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个权限路由转换成vue路由
|
||||
* @param route - 权限路由
|
||||
|
Loading…
Reference in New Issue
Block a user