mirror of
https://github.com/vbenjs/vue-vben-admin.git
synced 2025-08-27 14:13:40 +08:00
feat: add search page
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import AppLocalePicker from './src/AppLocalePicker.vue';
|
||||
import AppLogo from './src/AppLogo.vue';
|
||||
import AppProvider from './src/AppProvider.vue';
|
||||
import { withInstall } from '../util';
|
||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
||||
|
||||
withInstall(AppLocalePicker, AppLogo, AppProvider);
|
||||
export const AppLocalePicker = createAsyncComponent(() => import('./src/AppLocalePicker.vue'));
|
||||
export const AppProvider = createAsyncComponent(() => import('./src/AppProvider.vue'));
|
||||
export const AppSearch = createAsyncComponent(() => import('./src/search/AppSearch.vue'));
|
||||
export const AppLogo = createAsyncComponent(() => import('./src/AppLogo.vue'));
|
||||
|
||||
withInstall(AppLocalePicker, AppLogo, AppProvider, AppSearch);
|
||||
|
||||
export { useAppProviderContext } from './src/useAppContext';
|
||||
|
||||
export { AppLocalePicker, AppLogo, AppProvider };
|
||||
|
55
src/components/Application/src/search/AppSearch.vue
Normal file
55
src/components/Application/src/search/AppSearch.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div :class="prefixCls" v-if="getShowSearch" @click="handleSearch">
|
||||
<Tooltip>
|
||||
<template #title> {{ t('component.app.search') }} </template>
|
||||
<SearchOutlined />
|
||||
</Tooltip>
|
||||
|
||||
<transition name="zoom-fade" mode="out-in">
|
||||
<AppSearchModal @close="handleClose" v-if="showModal" />
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import AppSearchModal from './AppSearchModal.vue';
|
||||
import { useHeaderSetting } from '/@/hooks/setting/useHeaderSetting';
|
||||
import { SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppSearch',
|
||||
components: { AppSearchModal, Tooltip, SearchOutlined },
|
||||
setup() {
|
||||
const showModal = ref(false);
|
||||
const { prefixCls } = useDesign('app-search');
|
||||
const { getShowSearch } = useHeaderSetting();
|
||||
const { t } = useI18n();
|
||||
|
||||
function handleSearch() {
|
||||
showModal.value = true;
|
||||
}
|
||||
return {
|
||||
t,
|
||||
prefixCls,
|
||||
showModal,
|
||||
getShowSearch,
|
||||
handleClose: () => {
|
||||
showModal.value = false;
|
||||
},
|
||||
handleSearch,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@import (reference) '../../../../design/index.less';
|
||||
@prefix-cls: ~'@{namespace}-app-search';
|
||||
|
||||
.@{prefix-cls} {
|
||||
padding: 0 10px;
|
||||
}
|
||||
</style>
|
76
src/components/Application/src/search/AppSearchFooter.vue
Normal file
76
src/components/Application/src/search/AppSearchFooter.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div :class="`${prefixCls}`">
|
||||
<span :class="`${prefixCls}__item`">
|
||||
<g-icon icon="ant-design:enter-outlined" />
|
||||
</span>
|
||||
<span>{{ t('component.app.toSearch') }}</span>
|
||||
|
||||
<span :class="`${prefixCls}__item`">
|
||||
<g-icon icon="bi:arrow-up" />
|
||||
</span>
|
||||
<span :class="`${prefixCls}__item`">
|
||||
<g-icon icon="bi:arrow-down" />
|
||||
</span>
|
||||
<span>{{ t('component.app.toNavigate') }}</span>
|
||||
<span :class="`${prefixCls}__item`">
|
||||
<g-icon icon="mdi:keyboard-esc" />
|
||||
</span>
|
||||
<span>{{ t('component.app.toClose') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
export default defineComponent({
|
||||
name: 'AppSearchFooter',
|
||||
components: {},
|
||||
setup() {
|
||||
const { prefixCls } = useDesign('app-search-footer');
|
||||
const { t } = useI18n();
|
||||
return {
|
||||
prefixCls,
|
||||
t,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@import (reference) '../../../../design/index.less';
|
||||
@prefix-cls: ~'@{namespace}-app-search-footer';
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: rgb(255 255 255);
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 -1px 0 0 #e0e3e8, 0 -3px 6px 0 rgba(69, 98, 155, 0.12);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
padding-bottom: 2px;
|
||||
margin-right: 0.4em;
|
||||
background: linear-gradient(-225deg, #d5dbe4, #f8f8f8);
|
||||
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);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:nth-child(2),
|
||||
&:nth-child(3),
|
||||
&:nth-child(6) {
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
198
src/components/Application/src/search/AppSearchModal.vue
Normal file
198
src/components/Application/src/search/AppSearchModal.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div :class="prefixCls" @click.stop>
|
||||
<ClickOutSide @clickOutside="handleClose">
|
||||
<div :class="`${prefixCls}-content`">
|
||||
<a-input
|
||||
:class="`${prefixCls}-input`"
|
||||
:placeholder="t('component.app.search')"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<div :class="`${prefixCls}-not-data`" v-show="getIsNotData">
|
||||
{{ t('component.app.searchNotData') }}
|
||||
</div>
|
||||
<ul :class="`${prefixCls}-list`" v-show="!getIsNotData" ref="scrollWrap">
|
||||
<li
|
||||
:ref="setRefs(index)"
|
||||
v-for="(item, index) in searchResult"
|
||||
:key="item.path"
|
||||
:data-index="index"
|
||||
@mouseenter="handleMouseenter"
|
||||
@click="handleEnter"
|
||||
:class="[
|
||||
`${prefixCls}-list__item`,
|
||||
{
|
||||
[`${prefixCls}-list__item--active`]: activeIndex === index,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div :class="`${prefixCls}-list__item-icon`">
|
||||
<g-icon :icon="item.icon || 'mdi:form-select'" :size="20" />
|
||||
</div>
|
||||
<div :class="`${prefixCls}-list__item-text`">{{ item.name }}</div>
|
||||
<div :class="`${prefixCls}-list__item-enter`">
|
||||
<g-icon icon="ant-design:enter-outlined" :size="20" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<AppSearchFooter />
|
||||
</div>
|
||||
</ClickOutSide>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, unref, ref } from 'vue';
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useRefs } from '/@/hooks/core/useRefs';
|
||||
import { useMenuSearch } from './useMenuSearch';
|
||||
import { SearchOutlined } from '@ant-design/icons-vue';
|
||||
import AppSearchFooter from './AppSearchFooter.vue';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
import { ClickOutSide } from '/@/components/ClickOutSide';
|
||||
export default defineComponent({
|
||||
name: 'AppSearchModal',
|
||||
components: { SearchOutlined, ClickOutSide, AppSearchFooter },
|
||||
emits: ['close'],
|
||||
setup(_, { emit }) {
|
||||
const scrollWrap = ref<ElRef>(null);
|
||||
const { prefixCls } = useDesign('app-search-modal');
|
||||
const { t } = useI18n();
|
||||
const [refs, setRefs] = useRefs();
|
||||
|
||||
const {
|
||||
handleSearch,
|
||||
searchResult,
|
||||
keyword,
|
||||
activeIndex,
|
||||
handleEnter,
|
||||
handleMouseenter,
|
||||
} = useMenuSearch(refs, scrollWrap, emit);
|
||||
|
||||
const getIsNotData = computed(() => {
|
||||
return !keyword || unref(searchResult).length === 0;
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
prefixCls,
|
||||
handleSearch,
|
||||
searchResult,
|
||||
activeIndex,
|
||||
getIsNotData,
|
||||
handleEnter,
|
||||
setRefs,
|
||||
scrollWrap,
|
||||
handleMouseenter,
|
||||
handleClose: () => {
|
||||
emit('close');
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@import (reference) '../../../../design/index.less';
|
||||
@prefix-cls: ~'@{namespace}-app-search-modal';
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 50px;
|
||||
// background: #656c85cc;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
justify-content: center;
|
||||
// backdrop-filter: blur(2px);
|
||||
|
||||
&-content {
|
||||
position: relative;
|
||||
width: 532px;
|
||||
// padding: 14px;
|
||||
margin: 0 auto auto auto;
|
||||
background: #f5f6f7;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 1px 1px 0 0 hsla(0, 0%, 100%, 0.5), 0 3px 8px 0 #555a64;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-input {
|
||||
width: calc(100% - 28px);
|
||||
height: 56px;
|
||||
margin: 14px 14px 0 14px;
|
||||
font-size: 1.5em;
|
||||
color: #1c1e21;
|
||||
|
||||
span[role='img'] {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&-not-data {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
font-size: 0.9;
|
||||
color: rgb(150 159 175);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-list {
|
||||
max-height: 472px;
|
||||
padding: 0 14px;
|
||||
padding-bottom: 20px;
|
||||
margin: 0 auto;
|
||||
margin-top: 14px;
|
||||
overflow: auto;
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 14px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: @text-color-base;
|
||||
cursor: pointer;
|
||||
// background: @primary-color;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px 0 #d4d9e1;
|
||||
align-items: center;
|
||||
|
||||
&--active {
|
||||
color: #fff;
|
||||
background: @primary-color;
|
||||
|
||||
.@{prefix-cls}-list__item-enter {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-enter {
|
||||
width: 30px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
173
src/components/Application/src/search/useMenuSearch.ts
Normal file
173
src/components/Application/src/search/useMenuSearch.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { ref, onBeforeUnmount, onBeforeMount, unref, Ref } from 'vue';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
import { getMenus } from '/@/router/menus';
|
||||
import type { Menu } from '/@/router/types';
|
||||
import { filter, forEach } from '/@/utils/helper/treeHelper';
|
||||
import { useDebounce } from '/@/hooks/core/useDebounce';
|
||||
import { useGo } from '/@/hooks/web/usePage';
|
||||
import { useScrollTo } from '/@/hooks/event/useScrollTo';
|
||||
|
||||
export interface SearchResult {
|
||||
name: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const enum KeyCodeEnum {
|
||||
UP = 38,
|
||||
DOWN = 40,
|
||||
ENTER = 13,
|
||||
ESC = 27,
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function useMenuSearch(refs: Ref<HTMLElement[]>, scrollWrap: Ref<ElRef>, emit: EmitType) {
|
||||
const searchResult = ref<SearchResult[]>([]);
|
||||
const keyword = ref('');
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
let menuList: Menu[] = [];
|
||||
|
||||
const { t } = useI18n();
|
||||
const go = useGo();
|
||||
const [handleSearch] = useDebounce(search, 200);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const list = await getMenus();
|
||||
menuList = cloneDeep(list);
|
||||
forEach(menuList, (item) => {
|
||||
item.name = t(item.name);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', registerKeyDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', registerKeyDown);
|
||||
});
|
||||
|
||||
function search(e: ChangeEvent) {
|
||||
e?.stopPropagation();
|
||||
const key = e.target.value;
|
||||
keyword.value = key.trim();
|
||||
if (!key) {
|
||||
searchResult.value = [];
|
||||
return;
|
||||
}
|
||||
const reg = createSearchReg(unref(keyword));
|
||||
const filterMenu = filter(menuList, (item) => {
|
||||
return reg.test(item.name);
|
||||
});
|
||||
searchResult.value = handlerSearchResult(filterMenu, reg);
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
|
||||
function handlerSearchResult(filterMenu: Menu[], reg: RegExp, parent?: Menu) {
|
||||
const ret: SearchResult[] = [];
|
||||
|
||||
filterMenu.forEach((item) => {
|
||||
const { name, path, icon, children } = item;
|
||||
if (reg.test(name) && !children?.length) {
|
||||
ret.push({
|
||||
name: parent?.name ? `${parent.name} > ${name}` : name,
|
||||
path,
|
||||
icon,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(children) && children.length) {
|
||||
ret.push(...handlerSearchResult(children, reg, item));
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
function handleMouseenter(e: ChangeEvent) {
|
||||
const index = e.target.dataset.index;
|
||||
activeIndex.value = Number(index);
|
||||
}
|
||||
|
||||
function handleUp() {
|
||||
if (!searchResult.value.length) return;
|
||||
activeIndex.value--;
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = searchResult.value.length - 1;
|
||||
}
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
function handleDown() {
|
||||
if (!searchResult.value.length) return;
|
||||
activeIndex.value++;
|
||||
if (activeIndex.value > searchResult.value.length - 1) {
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const refList = unref(refs);
|
||||
if (!refList || !Array.isArray(refList) || refList.length === 0 || !unref(scrollWrap)) return;
|
||||
|
||||
const index = unref(activeIndex);
|
||||
const currentRef = refList[index];
|
||||
if (!currentRef) return;
|
||||
const wrapEl = unref(scrollWrap);
|
||||
if (!wrapEl) return;
|
||||
const scrollHeight = currentRef.offsetTop + currentRef.offsetHeight;
|
||||
const wrapHeight = wrapEl.offsetHeight;
|
||||
const { start } = useScrollTo({
|
||||
el: wrapEl,
|
||||
duration: 100,
|
||||
to: scrollHeight - wrapHeight,
|
||||
});
|
||||
start();
|
||||
}
|
||||
|
||||
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();
|
||||
go(to.path);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function registerKeyDown(e: KeyboardEvent) {
|
||||
const keyCode = window.event ? e.keyCode : e.which;
|
||||
switch (keyCode) {
|
||||
case KeyCodeEnum.UP:
|
||||
handleUp();
|
||||
break;
|
||||
case KeyCodeEnum.DOWN:
|
||||
handleDown();
|
||||
break;
|
||||
case KeyCodeEnum.ENTER:
|
||||
handleEnter();
|
||||
break;
|
||||
case KeyCodeEnum.ESC:
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { handleSearch, searchResult, keyword, activeIndex, handleMouseenter, handleEnter };
|
||||
}
|
@@ -1,7 +1,6 @@
|
||||
import Authority from './src/index.vue';
|
||||
|
||||
import { withInstall } from '../util';
|
||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
||||
|
||||
export const Authority = createAsyncComponent(() => import('./src/index.vue'));
|
||||
|
||||
withInstall(Authority);
|
||||
|
||||
export { Authority };
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import BasicArrow from './src/BasicArrow.vue';
|
||||
import BasicHelp from './src/BasicHelp.vue';
|
||||
import BasicTitle from './src/BasicTitle.vue';
|
||||
|
||||
import { withInstall } from '../util';
|
||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
||||
|
||||
export const BasicArrow = createAsyncComponent(() => import('./src/BasicArrow.vue'));
|
||||
export const BasicHelp = createAsyncComponent(() => import('./src/BasicHelp.vue'));
|
||||
export const BasicTitle = createAsyncComponent(() => import('./src/BasicTitle.vue'));
|
||||
|
||||
withInstall(BasicArrow, BasicHelp, BasicTitle);
|
||||
|
||||
export { BasicArrow, BasicHelp, BasicTitle };
|
||||
|
Reference in New Issue
Block a user