Files
99AI/admin/src/layouts/components/Search/index.vue
vastxie 86e2eecc1f v4.3.0
2025-05-31 02:28:46 +08:00

406 lines
13 KiB
Vue
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import {
Dialog,
DialogDescription,
DialogPanel,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
import { cloneDeep } from 'lodash-es';
import hotkeys from 'hotkeys-js';
import Breadcrumb from '../Breadcrumb/index.vue';
import BreadcrumbItem from '../Breadcrumb/item.vue';
import { resolveRoutePath } from '@/utils';
import eventBus from '@/utils/eventBus';
import useSettingsStore from '@/store/modules/settings';
import useMenuStore from '@/store/modules/menu';
import type { Menu } from '@/types/global';
defineOptions({
name: 'Search',
});
const overlayTransitionClass = ref({
enter: 'ease-in-out duration-500',
enterFrom: 'opacity-0',
enterTo: 'opacity-100',
leave: 'ease-in-out duration-500',
leaveFrom: 'opacity-100',
leaveTo: 'opacity-0',
});
const transitionClass = computed(() => {
return {
enter: 'ease-out duration-300',
enterFrom: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
enterTo: 'opacity-100 translate-y-0 lg-scale-100',
leave: 'ease-in duration-200',
leaveFrom: 'opacity-100 translate-y-0 lg-scale-100',
leaveTo: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
};
});
const router = useRouter();
const settingsStore = useSettingsStore();
const menuStore = useMenuStore();
interface listTypes {
path: string;
icon?: string;
title?: string | (() => string);
link?: string;
breadcrumb: {
title?: string | (() => string);
}[];
}
const isShow = ref(false);
const searchInput = ref('');
const sourceList = ref<listTypes[]>([]);
const actived = ref(-1);
const searchInputRef = ref();
const searchResultRef = ref<OverlayScrollbarsComponentRef>();
const searchResultItemRef = ref<HTMLElement[]>([]);
onBeforeUpdate(() => {
searchResultItemRef.value = [];
});
const resultList = computed(() => {
let result = [];
result = sourceList.value.filter((item) => {
let flag = false;
if (item.title) {
if (typeof item.title === 'function') {
if (item.title().includes(searchInput.value)) {
flag = true;
}
} else {
if (item.title.includes(searchInput.value)) {
flag = true;
}
}
}
if (item.path.includes(searchInput.value)) {
flag = true;
}
if (
item.breadcrumb.some((b) => {
if (typeof b.title === 'function') {
if (b.title().includes(searchInput.value)) {
return true;
}
} else {
if (b.title?.includes(searchInput.value)) {
return true;
}
}
return false;
})
) {
flag = true;
}
return flag;
});
return result;
});
watch(
() => isShow.value,
(val) => {
if (val) {
searchInput.value = '';
actived.value = -1;
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
hotkeys('up', keyUp);
hotkeys('down', keyDown);
hotkeys('enter', keyEnter);
} else {
hotkeys.unbind('up', keyUp);
hotkeys.unbind('down', keyDown);
hotkeys.unbind('enter', keyEnter);
}
},
);
watch(
() => resultList.value,
() => {
actived.value = -1;
handleScroll();
},
);
onMounted(() => {
eventBus.on('global-search-toggle', () => {
if (!isShow.value) {
initSourceList();
}
isShow.value = !isShow.value;
});
hotkeys('alt+s', (e) => {
if (
settingsStore.settings.toolbar.navSearch &&
settingsStore.settings.navSearch.enableHotkeys
) {
e.preventDefault();
initSourceList();
isShow.value = true;
}
});
hotkeys('esc', (e) => {
if (
settingsStore.settings.toolbar.navSearch &&
settingsStore.settings.navSearch.enableHotkeys
) {
e.preventDefault();
isShow.value = false;
}
});
initSourceList();
});
function initSourceList() {
sourceList.value = [];
menuStore.allMenus.forEach((item) => {
getSourceListByMenus(item.children);
});
}
function hasChildren(item: Menu.recordRaw) {
let flag = true;
if (item.children?.every((i) => i.meta?.menu === false)) {
flag = false;
}
return flag;
}
function getSourceListByMenus(
arr: Menu.recordRaw[],
basePath?: string,
icon?: string,
breadcrumb?: { title?: string | (() => string) }[],
) {
arr.forEach((item) => {
if (item.meta?.menu !== false) {
const breadcrumbTemp = cloneDeep(breadcrumb) || [];
if (item.children && hasChildren(item)) {
breadcrumbTemp.push({
title: item.meta?.title,
});
getSourceListByMenus(
item.children,
resolveRoutePath(basePath, item.path),
item.meta?.icon ?? icon,
breadcrumbTemp,
);
} else {
breadcrumbTemp.push({
title: item.meta?.title,
});
sourceList.value.push({
path: resolveRoutePath(basePath, item.path),
icon: item.meta?.icon ?? icon,
title: item.meta?.title,
link: item.meta?.link,
breadcrumb: breadcrumbTemp,
});
}
}
});
}
function keyUp() {
if (resultList.value.length) {
actived.value -= 1;
if (actived.value < 0) {
actived.value = resultList.value.length - 1;
}
handleScroll();
}
}
function keyDown() {
if (resultList.value.length) {
actived.value += 1;
if (actived.value > resultList.value.length - 1) {
actived.value = 0;
}
handleScroll();
}
}
function keyEnter() {
if (actived.value !== -1) {
searchResultItemRef.value
.find((item) => Number.parseInt(item.dataset.index!) === actived.value)
?.click();
}
}
function handleScroll() {
if (searchResultRef.value) {
const contentDom = searchResultRef.value.osInstance()!.elements().content;
let scrollTo = 0;
if (actived.value !== -1) {
scrollTo = contentDom.scrollTop;
const activedOffsetTop =
searchResultItemRef.value.find(
(item) => Number.parseInt(item.dataset.index!) === actived.value,
)?.offsetTop ?? 0;
const activedClientHeight =
searchResultItemRef.value.find(
(item) => Number.parseInt(item.dataset.index!) === actived.value,
)?.clientHeight ?? 0;
const searchScrollTop = contentDom.scrollTop;
const searchClientHeight = contentDom.clientHeight;
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight;
} else if (activedOffsetTop <= searchScrollTop) {
scrollTo = activedOffsetTop;
}
}
contentDom.scrollTo({
top: scrollTo,
});
}
}
function pageJump(path: listTypes['path'], link: listTypes['link']) {
if (link) {
window.open(link, '_blank');
} else {
router.push(path);
}
isShow.value = false;
}
</script>
<template>
<TransitionRoot as="template" :show="isShow">
<Dialog
:initial-focus="searchInputRef"
class="fixed inset-0 z-2000 flex"
@close="isShow && eventBus.emit('global-search-toggle')"
>
<TransitionChild as="template" v-bind="overlayTransitionClass">
<div
class="fixed inset-0 bg-stone-200/75 backdrop-blur-sm transition-opacity dark-bg-stone-8/75"
/>
</TransitionChild>
<div class="fixed inset-0">
<div class="h-full flex items-end justify-center p-4 text-center lg-items-center">
<TransitionChild as="template" v-bind="transitionClass">
<DialogPanel
class="relative h-full max-h-4/5 w-full flex flex-col text-left lg-max-w-2xl"
>
<div
class="flex flex-col overflow-y-auto rounded-xl bg-white shadow-xl dark-bg-stone-8"
>
<div class="flex items-center px-4 py-3" border-b="~ solid stone-2 dark-stone-7">
<SvgIcon name="i-ep:search" :size="18" class="text-stone-5" />
<input
ref="searchInputRef"
v-model="searchInput"
placeholder="搜索页面支持标题、URL模糊查询"
class="w-full border-0 rounded-md bg-transparent px-3 text-base text-dark dark-text-white focus-outline-none placeholder-stone-4 dark-placeholder-stone-5"
@keydown.esc="eventBus.emit('global-search-toggle')"
@keydown.up.prevent="keyUp"
@keydown.down.prevent="keyDown"
@keydown.enter.prevent="keyEnter"
/>
</div>
<DialogDescription class="relative m-0 of-y-hidden">
<OverlayScrollbarsComponent
ref="searchResultRef"
:options="{ scrollbars: { autoHide: 'leave', autoHideDelay: 300 } }"
defer
class="h-full"
>
<template v-if="resultList.length > 0">
<a
v-for="(item, index) in resultList"
ref="searchResultItemRef"
:key="item.path"
class="flex cursor-pointer items-center"
:class="{ 'bg-stone-2/40 dark-bg-stone-7/40': index === actived }"
:data-index="index"
@click="pageJump(item.path, item.link)"
@mouseover="actived = index"
>
<SvgIcon
v-if="item.icon"
:name="item.icon"
:size="20"
class="basis-16 transition"
:class="{ 'scale-120 text-ui-primary': index === actived }"
/>
<div
class="flex flex-1 flex-col gap-1 truncate px-4 py-3"
border-l="~ solid stone-2 dark-stone-7"
>
<div class="truncate text-base font-bold">
{{
(typeof item.title === 'function' ? item.title() : item.title) ??
'[ 无标题 ]'
}}
</div>
<Breadcrumb v-if="item.breadcrumb.length" class="truncate">
<BreadcrumbItem
v-for="(bc, bcIndex) in item.breadcrumb"
:key="bcIndex"
class="text-xs"
>
{{
(typeof bc.title === 'function' ? bc.title() : bc.title) ??
'[ 无标题 ]'
}}
</BreadcrumbItem>
</Breadcrumb>
</div>
</a>
</template>
<template v-else>
<div flex="center col" py-6 text-stone-5>
<SvgIcon name="i-tabler:mood-empty" :size="40" />
<p m-2 text-base>没有找到你想要的</p>
</div>
</template>
</OverlayScrollbarsComponent>
</DialogDescription>
<div
v-if="settingsStore.mode === 'pc'"
class="flex justify-between px-4 py-3"
border-t="~ solid stone-2 dark-stone-7"
>
<div class="flex gap-8">
<div class="inline-flex items-center gap-1 text-xs">
<HKbd>
<SvgIcon name="i-ion:md-return-left" :size="14" />
</HKbd>
<span>访问</span>
</div>
<div class="inline-flex items-center gap-1 text-xs">
<HKbd>
<SvgIcon name="i-ant-design:caret-up-filled" :size="14" />
</HKbd>
<HKbd>
<SvgIcon name="i-ant-design:caret-down-filled" :size="14" />
</HKbd>
<span>切换</span>
</div>
</div>
<div
v-if="settingsStore.settings.navSearch.enableHotkeys"
class="inline-flex items-center gap-1 text-xs"
>
<HKbd> ESC </HKbd>
<span>退出</span>
</div>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>