429 lines
10 KiB
Plaintext
429 lines
10 KiB
Plaintext
<template>
|
|
<basicModal
|
|
@register="register"
|
|
:bodyStyle="getBodyStyle"
|
|
ref="modalRef"
|
|
wrapClassName="app-search"
|
|
>
|
|
<div class="app-search-card">
|
|
<div class="app-search-card-input">
|
|
<a-input
|
|
ref="searchInputRef"
|
|
v-model:value="searchKeyword"
|
|
:loading="loading"
|
|
allowClear
|
|
placeholder="请输入关键词搜索"
|
|
size="large"
|
|
@input="handleSearch"
|
|
>
|
|
<template #prefix>
|
|
<span v-if="loading"><LoadingOutlined /></span>
|
|
<span v-else><SearchOutlined /></span>
|
|
</template>
|
|
</a-input>
|
|
</div>
|
|
|
|
<div class="app-search-card-result" :class="{ 'search-result-dark': settingStore.darkTheme }">
|
|
<div v-if="!loading && !searchResult.length" class="no-result">
|
|
<a-empty v-if="!loading" :image="simpleImage" />
|
|
<a-spin v-else size="small" />
|
|
</div>
|
|
<div v-else-if="loading" class="no-result">
|
|
<a-spin size="small" />
|
|
</div>
|
|
<ul v-else class="result-ul">
|
|
<li
|
|
v-for="(item, index) in searchResult"
|
|
:key="item.key"
|
|
:class="{ 'result-ul-li-on': index === activeIndex }"
|
|
:data-index="index"
|
|
class="result-ul-li"
|
|
@click="handleEnter"
|
|
@mouseenter="handleMouseenter"
|
|
>
|
|
<a href="javascript:;">
|
|
<div class="result-ul-li-icon">
|
|
<InteractionOutlined />
|
|
</div>
|
|
<div class="result-ul-li-content"> {{ item.title }}</div>
|
|
<div class="result-ul-li-action">
|
|
<EnterOutlined />
|
|
</div>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="app-search-card-footer">
|
|
<ul class="commands">
|
|
<li>
|
|
<span class="commands-icon">
|
|
<EnterOutlined />
|
|
</span>
|
|
<span>确认</span>
|
|
</li>
|
|
<li>
|
|
<span class="mr-2 commands-icon">
|
|
<ArrowUpOutlined />
|
|
</span>
|
|
<span class="commands-icon">
|
|
<ArrowDownOutlined />
|
|
</span>
|
|
<span>切换</span>
|
|
</li>
|
|
<li>
|
|
<span class="commands-icon">
|
|
<CloseOutlined />
|
|
</span>
|
|
<span>ESC关闭</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
</basicModal>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import type { Menu } from '@/router/types';
|
|
import { ref, unref, onBeforeMount, nextTick, computed } from 'vue';
|
|
import { basicModal, useModal } from '@/components/Modal';
|
|
import {
|
|
EnterOutlined,
|
|
ArrowUpOutlined,
|
|
ArrowDownOutlined,
|
|
CloseOutlined,
|
|
InteractionOutlined,
|
|
LoadingOutlined,
|
|
SearchOutlined,
|
|
} from '@ant-design/icons-vue';
|
|
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
|
|
import { getMenus } from '@/router/menus';
|
|
import { filter } from '@/utils/helper/treeHelper';
|
|
import { useGo } from '@/hooks/web/usePage';
|
|
import { Empty } from 'ant-design-vue';
|
|
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
|
|
|
const loading = ref(false);
|
|
const searchInputRef = ref();
|
|
const searchKeyword = ref('');
|
|
const searchResult = ref<SearchResult[]>([]);
|
|
const activeIndex = ref(-1);
|
|
let menuList: Menu[] = [];
|
|
const go = useGo();
|
|
const settingStore = useProjectSettingStore();
|
|
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
|
|
|
|
interface SearchResult {
|
|
title: string;
|
|
path: string;
|
|
icon?: string;
|
|
key: string;
|
|
}
|
|
|
|
const [register, { openModal, closeModal }] = useModal({
|
|
title: '搜索页面',
|
|
width: 608,
|
|
wrapClassName: 'app-search',
|
|
keyboard: false,
|
|
});
|
|
|
|
const getBodyStyle = computed(() => {
|
|
return {
|
|
background: settingStore.darkTheme ? 'rgb(31,31,31)' : 'rgb(239, 239, 245)',
|
|
};
|
|
});
|
|
|
|
onBeforeMount(async () => {
|
|
const list = await getMenus();
|
|
menuList = list;
|
|
});
|
|
|
|
function show() {
|
|
openModal();
|
|
nextTick(() => {
|
|
searchInputRef.value.focus();
|
|
});
|
|
}
|
|
|
|
function hide() {
|
|
closeModal();
|
|
}
|
|
|
|
const handleSearch = useDebounceFn(search, 200);
|
|
|
|
function search() {
|
|
loading.value = true;
|
|
searchKeyword.value = searchKeyword.value.trim();
|
|
if (!searchKeyword.value) {
|
|
searchResult.value = [];
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
const reg = createSearchReg(unref(searchKeyword));
|
|
const filterMenu = filter(menuList, (item) => {
|
|
return reg.test(item.title) && !item.meta?.hidden;
|
|
});
|
|
searchResult.value = handlerSearchResult(filterMenu, reg);
|
|
activeIndex.value = 0;
|
|
nextTick(() => {
|
|
loading.value = false;
|
|
});
|
|
}
|
|
|
|
function handlerSearchResult(filterMenu: Menu[], reg: RegExp, parent?: Menu) {
|
|
const ret: SearchResult[] = [];
|
|
filterMenu.forEach((item) => {
|
|
const { title, path, key, icon, children, meta } = item;
|
|
if (!meta?.hidden && reg.test(title) && !children?.length) {
|
|
ret.push({
|
|
title: parent?.title ? `${parent.title} > ${title}` : title,
|
|
path: parent?.path ? parent.path : path,
|
|
icon,
|
|
key,
|
|
});
|
|
}
|
|
if (!meta?.hideChildrenInMenu && Array.isArray(children) && children.length) {
|
|
ret.push(...handlerSearchResult(children as any, reg, item));
|
|
}
|
|
});
|
|
return ret;
|
|
}
|
|
|
|
// Translate special characters
|
|
function transform(c: string) {
|
|
const code: string[] = ['$', '(', ')', '*', '+', '.', '[', ']', '?', '\\', '^', '{', '}', '|'];
|
|
return code.includes(c) ? `\\${c}` : c;
|
|
}
|
|
|
|
function createSearchReg(key: string) {
|
|
const keys = [...key].map((item) => transform(item));
|
|
const str = ['', ...keys, ''].join('.*');
|
|
return new RegExp(str);
|
|
}
|
|
|
|
function handleMouseenter(e: any) {
|
|
const index = e.target.dataset.index;
|
|
activeIndex.value = Number(index);
|
|
}
|
|
|
|
function handleClose() {
|
|
searchResult.value = [];
|
|
searchKeyword.value = '';
|
|
hide();
|
|
}
|
|
|
|
async function handleEnter() {
|
|
if (!searchResult.value.length) {
|
|
return;
|
|
}
|
|
const result = unref(searchResult);
|
|
const index = unref(activeIndex);
|
|
if (result.length === 0 || index < 0) {
|
|
return;
|
|
}
|
|
const to = result[index];
|
|
handleClose();
|
|
await nextTick();
|
|
go(to.path);
|
|
}
|
|
|
|
// 按方向上键
|
|
function handleUp() {
|
|
if (!searchResult.value.length) return;
|
|
activeIndex.value--;
|
|
if (activeIndex.value < 0) {
|
|
activeIndex.value = searchResult.value.length - 1;
|
|
}
|
|
}
|
|
|
|
// 按方向下键
|
|
function handleDown() {
|
|
if (!searchResult.value.length) return;
|
|
activeIndex.value++;
|
|
if (activeIndex.value > searchResult.value.length - 1) {
|
|
activeIndex.value = 0;
|
|
}
|
|
}
|
|
|
|
// 回车搜索
|
|
onKeyStroke('Enter', handleEnter);
|
|
|
|
// 按方向上键
|
|
onKeyStroke('ArrowUp', handleUp);
|
|
|
|
// 按方向下键
|
|
onKeyStroke('ArrowDown', handleDown);
|
|
|
|
// 键盘 esc 取消弹窗
|
|
onKeyStroke('Escape', handleClose);
|
|
|
|
defineExpose({
|
|
show,
|
|
});
|
|
</script>
|
|
<style lang="less">
|
|
.app-search .ant-modal .ant-modal-content .ant-modal-footer {
|
|
padding: 2px 0;
|
|
}
|
|
</style>
|
|
<style lang="less" scoped>
|
|
.app-search {
|
|
position: fixed;
|
|
top: 60px;
|
|
left: 50%;
|
|
margin-left: -235px;
|
|
|
|
&-card {
|
|
width: 100%;
|
|
padding: 0;
|
|
background: var(--color);
|
|
box-shadow: var(--box-shadow);
|
|
|
|
&-input {
|
|
margin-top: 6px;
|
|
}
|
|
|
|
:deep(.n-card .n-card__footer) {
|
|
padding: 0;
|
|
}
|
|
|
|
&-result {
|
|
.no-result {
|
|
font-size: 0.9em;
|
|
margin: 0 auto;
|
|
padding: 36px 0;
|
|
text-align: center;
|
|
width: 80%;
|
|
|
|
p {
|
|
color: #969faf;
|
|
}
|
|
}
|
|
|
|
.result-ul {
|
|
list-style: none;
|
|
margin: 14px 0 0 0;
|
|
padding: 0;
|
|
|
|
:deep(.n-scrollbar .n-scrollbar-container .n-scrollbar-content) {
|
|
max-height: 640px;
|
|
}
|
|
|
|
&-li {
|
|
border-radius: 4px;
|
|
display: flex;
|
|
padding-bottom: 8px;
|
|
position: relative;
|
|
|
|
a {
|
|
display: flex;
|
|
align-items: center;
|
|
background: #fff;
|
|
border-radius: 4px;
|
|
padding: 0 12px;
|
|
width: 100%;
|
|
color: rgb(51, 54, 57);
|
|
border-bottom: 1px solid var(--border-color);
|
|
|
|
.n-icon {
|
|
color: #969faf;
|
|
}
|
|
}
|
|
|
|
&-content {
|
|
align-items: center;
|
|
color: var(--text-color);
|
|
display: flex;
|
|
flex-direction: row;
|
|
height: 56px;
|
|
padding-left: 6px;
|
|
padding-right: 12px;
|
|
flex: 1;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
&-icon {
|
|
font-size: 18px;
|
|
}
|
|
|
|
&-on {
|
|
a {
|
|
background-color: #2d8cf0!important;
|
|
color: #fff;
|
|
|
|
.n-icon {
|
|
color: #fff;
|
|
}
|
|
|
|
.result-ul-li-content {
|
|
color: #fff;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.search-result-dark {
|
|
.result-ul {
|
|
&-li {
|
|
a {
|
|
background: #282828;
|
|
color: #b3b3b3;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
&-footer {
|
|
align-items: center;
|
|
background: var(--color);
|
|
border-radius: 0 0 8px 8px;
|
|
box-shadow: var(--box-shadow);
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
height: 44px;
|
|
padding: 0 14px;
|
|
position: relative;
|
|
user-select: none;
|
|
width: 100%;
|
|
|
|
.commands {
|
|
display: flex;
|
|
align-items: center;
|
|
|
|
li {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-right: 14px;
|
|
padding-left: 1px;
|
|
|
|
span {
|
|
color: #969faf;
|
|
}
|
|
}
|
|
|
|
&-icon {
|
|
align-items: center;
|
|
background: linear-gradient(-225deg, var(--color), var(--color));
|
|
border-radius: 2px;
|
|
box-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff,
|
|
0 1px 2px 1px rgba(30, 35, 90, 0.4);
|
|
display: flex;
|
|
height: 18px;
|
|
justify-content: center;
|
|
margin-right: 0.4em;
|
|
padding-bottom: 2px;
|
|
width: 20px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.light-item-bg {
|
|
background: rgb(239, 239, 245);
|
|
}
|
|
</style>
|