Vue3+ElementPlus实现可拖拽/吸附/搜索/收起展开的浮动菜单组件
在开发后台管理系统时,我们经常会用到浮动菜单来快速访问某些功能。本篇文章将分享一个基于 Vue3 + ElementPlus 实现的浮动菜单组件,支持拖拽移动、边缘吸附、二级菜单展开、菜单搜索过滤、视频弹窗等交互效果,极大提升了用户操作的便捷性与美观性。
效果预览
- 悬浮按钮支持全屏拖拽移动
- 贴边时自动收缩为小浮标
- 点击展开二级菜单,支持搜索过滤
- 支持在菜单项上点击视频icon预览操作视频
- 自带吸附动画与滚动提示
父组件(App.vue)
## 子组件(FloatingMenu.vue)```javascript import { ref, computed, onMounted, onUnmounted, watch, nextTick } from \'vue\'import { useRoute, useRouter } from \'vue-router\'import { ElMessage } from \'element-plus\'import OperateVideoDialog from \'@/components/popup/OperateVideoDialog.vue\'import { getVideoUrl } from \'@/utils/operateVideo\';const props = defineProps({ maxItemsBeforeScroll: { type: Number, default: 8 }, allowedMenuIds: { type: Array, default: () => [], validator: value => value.every(id => Number.isInteger(id)) }})const route = useRoute()const router = useRouter()const floatingNav = ref(null)const isMenuVisible = ref(false)const isDragging = ref(false)const searchQuery = ref(\'\')const startPos = ref({ x: 0, y: 0 })const dragStartTime = ref(0)const navPos = ref({ x: window.innerWidth - 200, y: window.innerHeight / 2 - 100})const videoModal = ref(null)const videoUrl = ref(\"\")const showOperateVisible = ref(false);const isDocked = ref(false)// 监听路由变化,自动关闭菜单watch(() => route.path, () => { isMenuVisible.value = false searchQuery.value = \'\'})// 从 sessionStorage 获取菜单const getMenus = () => { try { const menus = JSON.parse(sessionStorage.getItem(\'menus\')) || [] return menus } catch (e) { console.error(\'菜单解析失败:\', e) return [] }}// 处理菜单数据const allMenus = ref(getMenus())const topLevelMenus = computed(() => { return allMenus.value .filter(menu => menu.menu_level === 1) .map(menu => ({ ...menu, child: Array.isArray(menu.child) ? menu.child : [] }))})// 当前菜单const currentTopMenu = computed(() => { const currentPath = route.path.split(\'?\')[0].split(\'#\')[0]; // 根据传入的allowedMenuIds筛选一级菜单 const validTopMenus = topLevelMenus.value.filter(menu => { const menuId = parseInt(menu.id); return props.allowedMenuIds.includes(menuId); }); // 匹配二级菜单 for (const topMenu of validTopMenus) { const matchedSubMenu = (topMenu.child || []).find(subMenu => { const subMenuPath = subMenu.index || subMenu.router; return subMenuPath && currentPath === subMenuPath; }); if (matchedSubMenu) { return validTopMenus.find(menu => menu.id === matchedSubMenu.level_pre); } } // 如果没有匹配的二级菜单,尝试精确匹配一级菜单 return validTopMenus.find(topMenu => { const topMenuPath = topMenu.router || topMenu.index; return topMenuPath && currentPath === topMenuPath; }) || null;});// 是否显示浮标const shouldShowFloatingMenu = computed(() => { try { if (!currentTopMenu.value) return false; const menuId = parseInt(currentTopMenu.value.id); return menuId >= 1 && menuId { try { return currentTopMenu.value?.child || [] } catch (e) { console.error(\'获取子菜单出错:\', e) return [] }})// 搜索过滤const filteredSubMenus = computed(() => { try { if (!searchQuery.value) return currentSubMenus.value const query = searchQuery.value.toLowerCase() return currentSubMenus.value.filter(item => item.menu_name.toLowerCase().includes(query) || (item.remark && item.remark.toLowerCase().includes(query)) ) } catch (e) { console.error(\'菜单搜索出错:\', e) return currentSubMenus.value }})// 是否需要显示搜索框const hasSearch = computed(() => currentSubMenus.value.length > 10)// 是否需要显示滚动提示const showScrollHint = computed(() => filteredSubMenus.value.length > props.maxItemsBeforeScroll)const menuDirection = computed(() => { const threshold = window.innerWidth / 2 return navPos.value.x { if (!isDocked.value) return {} const nearLeft = navPos.value.x item.index && route.path.startsWith(item.index)// 导航功能const navigateTo = (item) => { try { if (item.index) { router.push(item.index) isMenuVisible.value = false searchQuery.value = \'\' } } catch (e) { console.error(\'菜单跳转出错:\', e) isMenuVisible.value = false }}// 切换菜单const toggleMenu = () => { isMenuVisible.value = !isMenuVisible.value if (isMenuVisible.value) { searchQuery.value = \'\' }}const showVideo = async (item) => { try { videoUrl.value = await getVideoUrl(item.index || \"\") showOperateVisible.value = true nextTick(() => { toggleMenu() videoModal.value.open() }) } catch (e) { ElMessage.warning(e.message) showOperateVisible.value = false }}const closeOperateVideoDialog = () => { videoUrl.value = \"\" showOperateVisible.value = false}// 处理鼠标按下事件const handleMouseDown = (e) => { try { e.preventDefault() if (isDocked.value) { // 吸附状态,点击恢复为正常浮标,不做拖动 isDocked.value = false navPos.value.x = navPos.value.x { // 如果移动距离超过阈值,开始拖拽 const deltaX = Math.abs(e.clientX - (startPos.value.x + navPos.value.x)) const deltaY = Math.abs(e.clientY - (startPos.value.y + navPos.value.y)) if ((deltaX > 5 || deltaY > 5) && !isDragging.value) { isDragging.value = true isMenuVisible.value = false } if (isDragging.value) { const maxX = window.innerWidth - 60 const maxY = window.innerHeight - 60 navPos.value = { x: Math.max(0, Math.min(maxX, e.clientX - startPos.value.x)), y: Math.max(0, Math.min(maxY, e.clientY - startPos.value.y)) } } } // const onUp = () => { // const clickDuration = Date.now() - dragStartTime.value // // 如果没有拖拽且点击时间短,则切换菜单 // if (!isDragging.value && clickDuration < 200) { // toggleMenu() // } // if (isDragging.value) { // // 贴边吸附 // // const threshold = window.innerWidth / 2 // // navPos.value.x = navPos.value.x { const clickDuration = Date.now() - dragStartTime.value if (!isDragging.value && clickDuration < 200) { if (isDocked.value) { isDocked.value = false navPos.value.x = navPos.value.x <= window.innerWidth / 2 ? 0 : window.innerWidth - 60 } else { toggleMenu() } } if (isDragging.value) { const edgeThreshold = 20 const nearLeft = navPos.value.x = window.innerWidth - 60 - edgeThreshold if (nearLeft || nearRight) { isDocked.value = true navPos.value.x = nearLeft ? 0 : window.innerWidth - 32 } else { sessionStorage.setItem(\'floatingNavPos\', JSON.stringify(navPos.value)) } } isDragging.value = false document.removeEventListener(\'mousemove\', onMove) document.removeEventListener(\'mouseup\', onUp) } document.addEventListener(\'mousemove\', onMove) document.addEventListener(\'mouseup\', onUp) } catch (e) { console.error(\'拖拽操作出错:\', e) isDragging.value = false }}// 样式计算const navStyle = computed(() => ({ left: `${navPos.value.x}px`, top: `${navPos.value.y}px`, \'--active-color\': isActiveColor.value, \'--active-color-light\': isActiveColor.value + \'20\'}))// 获取激活菜单的颜色const isActiveColor = computed(() => { const activeItem = currentSubMenus.value.find(item => isActive(item)) return activeItem ? \'#10b981\' : \'#6366f1\'})// 初始化位置const initPosition = () => { const savedPos = sessionStorage.getItem(\'floatingNavPos\') if (savedPos) { try { const pos = JSON.parse(savedPos) navPos.value = { x: Math.min(pos.x, window.innerWidth - 60), y: Math.min(pos.y, window.innerHeight - 60) } } catch (e) { console.error(\'位置解析失败:\', e) } }}// 窗口大小调整const handleResize = () => { try { navPos.value = { x: Math.min(navPos.value.x, window.innerWidth - 60), y: Math.min(navPos.value.y, window.innerHeight - 60) } } catch (e) { console.error(\'窗口调整大小出错:\', e) }}// 点击外部关闭菜单const handleClickOutside = (e) => { if (isMenuVisible.value && !floatingNav.value?.contains(e.target)) { isMenuVisible.value = false }}onMounted(() => { initPosition() window.addEventListener(\'resize\', handleResize) document.addEventListener(\'click\', handleClickOutside)})onUnmounted(() => { window.removeEventListener(\'resize\', handleResize) document.removeEventListener(\'click\', handleClickOutside)}).floating-nav { position: fixed; z-index: 9999; /** transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); */ user-select: none;}.nav-trigger { position: relative; display: flex; align-items: center; justify-content: center; width: 64px; height: 64px; background: linear-gradient(135deg, var(--active-color), rgba(99, 102, 241, 0.8)); color: white; border-radius: 50%; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2); cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden;}.nav-trigger::before { content: \'\'; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), transparent); border-radius: 50%; pointer-events: none;}.nav-trigger:hover { transform: translateY(-2px) scale(1.05); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16), 0 8px 24px rgba(99, 102, 241, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3);}.nav-trigger.active { transform: translateY(-1px) scale(1.02); background: linear-gradient(135deg, #ef4444, #dc2626); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16), 0 8px 24px rgba(239, 68, 68, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3);}.nav-trigger.dragging { cursor: grabbing; transform: scale(1.1); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2), 0 8px 32px rgba(99, 102, 241, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.3);}.nav-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 2;}.nav-trigger.active .nav-icon { transform: rotate(90deg);}.nav-icon svg { width: 100%; height: 100%; fill: currentColor; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));}.nav-ripple { position: absolute; top: 50%; left: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.3); transform: translate(-50%, -50%); pointer-events: none; transition: all 0.6s ease-out;}.nav-trigger:active .nav-ripple { width: 120px; height: 120px; opacity: 0;}.nav-trigger.docked { width: 32px; height: 64px; background: rgba(99, 102, 241, 0.9); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); transition: all 0.3s ease; display: flex; align-items: center;}.dock-icon { width: 16px; height: 16px;}.dock-icon svg { width: 100%; height: 100%; fill: white; transform: rotate(0deg); transition: transform 0.3s;}/* 自动旋转箭头指向 */.floating-nav[style*=\"left: 0px\"] .dock-icon svg { transform: rotate(0deg);}.floating-nav[style*=\"left:\"]:not([style*=\"left: 0px\"]) .dock-icon svg { transform: rotate(180deg);}.nav-pulse { position: absolute; top: -4px; left: -4px; right: -4px; bottom: -4px; border-radius: 50%; background: linear-gradient(135deg, var(--active-color), rgba(99, 102, 241, 0.3)); animation: pulse 3s ease-in-out infinite; z-index: -1;}@keyframes pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.7; } 100% { transform: scale(1); opacity: 1; }}.submenu-panel { position: absolute; right: 0; bottom: calc(100% + 16px); width: 300px; max-height: 420px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); border-radius: 16px; box-shadow: 0 20px 64px rgba(0, 0, 0, 0.12), 0 8px 32px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(255, 255, 255, 0.5); overflow: hidden; border: 1px solid rgba(229, 231, 235, 0.3);}.submenu-panel.left { right: calc(100% + 16px);}.submenu-panel.right { left: calc(100% + 16px);}.panel-header { padding: 10px; background: linear-gradient(135deg, #f8fafc, #f1f5f9); border-bottom: 1px solid rgba(229, 231, 235, 0.3);}.panel-header h3 { font-size: 18px; font-weight: 700; color: #1e293b; background: linear-gradient(135deg, #1e293b, #475569); -webkit-background-clip: text; -webkit-text-fill-color: transparent;}.search-box { margin-top: 16px;}.search-input-wrapper { position: relative; display: flex; align-items: center;}.search-icon { position: absolute; left: 14px; width: 16px; height: 16px; fill: #64748b; pointer-events: none; z-index: 1;}.search-input-wrapper input { width: 100%; padding: 12px 16px 12px 40px; border: 1px solid rgba(209, 213, 219, 0.5); border-radius: 10px; font-size: 14px; background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(8px); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); outline: none; color: #374151;}.search-input-wrapper input:focus { border-color: var(--active-color); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); background: rgba(255, 255, 255, 0.95);}.menu-scroll-container { max-height: calc(70vh - 160px); overflow-y: auto; padding: 12px 0;}.menu-item { padding: 0; margin: 6px 16px; cursor: pointer; border-radius: 12px; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border: 1px solid transparent; overflow: hidden; position: relative;}.menu-item::before { content: \'\'; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1), transparent); transition: left 0.5s ease;}.menu-item:hover::before { left: 100%;}.menu-item:hover { background: linear-gradient(135deg, #f8fafc, #f1f5f9); border-color: rgba(99, 102, 241, 0.2); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);}.menu-item.active { background: linear-gradient(135deg, var(--active-color-light), rgba(99, 102, 241, 0.1)); border-color: var(--active-color); border-left: 4px solid var(--active-color); transform: translateY(-1px);}.menu-content { padding: 8px 10px; display: flex; flex-direction: column;}.menu-main { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;}.menu-text { font-size: 15px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;}.menu-icons { display: flex; align-items: center; gap: 8px;}.demo-icon { width: 16px; height: 16px; fill: #9ca3af; cursor: help; transition: all 0.3s ease;}.demo-icon:hover { fill: var(--active-color); transform: scale(1.1);}.menu-item:hover .demo-icon { opacity: 1;}.menu-arrow { width: 18px; height: 18px; fill: #9ca3af; opacity: 0; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);}.menu-item:hover .menu-arrow { opacity: 1; transform: translateX(3px); fill: var(--active-color);}.menu-item.active .menu-arrow { opacity: 1; fill: var(--active-color);}.menu-hint { font-size: 12px; color: #64748b; font-weight: 400; line-height: 1.4; opacity: 0.8;}.empty-state { display: flex; flex-direction: column; align-items: center; padding: 48px 24px; color: #64748b;}.empty-state svg { width: 56px; height: 56px; fill: #cbd5e1; margin-bottom: 16px;}.empty-state span { font-size: 14px; font-weight: 500;}.panel-footer { padding: 12px 20px; background: linear-gradient(135deg, #f8fafc, #f1f5f9); border-top: 1px solid rgba(229, 231, 235, 0.3);}.scroll-hint { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 12px; color: #6b7280; font-weight: 500;}.scroll-hint svg { width: 16px; height: 16px; fill: currentColor; animation: bounce 2s infinite;}@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-6px); } 60% { transform: translateY(-3px); }}/* 动画效果 */.menu-slide-enter-active { transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);}.menu-slide-leave-active { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);}.menu-slide-enter-from { opacity: 0; transform: scale(0.8) translateY(30px);}.menu-slide-leave-to { opacity: 0; transform: scale(0.9) translateY(15px);}/* 滚动条样式 */.menu-scroll-container::-webkit-scrollbar { width: 8px;}.menu-scroll-container::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.03); border-radius: 4px;}.menu-scroll-container::-webkit-scrollbar-thumb { background: linear-gradient(135deg, #cbd5e1, #94a3b8); border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2);}.menu-scroll-container::-webkit-scrollbar-thumb:hover { background: linear-gradient(135deg, #94a3b8, #64748b);}/* 响应式设计 */@media (max-width: 768px) { .submenu-panel { width: 280px; max-height: 360px; } .nav-trigger { width: 56px; height: 56px; } .nav-icon { width: 20px; height: 20px; }}
OperateVideoDialog.vue(视频播放)
<template> <vxe-modal v-model=\"isVisible\" :title=\"title\" width=\"800\" min-width=\"600\" min-height=\"400\" :show-footer=\"false\" resize remember transfer @close=\"close\"> <div class=\"video-demo-container\"> <video ref=\"videoPlayer\" controls class=\"demo-video\" :poster=\"poster\" @play=\"onVideoPlay\"> <source :src=\"videoUrl\" type=\"video/mp4\"> 您的浏览器不支持视频播放 </video> <div v-if=\"showTips\" class=\"video-tips\"> <vxe-icon type=\"question-circle-fill\"></vxe-icon> <span>{{ tipsText }}</span> </div> </div> </vxe-modal></template><script setup>import { ref, watch } from \'vue\'const props = defineProps({ // 视频地址(必传) videoUrl: { type: String, required: true }, // 弹框标题 title: { type: String, default: \'操作演示\' }, // 视频封面图 poster: { type: String, default: \'\' }, // 是否显示提示文本 showTips: { type: Boolean, default: true }, // 提示文本内容 tipsText: { type: String, default: \'请按照视频中的步骤进行操作\' }, // 是否自动播放 autoPlay: { type: Boolean, default: false }})const emit = defineEmits([\'play\', \'close\'])const isVisible = ref(false)const videoPlayer = ref(null)// 打开弹窗const open = () => { isVisible.value = true}// 关闭弹窗const close = () => { isVisible.value = false resetVideo() emit(\'close\')}// 重置视频const resetVideo = () => { if (videoPlayer.value) { videoPlayer.value.pause() videoPlayer.value.currentTime = 0 }}// 视频播放事件const onVideoPlay = () => { emit(\'play\', props.videoUrl)}// 自动播放处理watch(isVisible, (val) => { if (val && props.autoPlay) { nextTick(() => { videoPlayer.value?.play() }) }})// 暴露方法给父组件defineExpose({ open, close})</script><style scoped>.video-demo-container { position: relative; padding: 10px;}.demo-video { width: 100%; border-radius: 4px; background: #000; aspect-ratio: 16/9; display: block;}.video-tips { margin-top: 15px; padding: 10px; background-color: #f0f7ff; border-radius: 4px; display: flex; align-items: center; color: #409eff;}.video-tips .vxe-icon { margin-right: 8px; font-size: 16px;}</style>
operateVideo.ts(获取视频url)
/** * 根据路由名称生成视频URL * @param routeName 路由名称 * @returns 视频文件的完整URL,如果路由无效则抛出错误 */export const getVideoUrl = async (routeName: any): Promise<string> => { if (!routeName) { throw new Error(\"该页面暂无视频演示\"); } const cleanRouteName = routeName .toString() .trim() .replace(/\\//g, \"\") .replace(/\\*/g, \"\") .replace(/\\s+/g, \"\"); if (!cleanRouteName) { throw new Error(\"该页面暂无视频演示\"); } const url = `https://api.ecom20200909.com/saasFile/video/${cleanRouteName}.mp4`; return url;};