feat: menu search (#209)

* feat: menu search

* style(layout): 剔除windcss依赖
This commit is contained in:
一万
2022-03-09 13:54:16 +08:00
committed by GitHub
parent 81c4184cc4
commit c9026a45cc
14 changed files with 382 additions and 0 deletions

View File

@@ -2,6 +2,7 @@
import { useI18n } from "vue-i18n";
import { useNav } from "../hooks/nav";
import { useRoute } from "vue-router";
import Search from "./search/index.vue";
import Notice from "./notice/index.vue";
import mixNav from "./sidebar/mixNav.vue";
import avatars from "/@/assets/avatars.jpg";
@@ -58,6 +59,8 @@ function translationEn() {
<mixNav v-if="pureApp.layout === 'mix'" />
<div v-if="pureApp.layout === 'vertical'" class="vertical-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 全屏 -->

View File

@@ -0,0 +1,42 @@
<template>
<div class="search-footer">
<span class="search-footer-item">
<enterOutlined class="icon" />
确认
</span>
<span class="search-footer-item">
<IconifyIconOffline icon="arrow-up-line" class="icon" />
<IconifyIconOffline icon="arrow-down-line" class="icon" />
切换
</span>
<span class="search-footer-item">
<mdiKeyboardEsc class="icon" />
关闭
</span>
</div>
</template>
<script lang="ts" setup>
import enterOutlined from "/@/assets/svg/enter_outlined.svg?component";
import mdiKeyboardEsc from "/@/assets/svg/mdi_keyboard_esc.svg?component";
</script>
<style lang="scss" scoped>
.search-footer {
display: flex;
color: #333;
.search-footer-item {
display: flex;
align-items: center;
margin-right: 14px;
}
.icon {
padding: 2px;
margin-right: 3px;
font-size: 20px;
box-shadow: inset 0 -2px #cdcde6, inset 0 0 1px 1px #fff,
0 1px 2px 1px #1e235a66;
}
}
</style>

View File

@@ -0,0 +1,165 @@
<script lang="ts" setup>
import { useRouter } from "vue-router";
import SearchResult from "./SearchResult.vue";
import SearchFooter from "./SearchFooter.vue";
import { deleteChildren } from "/@/utils/tree";
import { transformI18n } from "/@/plugins/i18n";
import { useDebounceFn, onKeyStroke } from "@vueuse/core";
import { ref, watch, computed, nextTick, shallowRef } from "vue";
import { usePermissionStoreHook } from "/@/store/modules/permission";
interface Props {
/** 弹窗显隐 */
value: boolean;
}
interface Emits {
(e: "update:value", val: boolean): void;
}
const emit = defineEmits<Emits>();
const props = withDefaults(defineProps<Props>(), {});
const router = useRouter();
const keyword = ref("");
const activePath = ref("");
const inputRef = ref<HTMLInputElement | null>(null);
const resultOptions = shallowRef([]);
const handleSearch = useDebounceFn(search, 300);
/** 菜单树形结构 */
const menusData = computed(() => {
return deleteChildren(usePermissionStoreHook().menusTree);
});
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 flatTree(arr) {
const res = [];
function deep(arr) {
arr.forEach(item => {
res.push(item);
item.children && deep(item.children);
});
}
deep(arr);
return res;
}
/** 查询 */
function search() {
const flatMenusData = flatTree(menusData.value);
resultOptions.value = flatMenusData.filter(
menu =>
keyword.value &&
transformI18n(menu.meta?.title, menu.meta?.i18n)
.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;
router.push(activePath.value);
handleClose();
}
onKeyStroke("Enter", handleEnter);
onKeyStroke("ArrowUp", handleUp);
onKeyStroke("ArrowDown", handleDown);
</script>
<template>
<el-dialog top="5vh" v-model="show" :before-close="handleClose">
<el-input
ref="inputRef"
v-model="keyword"
clearable
placeholder="请输入关键词搜索"
@input="handleSearch"
>
<template #prefix>
<el-icon class="el-input__icon">
<IconifyIconOffline icon="search" />
</el-icon>
</template>
</el-input>
<div class="search-result-container">
<el-empty v-if="resultOptions.length === 0" description="暂无搜索结果" />
<SearchResult
v-else
v-model:value="activePath"
:options="resultOptions"
@click="handleEnter"
/>
</div>
<template #footer>
<SearchFooter />
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.search-result-container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="result">
<template v-for="item in options" :key="item.path">
<div
class="result-item"
:style="{
background:
item?.path === active ? useEpThemeStoreHook().epThemeColor : '',
color: item.path === active ? '#fff' : ''
}"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<component
:is="useRenderIcon(item.meta?.icon ?? 'bookmark-2-line')"
></component>
<span class="result-item-title">{{ $t(item.meta?.title) }}</span>
<enterOutlined />
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { useEpThemeStoreHook } from "/@/store/modules/epTheme";
import { useRenderIcon } from "/@/components/ReIcon/src/hooks";
import enterOutlined from "/@/assets/svg/enter_outlined.svg?component";
interface optionsItem {
path: string;
meta?: {
icon?: string;
title?: string;
};
}
interface Props {
value: string;
options: Array<optionsItem>;
}
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);
}
});
/** 鼠标移入 */
async function handleMouse(item) {
active.value = item.path;
}
function handleTo() {
emit("enter");
}
</script>
<style lang="scss" scoped>
.result {
padding-bottom: 12px;
&-item {
display: flex;
align-items: center;
height: 56px;
margin-top: 8px;
padding: 14px;
border-radius: 4px;
background: #e5e7eb;
cursor: pointer;
&-title {
display: flex;
flex: 1;
margin-left: 5px;
}
}
}
</style>

View File

@@ -0,0 +1,3 @@
import SearchModal from "./SearchModal.vue";
export { SearchModal };

View File

@@ -0,0 +1 @@
export type RouteList = AuthRoute.Route;

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { SearchModal } from "./components";
import useBoolean from "../../hooks/useBoolean";
const { bool: show, toggle } = useBoolean();
function handleSearch() {
toggle();
}
</script>
<template>
<div class="search-container" @click="handleSearch">
<IconifyIconOffline icon="search" />
</div>
<SearchModal v-model:value="show" />
</template>
<style lang="scss" scoped>
.search-container {
display: flex;
align-items: center;
justify-content: center;
height: 48px;
width: 40px;
cursor: pointer;
&:hover {
background: #f6f6f6;
}
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useNav } from "../../hooks/nav";
import Search from "../search/index.vue";
import Notice from "../notice/index.vue";
import { templateRef } from "@vueuse/core";
import SidebarItem from "./sidebarItem.vue";
@@ -91,6 +92,8 @@ function translationEn() {
/>
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 全屏 -->

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import Search from "../search/index.vue";
import Notice from "../notice/index.vue";
import { useNav } from "../../hooks/nav";
import { templateRef } from "@vueuse/core";
@@ -136,6 +137,8 @@ function translationEn() {
</el-menu-item>
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<Search />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 全屏 -->