mirror of
https://gitee.com/yiming_chang/vue-pure-admin.git
synced 2024-12-05 20:58:12 +08:00
feat: menu search (#209)
* feat: menu search * style(layout): 剔除windcss依赖
This commit is contained in:
parent
81c4184cc4
commit
c9026a45cc
1
src/assets/svg/enter_outlined.svg
Normal file
1
src/assets/svg/enter_outlined.svg
Normal 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 |
1
src/assets/svg/mdi_keyboard_esc.svg
Normal file
1
src/assets/svg/mdi_keyboard_esc.svg
Normal 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 |
@ -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";
|
||||
|
@ -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" />
|
||||
<!-- 全屏 -->
|
||||
|
42
src/layout/components/search/components/SearchFooter.vue
Normal file
42
src/layout/components/search/components/SearchFooter.vue
Normal 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>
|
165
src/layout/components/search/components/SearchModal.vue
Normal file
165
src/layout/components/search/components/SearchModal.vue
Normal 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>
|
90
src/layout/components/search/components/SearchResult.vue
Normal file
90
src/layout/components/search/components/SearchResult.vue
Normal 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>
|
3
src/layout/components/search/components/index.ts
Normal file
3
src/layout/components/search/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import SearchModal from "./SearchModal.vue";
|
||||
|
||||
export { SearchModal };
|
1
src/layout/components/search/components/types.ts
Normal file
1
src/layout/components/search/components/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type RouteList = AuthRoute.Route;
|
30
src/layout/components/search/index.vue
Normal file
30
src/layout/components/search/index.vue
Normal 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>
|
@ -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" />
|
||||
<!-- 全屏 -->
|
||||
|
@ -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" />
|
||||
<!-- 全屏 -->
|
||||
|
26
src/layout/hooks/useBoolean.ts
Normal file
26
src/layout/hooks/useBoolean.ts
Normal 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
|
||||
};
|
||||
}
|
@ -216,6 +216,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
&:hover {
|
||||
background: $menuHover;
|
||||
}
|
||||
}
|
||||
|
||||
.screen-full {
|
||||
cursor: pointer;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user