mirror of
https://github.com/un-pany/v3-admin-vite.git
synced 2025-04-20 19:09:21 +08:00
feat: 新增菜单搜索功能 (#96)
This commit is contained in:
parent
ad9ff59a40
commit
ac1b621667
59
src/components/SearchMenu/SearchFooter.vue
Normal file
59
src/components/SearchMenu/SearchFooter.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { useAppStore } from "@/store/modules/app"
|
||||
import { DeviceEnum } from "@/constants/app-key"
|
||||
|
||||
interface Props {
|
||||
total: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
const isMobile = computed(() => appStore.device === DeviceEnum.Mobile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-footer">
|
||||
<template v-if="!isMobile">
|
||||
<span class="search-footer-item">
|
||||
<SvgIcon name="keyboard-enter" />
|
||||
<span>确认</span>
|
||||
</span>
|
||||
<span class="search-footer-item">
|
||||
<SvgIcon name="keyboard-up" />
|
||||
<SvgIcon name="keyboard-down" />
|
||||
<span>切换</span>
|
||||
</span>
|
||||
<span class="search-footer-item">
|
||||
<SvgIcon name="keyboard-esc" />
|
||||
<span>关闭</span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="search-footer-total">共 {{ props.total }} 项</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-footer {
|
||||
display: flex;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
.svg-icon {
|
||||
margin-right: 5px;
|
||||
padding: 2px;
|
||||
font-size: 20px;
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
&-total {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
168
src/components/SearchMenu/SearchModal.vue
Normal file
168
src/components/SearchMenu/SearchModal.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef } from "vue"
|
||||
import { type RouteRecordName, type RouteRecordRaw, useRouter } from "vue-router"
|
||||
import { useAppStore } from "@/store/modules/app"
|
||||
import { usePermissionStore } from "@/store/modules/permission"
|
||||
import SearchResult from "./SearchResult.vue"
|
||||
import SearchFooter from "./SearchFooter.vue"
|
||||
import { ElScrollbar } from "element-plus"
|
||||
import { cloneDeep, debounce } from "lodash-es"
|
||||
import { DeviceEnum } from "@/constants/app-key"
|
||||
|
||||
interface Props {
|
||||
/** 控制 modal 显隐 */
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [boolean]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const scrollbarRef = ref<InstanceType<typeof ElScrollbar> | null>(null)
|
||||
const searchResultRef = ref<InstanceType<typeof SearchResult> | null>(null)
|
||||
|
||||
const keyword = ref("")
|
||||
const resultList = shallowRef<RouteRecordRaw[]>([])
|
||||
const activeRouteName = ref<RouteRecordName>("")
|
||||
|
||||
/** 控制搜索对话框宽度 */
|
||||
const modalWidth = computed(() => (appStore.device === DeviceEnum.Mobile ? "80vw" : "40vw"))
|
||||
/** 控制搜索对话框显隐 */
|
||||
const modalVisible = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value: boolean) {
|
||||
emit("update:modelValue", value)
|
||||
}
|
||||
})
|
||||
/** 树形菜单 */
|
||||
const menusData = computed(() => cloneDeep(usePermissionStore().routes))
|
||||
|
||||
/** 搜索(防抖) */
|
||||
const handleSearch = debounce(() => {
|
||||
const flatMenusData = flatTree(menusData.value)
|
||||
resultList.value = flatMenusData.filter((menu) =>
|
||||
keyword.value ? menu.meta?.title?.toLocaleLowerCase().includes(keyword.value.toLocaleLowerCase().trim()) : false
|
||||
)
|
||||
// 默认选中搜索结果的第一项
|
||||
const length = resultList.value?.length
|
||||
activeRouteName.value = length > 0 ? resultList.value[0].name! : ""
|
||||
}, 500)
|
||||
|
||||
/** 将树形菜单扁平化为一维数组,用于菜单搜索 */
|
||||
const flatTree = (arr: RouteRecordRaw[], result: RouteRecordRaw[] = []) => {
|
||||
arr.forEach((item) => {
|
||||
result.push(item)
|
||||
item.children && flatTree(item.children, result)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 关闭搜索对话框 */
|
||||
const handleClose = () => {
|
||||
modalVisible.value = false
|
||||
// 延时处理防止用户看到重置数据的操作
|
||||
setTimeout(() => {
|
||||
keyword.value = ""
|
||||
resultList.value = []
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** 根据下标位置进行滚动 */
|
||||
const scrollTo = (index: number) => {
|
||||
const scrollTop = searchResultRef.value?.getScrollTop(index)
|
||||
// 手动控制 el-scrollbar 滚动条滚动,设置滚动条到顶部的距离
|
||||
scrollTop && scrollbarRef.value?.setScrollTop(scrollTop)
|
||||
}
|
||||
|
||||
/** 键盘上键 */
|
||||
const handleUp = () => {
|
||||
const { length } = resultList.value
|
||||
if (length === 0) return
|
||||
const index = resultList.value.findIndex((item) => item.name === activeRouteName.value)
|
||||
if (index === 0) {
|
||||
// 如果已处在顶部,跳转到底部
|
||||
activeRouteName.value = resultList.value[length - 1].name!
|
||||
scrollTo(resultList.value.length - 1)
|
||||
} else {
|
||||
activeRouteName.value = resultList.value[index - 1].name!
|
||||
scrollTo(index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 键盘下键 */
|
||||
const handleDown = () => {
|
||||
const { length } = resultList.value
|
||||
if (length === 0) return
|
||||
const index = resultList.value.findIndex((item) => item.name === activeRouteName.value)
|
||||
if (index === length - 1) {
|
||||
// 如果已处在底部,跳转到顶部
|
||||
activeRouteName.value = resultList.value[0].name!
|
||||
scrollTo(0)
|
||||
} else {
|
||||
activeRouteName.value = resultList.value[index + 1].name!
|
||||
scrollTo(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 键盘回车键 */
|
||||
const handleEnter = () => {
|
||||
const { length } = resultList.value
|
||||
if (length === 0 || !activeRouteName.value) return
|
||||
router.push({ name: activeRouteName.value })
|
||||
handleClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="modalVisible"
|
||||
@opened="inputRef?.focus()"
|
||||
@closed="inputRef?.blur()"
|
||||
@keydown.up="handleUp"
|
||||
@keydown.down="handleDown"
|
||||
@keydown.enter="handleEnter"
|
||||
:before-close="handleClose"
|
||||
:width="modalWidth"
|
||||
top="5vh"
|
||||
class="search-modal__private"
|
||||
append-to-body
|
||||
>
|
||||
<el-input ref="inputRef" v-model="keyword" @input="handleSearch" placeholder="搜索菜单" size="large" clearable>
|
||||
<template #prefix>
|
||||
<SvgIcon name="search" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-empty v-if="resultList.length === 0" description="暂无搜索结果" :image-size="100" />
|
||||
<template v-else>
|
||||
<p>搜索结果</p>
|
||||
<el-scrollbar ref="scrollbarRef" max-height="40vh">
|
||||
<SearchResult ref="searchResultRef" v-model="activeRouteName" :list="resultList" @click="handleEnter" />
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SearchFooter :total="resultList.length" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.search-modal__private {
|
||||
.svg-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
.el-dialog__footer {
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
padding: var(--el-dialog-padding-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
119
src/components/SearchMenu/SearchResult.vue
Normal file
119
src/components/SearchMenu/SearchResult.vue
Normal file
@ -0,0 +1,119 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, getCurrentInstance, onBeforeMount, onBeforeUnmount, onMounted, ref } from "vue"
|
||||
import { type RouteRecordName, type RouteRecordRaw } from "vue-router"
|
||||
|
||||
interface Props {
|
||||
modelValue: RouteRecordName
|
||||
list: RouteRecordRaw[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [RouteRecordName]
|
||||
}>()
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const scrollbarHeight = ref<number>(0)
|
||||
|
||||
/** 选中的菜单 */
|
||||
const activeRouteName = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value: RouteRecordName) {
|
||||
emit("update:modelValue", value)
|
||||
}
|
||||
})
|
||||
|
||||
/** 菜单的样式 */
|
||||
const itemStyle = (item: RouteRecordRaw) => {
|
||||
const flag = item.name === activeRouteName.value
|
||||
return {
|
||||
background: flag ? "var(--el-color-primary)" : "",
|
||||
color: flag ? "#fff" : ""
|
||||
}
|
||||
}
|
||||
|
||||
/** 鼠标移入 */
|
||||
const handleMouseenter = (item: RouteRecordRaw) => {
|
||||
activeRouteName.value = item.name!
|
||||
}
|
||||
|
||||
/** 计算滚动可视区高度 */
|
||||
const getScrollbarHeight = () => {
|
||||
// el-scrollbar max-height="40vh"
|
||||
scrollbarHeight.value = Number((window.innerHeight * 0.4).toFixed(1))
|
||||
}
|
||||
|
||||
/** 根据下标计算到顶部的距离 */
|
||||
const getScrollTop = (index: number) => {
|
||||
const currentInstance = instance?.proxy?.$refs[`resultItemRef${index}`] as HTMLDivElement[]
|
||||
if (!currentInstance) return 0
|
||||
const currentRef = currentInstance[0]
|
||||
const scrollTop = currentRef.offsetTop + 128 // 128 = 两个 result-item (56 + 56 = 112)高度与上下 margin(8 + 8 = 16)大小之和
|
||||
return scrollTop > scrollbarHeight.value ? scrollTop - scrollbarHeight.value : 0
|
||||
}
|
||||
|
||||
/** 在组件挂载前添加窗口大小变化事件监听器 */
|
||||
onBeforeMount(() => {
|
||||
window.addEventListener("resize", getScrollbarHeight)
|
||||
})
|
||||
|
||||
/** 在组件挂载时立即计算滚动可视区高度 */
|
||||
onMounted(() => {
|
||||
getScrollbarHeight()
|
||||
})
|
||||
|
||||
/** 在组件卸载前移除窗口大小变化事件监听器 */
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", getScrollbarHeight)
|
||||
})
|
||||
|
||||
defineExpose({ getScrollTop })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 外层 div 不能删除,是用来接收父组件 click 事件的 -->
|
||||
<div>
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="index"
|
||||
:ref="`resultItemRef${index}`"
|
||||
class="result-item"
|
||||
:style="itemStyle(item)"
|
||||
@mouseenter="handleMouseenter(item)"
|
||||
>
|
||||
<SvgIcon v-if="item.meta?.svgIcon" :name="item.meta.svgIcon" />
|
||||
<component v-else-if="item.meta?.elIcon" :is="item.meta.elIcon" class="el-icon" />
|
||||
<span class="result-item-title">
|
||||
{{ item.meta?.title }}
|
||||
</span>
|
||||
<SvgIcon v-if="activeRouteName === item.name" name="keyboard-enter" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 15px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
.svg-icon {
|
||||
min-width: 1em;
|
||||
font-size: 18px;
|
||||
}
|
||||
.el-icon {
|
||||
width: 1em;
|
||||
font-size: 18px;
|
||||
}
|
||||
&-title {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
29
src/components/SearchMenu/index.vue
Normal file
29
src/components/SearchMenu/index.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue"
|
||||
import SearchModal from "./SearchModal.vue"
|
||||
|
||||
/** 控制 modal 显隐 */
|
||||
const modalVisible = ref<boolean>(false)
|
||||
/** 打开 modal */
|
||||
const handleOpen = () => {
|
||||
modalVisible.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-tooltip effect="dark" content="搜索菜单" placement="bottom">
|
||||
<SvgIcon name="search" @click="handleOpen" />
|
||||
</el-tooltip>
|
||||
<SearchModal v-model="modalVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.svg-icon {
|
||||
font-size: 20px;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -18,6 +18,8 @@ export interface LayoutSettings {
|
||||
showThemeSwitch: boolean
|
||||
/** 是否显示全屏按钮 */
|
||||
showScreenfull: boolean
|
||||
/** 是否显示搜索按钮 */
|
||||
showSearchMenu: boolean
|
||||
/** 是否缓存标签栏 */
|
||||
cacheTagsView: boolean
|
||||
/** 是否显示灰色模式 */
|
||||
@ -35,6 +37,7 @@ export const layoutSettings: LayoutSettings = getConfigLayout() ?? {
|
||||
showNotify: true,
|
||||
showThemeSwitch: true,
|
||||
showScreenfull: true,
|
||||
showSearchMenu: true,
|
||||
cacheTagsView: false,
|
||||
showGreyMode: false,
|
||||
showColorWeakness: false
|
||||
|
1
src/icons/svg/keyboard-down.svg
Normal file
1
src/icons/svg/keyboard-down.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3"></path></g></svg>
|
After Width: | Height: | Size: 223 B |
1
src/icons/svg/keyboard-enter.svg
Normal file
1
src/icons/svg/keyboard-enter.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3"></path></g></svg>
|
After Width: | Height: | Size: 241 B |
1
src/icons/svg/keyboard-esc.svg
Normal file
1
src/icons/svg/keyboard-esc.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956"></path></g></svg>
|
After Width: | Height: | Size: 694 B |
1
src/icons/svg/keyboard-up.svg
Normal file
1
src/icons/svg/keyboard-up.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3"></path></g></svg>
|
After Width: | Height: | Size: 223 B |
1
src/icons/svg/search.svg
Normal file
1
src/icons/svg/search.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg t="1691398959507" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2431" width="200" height="200"><path d="M862.609 816.955L726.44 680.785l-0.059-0.056a358.907 358.907 0 0 0 56.43-91.927c18.824-44.507 28.369-91.767 28.369-140.467 0-48.701-9.545-95.96-28.369-140.467-18.176-42.973-44.19-81.56-77.319-114.689-33.13-33.129-71.717-59.144-114.69-77.32-44.507-18.825-91.767-28.37-140.467-28.37-48.701 0-95.96 9.545-140.467 28.37-42.973 18.176-81.56 44.19-114.689 77.32-33.13 33.129-59.144 71.717-77.32 114.689-18.825 44.507-28.37 91.767-28.37 140.467 0 48.7 9.545 95.96 28.37 140.467 18.176 42.974 44.19 81.561 77.32 114.69 33.129 33.129 71.717 59.144 114.689 77.319 44.507 18.824 91.767 28.369 140.467 28.369 48.7 0 95.96-9.545 140.467-28.369 32.78-13.864 62.997-32.303 90.197-54.968 0.063 0.064 0.122 0.132 0.186 0.195l136.169 136.17c6.25 6.25 14.438 9.373 22.628 9.373 8.188 0 16.38-3.125 22.627-9.372 12.496-12.496 12.496-32.758 0-45.254z m-412.274-69.466c-79.907 0-155.031-31.118-211.534-87.62-56.503-56.503-87.62-131.627-87.62-211.534s31.117-155.031 87.62-211.534c56.502-56.503 131.626-87.62 211.534-87.62s155.031 31.117 211.534 87.62c56.502 56.502 87.62 131.626 87.62 211.534s-31.118 155.031-87.62 211.534c-56.503 56.502-131.627 87.62-211.534 87.62z" p-id="2432"></path></svg>
|
After Width: | Height: | Size: 1.3 KiB |
@ -9,9 +9,10 @@ import { UserFilled } from "@element-plus/icons-vue"
|
||||
import Hamburger from "../Hamburger/index.vue"
|
||||
import Breadcrumb from "../Breadcrumb/index.vue"
|
||||
import Sidebar from "../Sidebar/index.vue"
|
||||
import Notify from "@/components/Notify/index.vue"
|
||||
import ThemeSwitch from "@/components/ThemeSwitch/index.vue"
|
||||
import Screenfull from "@/components/Screenfull/index.vue"
|
||||
import Notify from "@/components/Notify/index.vue"
|
||||
import SearchMenu from "@/components/SearchMenu/index.vue"
|
||||
import { DeviceEnum } from "@/constants/app-key"
|
||||
|
||||
const router = useRouter()
|
||||
@ -20,7 +21,7 @@ const settingsStore = useSettingsStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { sidebar, device } = storeToRefs(appStore)
|
||||
const { layoutMode, showNotify, showThemeSwitch, showScreenfull } = storeToRefs(settingsStore)
|
||||
const { layoutMode, showNotify, showThemeSwitch, showScreenfull, showSearchMenu } = storeToRefs(settingsStore)
|
||||
|
||||
const isTop = computed(() => layoutMode.value === "top")
|
||||
const isMobile = computed(() => device.value === DeviceEnum.Mobile)
|
||||
@ -43,6 +44,7 @@ const logout = () => {
|
||||
<Breadcrumb v-if="!isTop || isMobile" class="breadcrumb" />
|
||||
<Sidebar v-if="isTop && !isMobile" class="sidebar" />
|
||||
<div class="right-menu">
|
||||
<SearchMenu v-if="showSearchMenu" class="right-menu-item" />
|
||||
<Screenfull v-if="showScreenfull" class="right-menu-item" />
|
||||
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
|
||||
<Notify v-if="showNotify" class="right-menu-item" />
|
||||
|
@ -17,6 +17,7 @@ const {
|
||||
showNotify,
|
||||
showThemeSwitch,
|
||||
showScreenfull,
|
||||
showSearchMenu,
|
||||
cacheTagsView,
|
||||
showGreyMode,
|
||||
showColorWeakness
|
||||
@ -30,6 +31,7 @@ const switchSettings = {
|
||||
显示消息通知: showNotify,
|
||||
显示切换主题按钮: showThemeSwitch,
|
||||
显示全屏按钮: showScreenfull,
|
||||
显示搜索按钮: showSearchMenu,
|
||||
是否缓存标签栏: cacheTagsView,
|
||||
显示灰色模式: showGreyMode,
|
||||
显示色弱模式: showColorWeakness
|
||||
|
Loading…
x
Reference in New Issue
Block a user