|
@@ -0,0 +1,202 @@
|
|
|
+<script lang="ts" setup>
|
|
|
+import { computed, ref, shallowRef } from "vue"
|
|
|
+import { type RouteRecordName, type RouteRecordRaw, useRouter } from "vue-router"
|
|
|
+import { usePermissionStore } from "@/store/modules/permission"
|
|
|
+import SearchResult from "./SearchResult.vue"
|
|
|
+import SearchFooter from "./SearchFooter.vue"
|
|
|
+import { ElMessage, ElScrollbar } from "element-plus"
|
|
|
+import { cloneDeep, debounce } from "lodash-es"
|
|
|
+import { useDevice } from "@/hooks/useDevice"
|
|
|
+import { isExternal } from "@/utils/validate"
|
|
|
+
|
|
|
+/** 控制 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 searchResultRef = ref<InstanceType<typeof SearchResult> | null>(null)
|
|
|
+
|
|
|
+const keyword = ref<string>("")
|
|
|
+const resultList = shallowRef<RouteRecordRaw[]>([])
|
|
|
+const activeRouteName = ref<RouteRecordName | undefined>(undefined)
|
|
|
+/** 是否按下了上键或下键(用于解决和 mouseenter 事件的冲突) */
|
|
|
+const isPressUpOrDown = ref<boolean>(false)
|
|
|
+
|
|
|
+/** 控制搜索对话框宽度 */
|
|
|
+const modalWidth = computed(() => (isMobile.value ? "80vw" : "40vw"))
|
|
|
+/** 树形菜单 */
|
|
|
+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 : undefined
|
|
|
+}, 500)
|
|
|
+
|
|
|
+/** 将树形菜单扁平化为一维数组,用于菜单搜索 */
|
|
|
+const flatTree = (arr: RouteRecordRaw[], result: RouteRecordRaw[] = []) => {
|
|
|
+ arr.forEach((item) => {
|
|
|
+ result.push(item)
|
|
|
+ item.children && flatTree(item.children, result)
|
|
|
+ })
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+/** 关闭搜索对话框 */
|
|
|
+const handleClose = () => {
|
|
|
+ modelValue.value = false
|
|
|
+ // 延时处理防止用户看到重置数据的操作
|
|
|
+ setTimeout(() => {
|
|
|
+ keyword.value = ""
|
|
|
+ resultList.value = []
|
|
|
+ }, 200)
|
|
|
+}
|
|
|
+
|
|
|
+/** 根据下标位置进行滚动 */
|
|
|
+const scrollTo = (index: number) => {
|
|
|
+ if (!searchResultRef.value) return
|
|
|
+ const scrollTop = searchResultRef.value.getScrollTop(index)
|
|
|
+ // 手动控制 el-scrollbar 滚动条滚动,设置滚动条到顶部的距离
|
|
|
+ scrollbarRef.value?.setScrollTop(scrollTop)
|
|
|
+}
|
|
|
+
|
|
|
+/** 键盘上键 */
|
|
|
+const handleUp = () => {
|
|
|
+ isPressUpOrDown.value = true
|
|
|
+ const { length } = resultList.value
|
|
|
+ if (length === 0) return
|
|
|
+ // 获取该 name 在菜单中第一次出现的位置
|
|
|
+ const index = resultList.value.findIndex((item) => item.name === activeRouteName.value)
|
|
|
+ // 如果已处在顶部
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ activeRouteName.value = resultList.value[index - 1].name
|
|
|
+ scrollTo(index - 1)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 键盘下键 */
|
|
|
+const handleDown = () => {
|
|
|
+ isPressUpOrDown.value = true
|
|
|
+ const { length } = resultList.value
|
|
|
+ if (length === 0) return
|
|
|
+ // 获取该 name 在菜单中最后一次出现的位置(可解决遇到连续两个相同 name 导致的下键不能生效的问题)
|
|
|
+ const index = resultList.value.map((item) => item.name).lastIndexOf(activeRouteName.value)
|
|
|
+ // 如果已处在底部
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ activeRouteName.value = resultList.value[index + 1].name
|
|
|
+ scrollTo(index + 1)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 键盘回车键 */
|
|
|
+const handleEnter = () => {
|
|
|
+ const { length } = resultList.value
|
|
|
+ if (length === 0) return
|
|
|
+ const name = activeRouteName.value
|
|
|
+ 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 })
|
|
|
+ } catch {
|
|
|
+ ElMessage.error("该菜单有必填的动态参数,无法通过搜索进入")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ handleClose()
|
|
|
+}
|
|
|
+
|
|
|
+/** 释放上键或下键 */
|
|
|
+const handleReleaseUpOrDown = () => {
|
|
|
+ isPressUpOrDown.value = false
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ v-model="modelValue"
|
|
|
+ @opened="inputRef?.focus()"
|
|
|
+ @closed="inputRef?.blur()"
|
|
|
+ @keydown.up="handleUp"
|
|
|
+ @keydown.down="handleDown"
|
|
|
+ @keydown.enter="handleEnter"
|
|
|
+ @keyup.up.down="handleReleaseUpOrDown"
|
|
|
+ :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" always>
|
|
|
+ <SearchResult
|
|
|
+ ref="searchResultRef"
|
|
|
+ v-model="activeRouteName"
|
|
|
+ :list="resultList"
|
|
|
+ :isPressUpOrDown="isPressUpOrDown"
|
|
|
+ @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>
|