Vue3+ElementPlus实现可拖拽/吸附/搜索/收起展开的浮动菜单组件

发布于:2025-07-28 ⋅ 阅读:(15) ⋅ 点赞:(0)

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

网站公告

今日签到

点亮在社区的每一天
去签到