197 lines
6.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import type { ElScrollbar } from "element-plus"
import type { RouteRecordName, RouteRecordRaw } from "vue-router"
import { useDevice } from "@/composables/useDevice"
import { usePermissionStore } from "@/pinia/stores/permission"
import { isExternal } from "@/utils/validate"
import { ElMessage } from "element-plus"
import { cloneDeep, debounce } from "lodash-es"
import { computed, ref, shallowRef } from "vue"
import { useRouter } from "vue-router"
import Footer from "./Footer.vue"
import Result from "./Result.vue"
/** 控制 modal 显隐 */
const modelValue = defineModel<boolean>({ required: true })
const router = useRouter()
const { isMobile } = useDevice()
const inputRef = ref<HTMLInputElement | null>(null)
const scrollbarRef = ref<InstanceType<typeof ElScrollbar> | null>(null)
const resultRef = ref<InstanceType<typeof Result> | null>(null)
const keyword = ref<string>("")
const result = shallowRef<RouteRecordRaw[]>([])
const activeRouteName = ref<RouteRecordName | undefined>(undefined)
/** 是否按下了上键或下键(用于解决和 mouseenter 事件的冲突) */
const isPressUpOrDown = ref<boolean>(false)
/** 控制搜索对话框宽度 */
const modalWidth = computed(() => (isMobile.value ? "80vw" : "40vw"))
/** 树形菜单 */
const menus = computed(() => cloneDeep(usePermissionStore().routes))
/** 搜索(防抖) */
const handleSearch = debounce(() => {
const flatMenus = flatTree(menus.value)
const _keywords = keyword.value.toLocaleLowerCase().trim()
result.value = flatMenus.filter(menu => keyword.value ? menu.meta?.title?.toLocaleLowerCase().includes(_keywords) : false)
// 默认选中搜索结果的第一项
const length = result.value?.length
activeRouteName.value = length > 0 ? result.value[0].name : undefined
}, 500)
/** 将树形菜单扁平化为一维数组,用于菜单搜索 */
function flatTree(arr: RouteRecordRaw[], result: RouteRecordRaw[] = []) {
arr.forEach((item) => {
result.push(item)
item.children && flatTree(item.children, result)
})
return result
}
/** 关闭搜索对话框 */
function handleClose() {
modelValue.value = false
// 延时处理防止用户看到重置数据的操作
setTimeout(() => {
keyword.value = ""
result.value = []
}, 200)
}
/** 根据下标位置进行滚动 */
function scrollTo(index: number) {
if (!resultRef.value) return
const scrollTop = resultRef.value.getScrollTop(index)
// 手动控制 el-scrollbar 滚动条滚动,设置滚动条到顶部的距离
scrollbarRef.value?.setScrollTop(scrollTop)
}
/** 键盘上键 */
function handleUp() {
isPressUpOrDown.value = true
const { length } = result.value
if (length === 0) return
// 获取该 name 在菜单中第一次出现的位置
const index = result.value.findIndex(item => item.name === activeRouteName.value)
// 如果已处在顶部
if (index === 0) {
const bottomName = result.value[length - 1].name
// 如果顶部和底部的 bottomName 相同,且长度大于 1就再跳一个位置可解决遇到首尾两个相同 name 导致的上键不能生效的问题)
if (activeRouteName.value === bottomName && length > 1) {
activeRouteName.value = result.value[length - 2].name
scrollTo(length - 2)
} else {
// 跳转到底部
activeRouteName.value = bottomName
scrollTo(length - 1)
}
} else {
activeRouteName.value = result.value[index - 1].name
scrollTo(index - 1)
}
}
/** 键盘下键 */
function handleDown() {
isPressUpOrDown.value = true
const { length } = result.value
if (length === 0) return
// 获取该 name 在菜单中最后一次出现的位置(可解决遇到连续两个相同 name 导致的下键不能生效的问题)
const index = result.value.map(item => item.name).lastIndexOf(activeRouteName.value)
// 如果已处在底部
if (index === length - 1) {
const topName = result.value[0].name
// 如果底部和顶部的 topName 相同,且长度大于 1就再跳一个位置可解决遇到首尾两个相同 name 导致的下键不能生效的问题)
if (activeRouteName.value === topName && length > 1) {
activeRouteName.value = result.value[1].name
scrollTo(1)
} else {
// 跳转到顶部
activeRouteName.value = topName
scrollTo(0)
}
} else {
activeRouteName.value = result.value[index + 1].name
scrollTo(index + 1)
}
}
/** 键盘回车键 */
function handleEnter() {
const { length } = result.value
if (length === 0) return
const name = activeRouteName.value
const path = result.value.find(item => item.name === name)?.path
if (path && isExternal(path)) return window.open(path, "_blank", "noopener, noreferrer")
if (!name) return ElMessage.warning("无法通过搜索进入该菜单,请为对应的路由设置唯一的 Name")
try {
router.push({ name })
} catch {
return ElMessage.warning("该菜单有必填的动态参数,无法通过搜索进入")
}
handleClose()
}
/** 释放上键或下键 */
function handleReleaseUpOrDown() {
isPressUpOrDown.value = false
}
</script>
<template>
<el-dialog
v-model="modelValue"
:before-close="handleClose"
:width="modalWidth"
top="5vh"
class="search-modal__private"
append-to-body
@opened="inputRef?.focus()"
@closed="inputRef?.blur()"
@keydown.up="handleUp"
@keydown.down="handleDown"
@keydown.enter="handleEnter"
@keyup.up.down="handleReleaseUpOrDown"
>
<el-input ref="inputRef" v-model="keyword" placeholder="搜索菜单" size="large" clearable @input="handleSearch">
<template #prefix>
<SvgIcon name="search" />
</template>
</el-input>
<el-empty v-if="result.length === 0" description="暂无搜索结果" :image-size="100" />
<template v-else>
<p>搜索结果</p>
<el-scrollbar ref="scrollbarRef" max-height="40vh" always>
<Result
ref="resultRef"
v-model="activeRouteName"
:data="result"
:is-press-up-or-down="isPressUpOrDown"
@click="handleEnter"
/>
</el-scrollbar>
</template>
<template #footer>
<Footer :total="result.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-top: var(--el-dialog-padding-primary);
}
}
</style>