Vue3+ElementPlus實現可拖拽/吸附/搜索/收起展開的浮動菜單組件

在開發后臺管理系統時,我們經常會用到浮動菜單來快速訪問某些功能。本篇文章將分享一個基于 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…