mirror of
https://github.com/vastxie/99AI.git
synced 2026-04-24 11:14:29 +08:00
v4.3.0
This commit is contained in:
405
admin/src/layouts/components/Search/index.vue
Executable file
405
admin/src/layouts/components/Search/index.vue
Executable file
@@ -0,0 +1,405 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user