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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 382 additions and 0 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--ant-design" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 1024 1024"><path fill="currentColor" d="M864 170h-60c-4.4 0-8 3.6-8 8v518H310v-73c0-6.7-7.8-10.5-13-6.3l-141.9 112a8 8 0 0 0 0 12.6l141.9 112c5.3 4.2 13 .4 13-6.3v-75h498c35.3 0 64-28.7 64-64V178c0-4.4-3.6-8-8-8z"></path></svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--mdi" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1V7m10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2Z"></path></svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@ -27,6 +27,7 @@ import Notebook from "@iconify-icons/ep/notebook";
import Rank from "@iconify-icons/ep/rank";
import videoPlay from "@iconify-icons/ep/video-play";
import Monitor from "@iconify-icons/ep/monitor";
import Search from "@iconify-icons/ep/search";
addIcon("check", Check);
addIcon("menu", Menu);
addIcon("home-filled", HomeFilled);
@ -52,6 +53,7 @@ addIcon("notebook", Notebook);
addIcon("video-play", videoPlay);
addIcon("rank", Rank);
addIcon("monitor", Monitor);
addIcon("search", Search);
// remixicon
import arrowRightSLine from "@iconify-icons/ri/arrow-right-s-line";
@ -63,6 +65,9 @@ import questionLine from "@iconify-icons/ri/question-line";
import checkboxCircleLine from "@iconify-icons/ri/checkbox-circle-line";
import informationLine from "@iconify-icons/ri/information-line";
import closeCircleLine from "@iconify-icons/ri/close-circle-line";
import arrowUpLine from "@iconify-icons/ri/arrow-up-line";
import arrowDownLine from "@iconify-icons/ri/arrow-down-line";
import bookmark2Line from "@iconify-icons/ri/bookmark-2-line";
addIcon("arrow-right-s-line", arrowRightSLine);
addIcon("arrow-left-s-line", arrowLeftSLine);
addIcon("logout-circle-r-line", logoutCircleRLine);
@ -72,6 +77,9 @@ addIcon("question-line", questionLine);
addIcon("checkbox-circle-line", checkboxCircleLine);
addIcon("information-line", informationLine);
addIcon("close-circle-line", closeCircleLine);
addIcon("arrow-up-line", arrowUpLine);
addIcon("arrow-down-line", arrowDownLine);
addIcon("bookmark-2-line", bookmark2Line);
// Font Awesome 4
import faUser from "@iconify-icons/fa/user";

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" />
<!-- 全屏 -->

View File

@ -0,0 +1,26 @@
import { ref } from "vue";
export default function useBoolean(initValue = false) {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle
};
}

View File

@ -216,6 +216,12 @@
}
}
.search-container {
&:hover {
background: $menuHover;
}
}
.screen-full {
cursor: pointer;