在开发后台管理系统时,我们经常会用到浮动菜单来快速访问某些功能。本篇文章将分享一个基于 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>
<path
d="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">
<path
d="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)">
<path
d="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">
<path
d="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 = 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 <= 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.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 < threshold ? 'right' : 'left'
})
const dockStyle = computed(() => {
if (!isDocked.value) return {}
const nearLeft = navPos.value.x <= window.innerWidth / 2
return {
'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 = 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 <= window.innerWidth / 2 ? 0 : window.innerWidth - 60
return // 不再监听拖拽事件
}
isDragging.value = false
dragStartTime.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 = 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 < 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.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 <= edgeThreshold
const nearRight = 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)
})
</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" 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;
};