Template
1
0
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:
ClariS 2023-08-10 14:48:22 +08:00 committed by GitHub
parent ad9ff59a40
commit ac1b621667
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 389 additions and 2 deletions

View 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>

View 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>

View 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 margin8 + 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>

View 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>

View File

@ -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

View 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

View 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

View 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

View 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
View 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

View File

@ -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" />

View File

@ -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