204 lines
6.5 KiB
Vue
Raw Normal View History

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