在开发后台管理系统时,我们经常会用到浮动菜单来快速访问某些功能。本篇文章将分享一个基于 Vue3 + ElementPlus 实现的浮动菜单组件,支持拖拽移动、边缘吸附、二级菜单展开、菜单搜索过滤、视频弹窗等交互效果,极大提升了用户操作的便捷性与美观性。
效果预览
- 悬浮按钮支持全屏拖拽移动
- 贴边时自动收缩为小浮标
- 点击展开二级菜单,支持搜索过滤
- 支持在菜单项上点击视频icon预览操作视频
- 自带吸附动画与滚动提示
父组件(App.vue)
<template><el-config-provider :locale="locale"><router-view /><FloatingMenu :max-items-before-scroll="4" :allowed-menu-ids="[1, 2, 3, 4, 5, 6, 7, 8]" /></el-config-provider>
</template>## 子组件(FloatingMenu.vue)```javascript
<template><div v-if="shouldShowFloatingMenu" class="floating-nav" ref="floatingNav" :style="navStyle"><!-- 主浮标 --><div class="nav-trigger" :class="{ active: isMenuVisible, dragging: isDragging, docked: isDocked }":style="dockStyle" @mousedown="handleMouseDown"><div class="nav-icon" v-if="!isDocked"><svg viewBox="0 0 24 24" v-if="!isMenuVisible"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" /></svg><svg viewBox="0 0 24 24" v-else><pathd="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" /></svg></div><div v-if="isDocked" class="dock-icon"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg></div><div class="nav-ripple"></div><div class="nav-pulse"></div></div><!-- 二级菜单面板 --><transition name="menu-slide"><div v-show="isMenuVisible" class="submenu-panel" :class="menuDirection" @click.stop><div class="panel-header"><h3>{{ currentTopMenu?.menu_name }}</h3><div class="search-box" v-if="hasSearch"><div class="search-input-wrapper"><svg class="search-icon" viewBox="0 0 24 24"><pathd="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" /></svg><input v-model="searchQuery" placeholder="搜索菜单..." @click.stop /></div></div></div><div class="menu-scroll-container"><div v-for="item in filteredSubMenus" :key="item.id" class="menu-item" :class="{ active: isActive(item) }"@click="navigateTo(item)"><div class="menu-content"><div class="menu-main"><span class="menu-text">{{ item.menu_name }}</span><div class="menu-icons"><svg class="menu-arrow" viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" /></svg><svg class="demo-icon" viewBox="0 0 24 24" @click.stop="showVideo(item)"><pathd="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z" /></svg></div></div><span class="menu-hint" v-if="item.remark">{{ item.remark }}</span></div></div><div v-if="filteredSubMenus.length === 0" class="empty-state"><svg viewBox="0 0 24 24"><pathd="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /></svg><span>没有找到匹配的菜单</span></div></div><div class="panel-footer" v-if="showScrollHint"><div class="scroll-hint"><svg viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" /></svg><span>滚动查看更多</span></div></div></div></transition></div><OperateVideoDialog v-if="showOperateVisible" ref="videoModal" :videoUrl="videoUrl"@close="closeOperateVideoDialog" />
</template><script setup>
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 = falsesearchQuery.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 <= 8;} catch (e) {console.error('浮标显示判断出错:', e);return false;}
});// 当前二级菜单
const currentSubMenus = computed(() => {try {return currentTopMenu.value?.child || []} catch (e) {console.error('获取子菜单出错:', e)return []}
})// 搜索过滤
const filteredSubMenus = computed(() => {try {if (!searchQuery.value) return currentSubMenus.valueconst 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 / 2return navPos.value.x < threshold ? 'right' : 'left'
})const dockStyle = computed(() => {if (!isDocked.value) return {}const nearLeft = navPos.value.x <= window.innerWidth / 2return {'border-radius': nearLeft ? '0 32px 32px 0' : '32px 0 0 32px','justify-content': nearLeft ? 'flex-start' : 'flex-end','padding-left': nearLeft ? '4px' : '0','padding-right': nearLeft ? '0' : '4px',}
})// 检查激活状态
const isActive = (item) =>item.index && route.path.startsWith(item.index)// 导航功能
const navigateTo = (item) => {try {if (item.index) {router.push(item.index)isMenuVisible.value = falsesearchQuery.value = ''}} catch (e) {console.error('菜单跳转出错:', e)isMenuVisible.value = false}
}// 切换菜单
const toggleMenu = () => {isMenuVisible.value = !isMenuVisible.valueif (isMenuVisible.value) {searchQuery.value = ''}
}const showVideo = async (item) => {try {videoUrl.value = await getVideoUrl(item.index || "")showOperateVisible.value = truenextTick(() => {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 = falsenavPos.value.x = navPos.value.x <= window.innerWidth / 2 ? 0 : window.innerWidth - 60return // 不再监听拖拽事件}isDragging.value = falsedragStartTime.value = Date.now()startPos.value = {x: e.clientX - navPos.value.x,y: e.clientY - navPos.value.y}const onMove = (e) => {// 如果移动距离超过阈值,开始拖拽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 = trueisMenuVisible.value = false}if (isDragging.value) {const maxX = window.innerWidth - 60const maxY = window.innerHeight - 60navPos.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 < threshold ? 0 : window.innerWidth - 60// sessionStorage.setItem('floatingNavPos', JSON.stringify(navPos.value))// }// isDragging.value = false// document.removeEventListener('mousemove', onMove)// document.removeEventListener('mouseup', onUp)// }const onUp = () => {const clickDuration = Date.now() - dragStartTime.valueif (!isDragging.value && clickDuration < 200) {if (isDocked.value) {isDocked.value = falsenavPos.value.x = navPos.value.x <= window.innerWidth / 2 ? 0 : window.innerWidth - 60} else {toggleMenu()}}if (isDragging.value) {const edgeThreshold = 20const nearLeft = navPos.value.x <= edgeThresholdconst nearRight = navPos.value.x >= window.innerWidth - 60 - edgeThresholdif (nearLeft || nearRight) {isDocked.value = truenavPos.value.x = nearLeft ? 0 : window.innerWidth - 32} else {sessionStorage.setItem('floatingNavPos', JSON.stringify(navPos.value))}}isDragging.value = falsedocument.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)
})
</script><style scoped>
.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;}
}
</style>
OperateVideoDialog.vue(视频播放)
<template><vxe-modal v-model="isVisible" :title="title" width="800" min-width="600" min-height="400" :show-footer="false" resizeremember 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 = falseresetVideo()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;
};