在开发后台管理系统时,我们经常会用到浮动菜单来快速访问某些功能。本篇文章将分享一个基于 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;
};

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/pingmian/90671.shtml
繁体地址,请注明出处:http://hk.pswp.cn/pingmian/90671.shtml
英文地址,请注明出处:http://en.pswp.cn/pingmian/90671.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

CSS 盒子模型学习版的理解

文章目录一、盒子模型构建流程&#xff08;一句话抓关键&#xff09;二、核心逻辑提炼三、代码验证四、一句话总结流程通过手绘图示&#xff0c;清晰拆解 Content&#xff08;内容&#xff09;→ Padding&#xff08;内边距&#xff09;→ Border&#xff08;边框&#xff09;→…

解决线程安全的几个方法

线程安全&#xff1a;线程安全问题的发现与解决-CSDN博客 Java中所使用的并发机制依赖于JVM的实现和CPU的指令。 所以了解并掌握深入Java并发编程基础的前提知识是熟悉JVM的实现了解CPU的指令。 1.volatile简介 在多线程并发编程中&#xff0c;有两个重要的关键字&#xff1a…

大模型应用班-第2课 DeepSeek使用与提示词工程课程重点 学习ollama 安装 用deepseek-r1:1.5b 分析PDF 内容

DeepSeek使用与提示词工程课程重点Homework&#xff1a;ollama 安装 用deepseek-r1:1.5b 分析PDF 内容python 代码建构&#xff1a;1.小模型 1.5b 可以在 笔记本上快速执行2.分析结果还不错3. 重点是提示词 prompt 的写法一、DeepSeek模型创新与特点1. DeepSeek-V3模型特点采用…

在FreeBSD系统下使用llama-cpp运行飞桨开源大模型Ernie4.5 0.3B(失败)

先上结论&#xff0c;截止到目前2025.7.25日&#xff0c;还不能用。也就是Ernie4.5模型无法在llama.cpp 和Ollama上进行推理&#xff0c;原因主要就llama是不支持Ernie4.5异构MoE架构。 不局限于FreeBSD系统&#xff0c;Windows也测试失败&#xff0c;理论上Ubuntu下也是不行。…

OpenCV图像梯度、边缘检测、轮廓绘制、凸包检测大合集

一、图像梯度 在图像处理中&#xff0c;「梯度&#xff08;Gradient&#xff09;」是一个非常基础但又极其重要的概念。它是图像边缘检测、特征提取、纹理分析等众多任务的核心。梯度的本质是在空间上描述像素灰度值变化的快慢和方向。 但我们如何在图像中计算梯度&#xff1f;…

GitHub 趋势日报 (2025年07月25日)

&#x1f4ca; 由 TrendForge 系统生成 | &#x1f310; https://trendforge.devlive.org/ &#x1f310; 本日报中的项目描述已自动翻译为中文 &#x1f4c8; 今日获星趋势图 今日获星趋势图1814Resume-Matcher985neko714Qwen3-Coder622OpenBB542BillionMail486hrms219hyper…

编程语言Java——核心技术篇(五)IO流:数据洪流中的航道设计

&#x1f31f; 你好&#xff0c;我是 励志成为糕手 &#xff01; &#x1f30c; 在代码的宇宙中&#xff0c;我是那个追逐优雅与性能的星际旅人。 ✨ 每一行代码都是我种下的星光&#xff0c;在逻辑的土壤里生长成璀璨的银河&#xff1b; &#x1f6e0;️ 每一个算法都是我绘制…

基于FPGA的16QAM软解调+卷积编码Viterbi译码通信系统,包含帧同步,信道,误码统计,可设置SNR

目录 1.引言 2.算法仿真效果 3.算法涉及理论知识概要 3.1 16QAM调制软解调原理 3.2 帧同步 3.3 卷积编码&#xff0c;维特比译码 4.Verilog程序接口 5.参考文献 6.完整算法代码文件获得 1.引言 基于FPGA的16QAM软解调卷积编码Viterbi译码通信系统开发,包含帧同步,高斯…

Python数据分析基础(二)

一、Numpy 常用函数分类概览函数类别常用函数基本数学函数np.sum(x)、np.sqrt(x)、np.exp(x)、np.log(x)、np.sin(x)、np.abs(x)、np.power(a, b)、np.round(x, n) 等统计函数np.mean(x)、np.median(x)、np.std(x)、np.var(x)、np.min(x)、np.max(x)、np.percentile(x, q) 等比…

Colab中如何临时使用udocker(以MinIO为例)

本文主要是想记录一下自己在Colab中用udocker启动一个MinIO的容器的过程。 1. 命令行配置环境 由于目前没有用到GPU&#xff0c;所以我选择的是CPU的环境。(内存12G)然后就可以在命令行里安装udocker了&#xff0c;并配置minio的环境 # 由于minio需要做两个端口映射&#xff0c…

rt-thread 5.2.1 基于at-start-f437开发过程记录

基于rt-thread 5.2.1 bsp/at/at32f437-start进行开发&#xff0c;记录详细过程&#xff0c;包括中间遇到的各种坑。 at32f437-start原理图 自己设计的电路板主要换了一块小封装的同系列芯片, 目标是移植opENer。 1. 开发环境 env长时间不用&#xff0c;有点忘了。这次新下载…

EMCCD相机与电可调变焦透镜的同步控制系统设计与实现

EMCCD相机与电可调变焦透镜的同步控制系统设计与实现 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家&#xff0c;觉得好请收藏。点击跳转到网站。 摘要 本文详细介绍了基于Python的EMCCD相机&#xff0…

前缀和-560.和为k的子数组-力扣(LeetCode)

一、题目解析1.子数组是数组中元素的连续非空序列2.nums[i]范围为[-1000,1000]&#xff0c;存在负数3.由于2的题目条件&#xff0c;该题不能用双指针算法&#xff0c;不具备单调性 二、算法原理解法1&#xff1a;暴力解法->枚举 O(N^2)固定一个值&#xff0c;向后枚举数组和…

解决企业微信收集表没有图片、文件组件,不能收集图片的问题

问题&#xff1a; 企业微信里面的收集表功能&#xff0c;有一个图片收集的收集表&#xff0c;但是插入的组件没有收集图片的组件&#xff1f; 原因&#xff1a; 大概率是微盘未启用 解决方法&#xff1a; 1、登陆企业微信管理后台 企业微信 2、访问微盘页面&#xff0c;…

认识单片机

《认识单片机》课程内容 一、课程导入 在我们的日常生活中&#xff0c;有很多看似普通却充满智慧的小物件。比如家里的智能电饭煲&#xff0c;它能精准地控制煮饭的时间和温度&#xff0c;让米饭煮得香喷喷的&#xff1b;还有楼道里的声控灯&#xff0c;当有人走过发出声音时&a…

数据结构(2)顺序表算法题

一、移除元素1、题目描述2、算法分析 思路1&#xff1a;查找val值对应的下标pos&#xff0c;执行删除pos位置数据的操作。该方法时间复杂度为O&#xff08;n^2&#xff09;&#xff0c;因此不建议使用。思路2&#xff1a;创建新数组&#xff08;空间大小与原数组一致&#xff0…

汽车电子架构

本文试图从Analog Devices官网中的汽车解决方案视角带读者构建起汽车电子的总体架构图&#xff0c;为国内热爱和从事汽车电子行业的伙伴们贡献一份力量。 一 、汽车电子架构总览 整个汽车电子包括四个部分&#xff1a;车身电子&#xff08;Body Electronics&#xff09;、座舱与…

pycharm 2025 专业版下载安装教程【附安装包】

安装之前&#xff0c;请确保已经关闭所有安全软件&#xff08;如杀毒软件、防火墙等&#xff09;安装包 &#x1f447;链接&#xff1a;https://pan.xunlei.com/s/VOU-5_L1KOH5j3zDaaCh-Z28A1# 提取码&#xff1a;6bjy下载 PyCharm2025专业版 安装包 并 进行解压运行 pycharm-2…

在 Java 世界里让对象“旅行”:序列化与反序列化

Java 生态里关于 JSON 的序列化与反序列化&#xff08;以下简称“序列化”&#xff09;是一个久经考验的话题&#xff0c;却常因框架繁多、配置琐碎而让初学者望而却步。本文将围绕一段极简的 JsonUtils 工具类展开&#xff0c;以 FastJSON 与 Jackson 两大主流实现为例&#x…

High Speed SelectIO Wizard ip使用记录

本次实验的目的是通过VU9P开发板的6个TG接口&#xff0c;采用固定连接的方式&#xff0c;即X和X-维度互联&#xff0c;其框图如下所示&#xff1a;IP参数配置通过调用High Speed SelectIO Wizard来实现数据通路&#xff0c;High Speed SelectIO Wizard ip有24对数据通道&#x…