Browse Source

refactor(video): 重构视频播放器组件并优化抖音作品分析页面UI

- 重构视频播放器界面,添加倍速播放、音量控制、全屏等功能菜单
- 实现键盘快捷键支持(空格播放暂停、方向键调节音量和进度、M静音、F全屏)
- 添加拖拽进度条和音量滑块功能,提升用户体验
- 优化抖音作品分析页面布局,重构作者信息展示结构
- 新增点赞、评论、收藏、分享统计卡片,使用图标增强视觉效果
- 为视频播放器添加音量和倍速调整提示动画效果
- 修复视频组件事件监听器内存泄漏问题,完善组件销毁逻辑
JX.Li 1 week ago
parent
commit
15119c5c0c

File diff suppressed because it is too large
+ 0 - 0
nexo-ui/src/assets/icons/svg/collect.svg


File diff suppressed because it is too large
+ 0 - 0
nexo-ui/src/assets/icons/svg/comment.svg


File diff suppressed because it is too large
+ 0 - 0
nexo-ui/src/assets/icons/svg/like.svg


File diff suppressed because it is too large
+ 0 - 0
nexo-ui/src/assets/icons/svg/share.svg


+ 0 - 0
nexo-ui/src/views/components/video/svg/beisu1.svg → nexo-ui/src/views/components/video/svg/beisu.svg


File diff suppressed because it is too large
+ 0 - 0
nexo-ui/src/views/components/video/svg/qxquanping.svg


+ 590 - 35
nexo-ui/src/views/components/video/video_play.vue

@@ -25,23 +25,27 @@
     <div class="custom-controls" :class="{ 'controls-hidden': !showControls }">
       <div class="mini-top-bar">
         <div class="mini-top-left">
-          <div class="mini-top-icon" title="返回" @click="handleBack">
+          <div class="mini-top-icon" data-title="返回" @click="handleBack">
             <img src="./svg/fanhui.svg" alt="返回"/>
           </div>
         </div>
         <div class="mini-top-right">
-          <div class="mini-top-icon" title="分享" @click="handleShare">
+          <div class="mini-top-icon" data-title="分享" @click="handleShare">
             <img src="./svg/fenxiang.svg" alt="分享"/>
           </div>
         </div>
       </div>
+      <div class="speed-toast" v-if="showSpeedToast">
+        <span class="toast-icon" v-if="toastType === 'speed'">⚡</span>
+        <span class="toast-icon" v-if="toastType === 'volume'">🔊</span>
+        <span class="toast-text">{{ speedToastText }}</span>
+      </div>
       <div class="center-play-btn" v-if="!isPlaying" @click="togglePlay">
         <img src="./svg/play.svg" alt="播放"/>
       </div>
-      <div class="video-click-area" @click="handleVideoClick"></div>
       <div class="mini-controls">
         <div class="mini-control-bar">
-          <div class="play-btn" @click="togglePlay">
+          <div class="play-btn" @click="togglePlay" :data-title="!isPlaying ? '播放':'暂停'">
             <img v-if="!isPlaying" src="./svg/play.svg" alt="播放"/>
             <img v-else src="./svg/pause.svg" alt="暂停"/>
           </div>
@@ -58,22 +62,59 @@
             <div class="mini-progress-dot" :style="{ left: progressPercent + '%' }"></div>
           </div>
           <div class="time-duration">{{ formatTime(duration) }}</div>
-          <div class="pip-btn" @click="togglePiP">
+          <div class="pip-btn" @click="togglePiP" data-title="小窗口播放">
             <img src="./svg/xiaochuang.svg" alt="小窗播放"/>
           </div>
-          <div class="pip-btn" @click="togglePiP">
-            <img src="./svg/beisu1.svg" alt="小窗播放"/>
+          <div class="pip-btn" @click.stop ref="speedBtn" @mouseenter="handleSpeedMenuEnter"
+               @mouseleave="handleSpeedMenuLeave" data-title="倍速"
+          >
+            <img src="./svg/beisu.svg" alt="倍速播放"/>
+          </div>
+          <div class="speed-menu-wrapper" @mouseenter="handleSpeedMenuEnter" @mouseleave="handleSpeedMenuLeave">
+            <div class="speed-menu" v-if="showSpeedMenu" @click.stop>
+              <div
+                v-for="rate in speedOptions"
+                :key="rate"
+                class="speed-option"
+                :class="{ active: playbackRate === rate }"
+                @click="changePlaybackRate(rate)"
+              >
+                {{ rate }}x
+              </div>
+            </div>
           </div>
-          <div class="pip-btn" @click="toggleMute">
-            <img v-if="!isMuted" src="./svg/yinliang.svg" alt="音量"/>
+          <div class="pip-btn" @click.stop ref="volumeBtn" @mouseenter="handleVolumeMenuEnter"
+               @mouseleave="handleVolumeMenuLeave" data-title="音量"
+          >
+            <img v-if="!isMuted && volume > 0.5" src="./svg/yinliang.svg" alt="音量"/>
+            <img v-else-if="!isMuted && volume > 0" src="./svg/yinliang.svg" alt="低音量"/>
             <img v-else src="./svg/jingyin.svg" alt="静音"/>
           </div>
-          <div class="fullscreen-btn" @click="toggleFullscreen">
-            <img src="./svg/quanping.svg" alt="全屏"/>
+          <div class="volume-menu-wrapper" @mouseenter="handleVolumeMenuEnter" @mouseleave="handleVolumeMenuLeave">
+            <div class="volume-menu" v-if="showVolumeMenu" @click.stop>
+              <div class="volume-slider-container">
+                <div class="volume-slider-vertical"
+                     ref="volumeSlider"
+                     @click="onVolumeSliderClick"
+                     @mousedown="onVolumeSliderMouseDown"
+                >
+                  <div class="volume-slider-track">
+                    <div class="volume-slider-fill" :style="{ height: (volume * 100) + '%' }"></div>
+                    <div class="volume-slider-thumb" :style="{ bottom: (volume * 100) + '%' }"></div>
+                  </div>
+                </div>
+              </div>
+              <div class="volume-percent">{{ Math.round(volume * 100) }}%</div>
+            </div>
+          </div>
+          <div class="fullscreen-btn" @click="toggleFullscreen" :data-title="isFullscreen ? '退出全屏':'全屏'">
+            <img v-if="!isFullscreen" src="./svg/quanping.svg" alt="全屏"/>
+            <img v-else src="./svg/qxquanping.svg" alt="退出全屏"/>
           </div>
         </div>
       </div>
     </div>
+    <div class="video-click-area" @click="handleVideoClick"></div>
   </div>
 </template>
 
@@ -85,10 +126,6 @@ export default {
       type: Boolean,
       default: false
     },
-    controls: {
-      type: Boolean,
-      default: true
-    },
     autoplay: {
       type: Boolean,
       default: false
@@ -112,7 +149,19 @@ export default {
       showControls: true,
       controlsTimer: null,
       isDragging: false,
-      isPiPActive: false
+      isPiPActive: false,
+      isFullscreen: false,
+      playbackRate: 1.0,
+      showSpeedMenu: false,
+      showSpeedToast: false,
+      speedToastText: '',
+      toastType: null,
+      speedToastTimer: null,
+      speedOptions: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0],
+      volume: 1.0,
+      previousVolume: 1.0,
+      showVolumeMenu: false,
+      menuHideTimer: null
     }
   },
   computed: {
@@ -123,18 +172,40 @@ export default {
     bufferedPercent() {
       if (this.duration === 0) return 0
       return (this.buffered / this.duration) * 100
-    },
-    isPiPSupported() {
-      const video = this.$refs.videoPlayer
-      return video && (video.requestPictureInPicture || document.pictureInPictureEnabled)
     }
   },
   mounted() {
+    document.addEventListener('fullscreenchange', this.handleFullscreenChange)
+    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange)
+    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange)
+    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange)
+    document.addEventListener('keydown', this.handleKeydown)
+
+    const video = this.$refs.videoPlayer
+    if (video) {
+      this.volume = video.volume || 1.0
+      this.playbackRate = video.playbackRate || 1.0
+      video.addEventListener('volumechange', this.handleVolumeChange)
+    }
   },
   beforeDestroy() {
+    this.cleanupEventListeners()
+
     if (this.isPiPActive) {
       this.exitPiP()
     }
+
+    const video = this.$refs.videoPlayer
+    if (video) {
+      video.removeEventListener('volumechange', this.handleVolumeChange)
+    }
+
+    document.removeEventListener('keydown', this.handleKeydown)
+
+    if (this.speedToastTimer) {
+      clearTimeout(this.speedToastTimer)
+      this.speedToastTimer = null
+    }
   },
   methods: {
     togglePlay() {
@@ -148,7 +219,9 @@ export default {
     handleVideoClick(e) {
       if (e.target.closest('.mini-top-bar') ||
         e.target.closest('.mini-control-bar') ||
-        e.target.closest('.center-play-btn')) {
+        e.target.closest('.center-play-btn') ||
+        e.target.closest('.speed-menu') ||
+        e.target.closest('.volume-menu')) {
         return
       }
       this.togglePlay()
@@ -187,16 +260,140 @@ export default {
     },
     toggleMute() {
       const video = this.$refs.videoPlayer
+      if (!video) return
+
       if (this.isMuted) {
         video.muted = false
         this.isMuted = false
-        video.volume = this.previousVolume
-        this.volume = this.previousVolume
+        if (this.previousVolume && this.previousVolume > 0) {
+          video.volume = this.previousVolume
+          this.volume = this.previousVolume
+        } else {
+          video.volume = 1.0
+          this.volume = 1.0
+          this.previousVolume = 1.0
+        }
       } else {
         this.previousVolume = this.volume
         video.muted = true
         this.isMuted = true
+        video.volume = 0
+        this.volume = 0
       }
+      this.showVolumeToastMessage(this.isMuted ? '已静音' : `音量 ${Math.round(this.volume * 100)}%`)
+    },
+    handleSpeedMenuEnter() {
+      if (this.menuHideTimer) {
+        clearTimeout(this.menuHideTimer)
+        this.menuHideTimer = null
+      }
+      this.showVolumeMenu = false
+      this.showSpeedMenu = true
+    },
+    handleSpeedMenuLeave() {
+      this.menuHideTimer = setTimeout(() => {
+        this.showSpeedMenu = false
+        this.menuHideTimer = null
+      }, 200)
+    },
+    handleVolumeMenuEnter() {
+      if (this.menuHideTimer) {
+        clearTimeout(this.menuHideTimer)
+        this.menuHideTimer = null
+      }
+      this.showSpeedMenu = false
+      this.showVolumeMenu = true
+    },
+    handleVolumeMenuLeave() {
+      this.menuHideTimer = setTimeout(() => {
+        this.showVolumeMenu = false
+        this.menuHideTimer = null
+      }, 200)
+    },
+    setVolume(value) {
+      const video = this.$refs.videoPlayer
+      if (!video) return
+
+      value = Math.max(0, Math.min(1, value))
+
+      this.volume = value
+      video.volume = value
+
+      if (value === 0) {
+        this.isMuted = true
+        video.muted = true
+      } else {
+        this.isMuted = false
+        video.muted = false
+        this.previousVolume = value
+      }
+      this.showVolumeToastMessage(`音量 ${Math.round(value * 100)}%`)
+    },
+    handleVolumeChange() {
+      const video = this.$refs.videoPlayer
+      if (!video) return
+
+      this.volume = video.volume
+      this.isMuted = video.muted
+    },
+    onVolumeSliderClick(e) {
+      e.stopPropagation()
+      e.preventDefault()
+
+      const slider = this.$refs.volumeSlider
+      if (!slider) return
+
+      const rect = slider.getBoundingClientRect()
+      const pos = 1 - (e.clientY - rect.top) / rect.height
+      const volume = Math.max(0, Math.min(1, pos))
+      this.setVolume(volume)
+    },
+    onVolumeSliderMouseDown(e) {
+      e.preventDefault()
+      e.stopPropagation()
+
+      const slider = this.$refs.volumeSlider
+      if (!slider) return
+
+      let isDragging = true
+
+      const updateVolume = (event) => {
+        if (!isDragging) return
+
+        const rect = slider.getBoundingClientRect()
+        const pos = 1 - (event.clientY - rect.top) / rect.height
+        const volume = Math.max(0, Math.min(1, pos))
+        this.setVolume(volume)
+      }
+
+      const handleMouseMove = (event) => {
+        event.preventDefault()
+        updateVolume(event)
+      }
+
+      const handleMouseUp = () => {
+        isDragging = false
+        document.removeEventListener('mousemove', handleMouseMove)
+        document.removeEventListener('mouseup', handleMouseUp)
+      }
+
+      document.addEventListener('mousemove', handleMouseMove, { passive: false })
+      document.addEventListener('mouseup', handleMouseUp)
+
+      updateVolume(e)
+    },
+    showVolumeToastMessage(text) {
+      this.toastType = 'volume'
+      this.speedToastText = text
+      this.showSpeedToast = true
+
+      if (this.speedToastTimer) {
+        clearTimeout(this.speedToastTimer)
+      }
+
+      this.speedToastTimer = setTimeout(() => {
+        this.showSpeedToast = false
+      }, 1500)
     },
     onPlay() {
       this.isPlaying = true
@@ -232,30 +429,47 @@ export default {
       return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
     },
     onProgressBarClick(e) {
+      e.stopPropagation()
       const progressBar = this.$refs.progressBar
+      if (!progressBar) return
+
       const rect = progressBar.getBoundingClientRect()
       const pos = (e.clientX - rect.left) / rect.width
       const video = this.$refs.videoPlayer
-      video.currentTime = pos * this.duration
+      if (video && this.duration > 0) {
+        video.currentTime = pos * this.duration
+      }
     },
     onProgressBarMouseDown(e) {
+      e.stopPropagation()
       this.isDragging = true
-      const handleMouseMove = (e) => {
+
+      const progressBar = this.$refs.progressBar
+      if (!progressBar) return
+
+      const handleMouseMove = (event) => {
         if (!this.isDragging) return
-        const progressBar = this.$refs.progressBar
+
         const rect = progressBar.getBoundingClientRect()
-        let pos = (e.clientX - rect.left) / rect.width
+        let pos = (event.clientX - rect.left) / rect.width
         pos = Math.max(0, Math.min(1, pos))
+
         const video = this.$refs.videoPlayer
-        video.currentTime = pos * this.duration
+        if (video && this.duration > 0) {
+          video.currentTime = pos * this.duration
+        }
       }
+
       const handleMouseUp = () => {
         this.isDragging = false
         document.removeEventListener('mousemove', handleMouseMove)
         document.removeEventListener('mouseup', handleMouseUp)
       }
+
       document.addEventListener('mousemove', handleMouseMove)
       document.addEventListener('mouseup', handleMouseUp)
+
+      handleMouseMove(e)
     },
     toggleFullscreen() {
       const container = this.$el
@@ -281,6 +495,40 @@ export default {
         }
       }
     },
+    handleFullscreenChange() {
+      this.isFullscreen = !!(document.fullscreenElement ||
+        document.webkitFullscreenElement ||
+        document.mozFullScreenElement ||
+        document.msFullscreenElement)
+    },
+    changePlaybackRate(rate) {
+      const video = this.$refs.videoPlayer
+      if (!video) return
+
+      video.playbackRate = rate
+      this.playbackRate = rate
+      this.showSpeedMenu = false
+      this.showSpeedToastMessage(`${rate}x 倍速播放`)
+    },
+    showSpeedToastMessage(text) {
+      this.toastType = 'speed'
+      this.speedToastText = text
+      this.showSpeedToast = true
+
+      if (this.speedToastTimer) {
+        clearTimeout(this.speedToastTimer)
+      }
+
+      this.speedToastTimer = setTimeout(() => {
+        this.showSpeedToast = false
+      }, 2000)
+    },
+    cleanupEventListeners() {
+      document.removeEventListener('fullscreenchange', this.handleFullscreenChange)
+      document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange)
+      document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange)
+      document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange)
+    },
     handleBack() {
       if (this.isPiPActive) {
         this.exitPiP()
@@ -300,10 +548,14 @@ export default {
       this.showControls = true
       if (this.controlsTimer) {
         clearTimeout(this.controlsTimer)
+        this.controlsTimer = null
       }
       if (this.isPlaying) {
         this.controlsTimer = setTimeout(() => {
-          this.showControls = false
+          if (!this.showSpeedMenu && !this.showVolumeMenu) {
+            this.showControls = false
+          }
+          this.controlsTimer = null
         }, 3000)
       }
     },
@@ -311,13 +563,77 @@ export default {
       if (this.isPlaying) {
         this.showControls = false
       }
+    },
+    handleKeydown(e) {
+      if (this.showSpeedMenu || this.showVolumeMenu) {
+        return
+      }
+
+      const video = this.$refs.videoPlayer
+      if (!video) return
+
+      switch (e.key) {
+        case 'ArrowUp':
+          e.preventDefault()
+          this.adjustVolume(0.05)
+          break
+        case 'ArrowDown':
+          e.preventDefault()
+          this.adjustVolume(-0.05)
+          break
+        case 'ArrowRight':
+          e.preventDefault()
+          this.adjustProgress(5)
+          break
+        case 'ArrowLeft':
+          e.preventDefault()
+          this.adjustProgress(-5)
+          break
+        case ' ':
+          e.preventDefault()
+          this.togglePlay()
+          break
+        case 'm':
+        case 'M':
+          e.preventDefault()
+          this.toggleMute()
+          break
+        case 'f':
+        case 'F':
+          e.preventDefault()
+          this.toggleFullscreen()
+          break
+      }
+    },
+    adjustVolume(delta) {
+      const newVolume = Math.max(0, Math.min(1, this.volume + delta))
+      this.setVolume(newVolume)
+    },
+    adjustProgress(seconds) {
+      const video = this.$refs.videoPlayer
+      if (!video || !this.duration) return
+
+      const newTime = Math.max(0, Math.min(this.duration, this.currentTime + seconds))
+      video.currentTime = newTime
+      this.showProgressToastMessage(`${seconds > 0 ? '+' : ''}${seconds}秒`)
+    },
+    showProgressToastMessage(text) {
+      this.toastType = 'speed'
+      this.speedToastText = text
+      this.showSpeedToast = true
+
+      if (this.speedToastTimer) {
+        clearTimeout(this.speedToastTimer)
+      }
+
+      this.speedToastTimer = setTimeout(() => {
+        this.showSpeedToast = false
+      }, 1000)
     }
   }
 }
 </script>
-
 <style scoped lang="scss">
-
 .video-container {
   position: relative;
   width: 100%;
@@ -367,6 +683,11 @@ export default {
     .mini-controls {
       pointer-events: none;
     }
+
+    .speed-menu {
+      opacity: 0;
+      visibility: hidden;
+    }
   }
 }
 
@@ -439,8 +760,8 @@ export default {
   top: 0;
   left: 0;
   width: 100%;
-  height: calc(100% - 60px);
-  z-index: 20;
+  height: calc(100% - 80px);
+  z-index: 10;
   pointer-events: auto;
   cursor: pointer;
 }
@@ -475,7 +796,7 @@ export default {
   align-items: center;
   gap: 6px;
   pointer-events: auto;
-  z-index: 15;
+  z-index: 40;
   transition: transform .3s ease, opacity .3s ease;
   transform: translateY(0)
 }
@@ -493,6 +814,8 @@ export default {
 
 .mini-controls .mini-control-bar .play-btn,
 .mini-controls .mini-control-bar .pip-btn,
+.mini-controls .mini-control-bar .speed-btn,
+.mini-controls .mini-control-bar .volume-btn,
 .mini-controls .mini-control-bar .fullscreen-btn {
   width: 32px;
   height: 32px;
@@ -502,11 +825,20 @@ export default {
   cursor: pointer;
   border-radius: 50%;
   transition: background .2s;
-  flex-shrink: 0
+  flex-shrink: 0;
+  position: relative
+}
+
+.mini-controls .mini-control-bar .speed-btn,
+.mini-controls .mini-control-bar .volume-btn {
+  width: auto;
+  gap: 4px;
 }
 
 .mini-controls .mini-control-bar .play-btn img,
 .mini-controls .mini-control-bar .pip-btn img,
+.mini-controls .mini-control-bar .speed-btn img,
+.mini-controls .mini-control-bar .volume-btn img,
 .mini-controls .mini-control-bar .fullscreen-btn img {
   width: 20px;
   height: 20px;
@@ -516,6 +848,8 @@ export default {
 @media(hover: hover) {
   .mini-controls .mini-control-bar .play-btn:hover,
   .mini-controls .mini-control-bar .pip-btn:hover,
+  .mini-controls .mini-control-bar .speed-btn:hover,
+  .mini-controls .mini-control-bar .volume-btn:hover,
   .mini-controls .mini-control-bar .fullscreen-btn:hover {
     background: rgba(255, 255, 255, .15)
   }
@@ -575,4 +909,225 @@ export default {
   box-shadow: 0 0 4px rgba(0, 0, 0, .3)
 }
 
+.speed-menu {
+  position: absolute;
+  bottom: 60px;
+  right: 80px;
+  background: rgba(28, 28, 28, 0.3);
+  backdrop-filter: blur(10px);
+  border-radius: 8px;
+  padding: 6px;
+  min-width: 60px;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  z-index: 100;
+  opacity: 1;
+  visibility: visible;
+  transition: all 0.2s ease;
+  transform-origin: bottom right;
+  pointer-events: auto;
+
+  .speed-option {
+    padding: 5px 10px;
+    margin-top: 5px;
+    color: #fff;
+    font-size: 13px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    text-align: center;
+    font-weight: 500;
+
+    &:hover {
+      border-radius: 5px;
+      background: rgba(255, 255, 255, 0.05);
+      color: #ffffff;
+    }
+
+    &.active {
+      border-radius: 5px;
+      color: #ffffff;
+      background: rgb(255 255 255 / 15%);
+      font-weight: 600;
+    }
+
+    &:first-child {
+      border-radius: 8px 8px 0 0;
+    }
+
+    &:last-child {
+      border-radius: 0 0 8px 8px;
+    }
+  }
+}
+
+.speed-menu-wrapper {
+  position: absolute;
+  bottom: 0;
+  right: 0;
+  z-index: 99;
+  pointer-events: auto;
+}
+
+.controls-hidden .speed-menu {
+  opacity: 0;
+  visibility: hidden;
+  transform: scale(0.9) translateY(10px);
+}
+
+
+.speed-toast {
+  position: absolute;
+  bottom: 80px;
+  left: 20px;
+  background: rgb(28 28 28 / 40%);
+  backdrop-filter: blur(10px);
+  border-radius: 5px;
+  padding: 5px 10px;
+  display: -webkit-box;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  z-index: 100;
+  animation: toastSlideIn-data-v-4d44248a 0.3s ease;
+  pointer-events: none;
+
+  .toast-icon {
+    font-size: 18px;
+    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+  }
+
+  .toast-text {
+    color: #fff;
+    font-size: 14px;
+    font-weight: 600;
+    white-space: nowrap;
+  }
+}
+
+@keyframes toastSlideIn {
+  from {
+    opacity: 0;
+    transform: translateX(-20px) translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0) translateY(0);
+  }
+}
+
+.controls-hidden .speed-toast {
+  opacity: 0;
+  visibility: hidden;
+  transform: translateX(-20px) translateY(10px);
+  transition: all 0.3s ease;
+}
+
+.controls-hidden .speed-menu {
+  opacity: 0;
+  visibility: hidden;
+  transform: scale(0.9) translateY(10px);
+}
+
+.volume-menu {
+  position: absolute;
+  bottom: 60px;
+  right: 50px;
+  height: 150px;
+  background: rgba(28, 28, 28, 0.3);
+  backdrop-filter: blur(10px);
+  border-radius: 8px;
+  padding: 6px 0;
+  min-width: 40px;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  z-index: 100;
+  opacity: 1;
+  visibility: visible;
+  transition: all 0.2s ease;
+  transform-origin: bottom right;
+  pointer-events: auto;
+
+  .volume-percent {
+    cursor: default;
+    color: #ffffff;
+    font-size: 12px;
+    position: absolute;
+    bottom: 10px;
+    width: 100%;
+    text-align: center;
+  }
+
+  .volume-slider-vertical {
+    position: relative;
+    width: 36px;
+    height: 100px;
+    cursor: pointer;
+    flex-shrink: 0;
+
+    .volume-slider-track {
+      position: absolute;
+      width: 4px;
+      height: 100%;
+      background: rgba(28, 28, 28, 0.5);
+      border-radius: 2px;
+      top: 10px;
+      left: 50%;
+      transform: translateX(-50%);
+      overflow: hidden;
+
+      .volume-slider-fill {
+        position: absolute;
+        width: 100%;
+        bottom: 0;
+        background: #fff;
+        border-radius: 2px;
+        transition: height 0.1s ease;
+      }
+
+      .volume-slider-thumb {
+        position: absolute;
+        width: 16px;
+        height: 16px;
+        background: #fff;
+        border-radius: 50%;
+        left: 50%;
+        transform: translate(-50%, 50%);
+        transition: bottom 0.1s ease;
+        z-index: 2;
+
+        &:hover {
+          transform: translate(-50%, 50%) scale(1.2);
+        }
+      }
+    }
+  }
+}
+
+.controls-hidden .volume-menu {
+  opacity: 0;
+  visibility: hidden;
+  transform: scale(0.9) translateY(10px);
+}
+
+div[data-title] {
+  &:hover {
+    position: relative;
+    &:after {
+      content: attr(data-title);
+      position: absolute;
+      top: 0;
+      left: 100%;
+      color: #ffffff;
+      font-size: 10px;
+      border-radius: 4px;
+      background-color: #00000080;
+      z-index: 20;
+      line-height: 1.5;
+      font-style: normal; //本例中data-title为i标签属性
+      white-space: nowrap;
+      padding: 5px 10px;
+    }
+  }
+}
 </style>

+ 216 - 22
nexo-ui/src/views/module/douyin/worksAanalysis/index.vue

@@ -25,18 +25,56 @@
             <span>作品信息</span>
           </div>
           <div class="video-info">
-            <div><span v-if="data.avatar">作者avatar: <el-avatar :src="data.avatar" :size="50" shape="square"/></span>
+            <div class="info-header" v-if="data.avatar || data.nickname">
+              <el-avatar :src="data.avatar" :size="56" shape="square" class="author-avatar" v-if="data.avatar"/>
+              <div class="author-meta" v-if="data.nickname || data.signature">
+                <div class="author-name" v-if="data.nickname">{{ data.nickname }}</div>
+                <div class="author-signature" v-if="data.signature" >{{ data.signature }}</div>
+              </div>
+            </div>
+
+            <div class="info-grid">
+              <div class="info-row" v-if="data.unique_id">
+                <span class="info-label">抖音号</span>
+                <span class="info-value primary">{{ data.unique_id }}</span>
+              </div>
+              <div class="info-row" v-if="data.sec_uid">
+                <span class="info-label">SecUid</span>
+                <span class="info-value text-sm">{{ data.sec_uid }}</span>
+              </div>
+              <div class="info-row" v-if="data.short_id">
+                <span class="info-label">Short ID</span>
+                <span class="info-value text-sm">{{ data.short_id }}</span>
+              </div>
+            </div>
+
+            <div class="video-title" v-if="data.desc">
+              <span class="title-label">作品标题</span>
+              <span class="title-content">{{ data.desc }}</span>
+            </div>
+
+            <div class="stats-bar">
+              <div class="stat-badge" v-if="data.digg_count !== undefined">
+                <svg-icon icon-class="like" class="stat-svg"/>
+                <span class="stat-num">{{ data.digg_count }}</span>
+                <span class="stat-text">点赞</span>
+              </div>
+              <div class="stat-badge" v-if="data.comment_count !== undefined">
+                <svg-icon icon-class="comment" class="stat-svg"/>
+                <span class="stat-num">{{ data.comment_count }}</span>
+                <span class="stat-text">评论</span>
+              </div>
+              <div class="stat-badge" v-if="data.collect_count !== undefined">
+                <svg-icon icon-class="collect" class="stat-svg"/>
+                <span class="stat-num">{{ data.collect_count }}</span>
+                <span class="stat-text">收藏</span>
+              </div>
+              <div class="stat-badge" v-if="data.share_count !== undefined">
+                <svg-icon icon-class="share" class="stat-svg"/>
+                <span class="stat-num">{{ data.share_count }}</span>
+                <span class="stat-text">分享</span>
+              </div>
             </div>
-            <div><span v-if="data.sec_uid">作者SecUid: {{ data.sec_uid }}</span></div>
-            <div><span v-if="data.short_id">作者shortId: {{ data.short_id }}</span></div>
-            <div><span v-if="data.unique_id">作者抖音号: {{ data.unique_id }}</span></div>
-            <div><span v-if="data.nickname">作者昵称: {{ data.nickname }}</span></div>
-            <div><span v-if="data.signature">作者签名: {{ data.signature }}</span></div>
-            <div><span v-if="data.desc">作品标题: {{ data.desc }}</span></div>
-            <div><span v-if="data.comment_count">comment_count: {{ data.comment_count }}</span></div>
-            <div><span v-if="data.share_count">share_count: {{ data.share_count }}</span></div>
-            <div><span v-if="data.digg_count">digg_count: {{ data.digg_count }}</span></div>
-            <div><span v-if="data.collect_count">collect_count: {{ data.collect_count }}</span></div>
           </div>
         </el-card>
       </el-col>
@@ -105,9 +143,7 @@ export default {
   }
 }
 </script>
-
-<style scoped lang="scss">
-::v-deep .el-card__header {
+<style scoped lang="scss">::v-deep .el-card__header {
   padding: 10px 20px;
 }
 
@@ -116,14 +152,172 @@ export default {
 }
 
 .video-info {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  font-size: 14px;
-  font-weight: 600;
-
-  div {
-    border-bottom: 1px solid #eee;
+  padding: 4px;
+
+  .info-header {
+    display: flex;
+    align-items: flex-start;
+    gap: 14px;
+    padding: 16px;
+    background: linear-gradient(135deg, rgba(0, 0, 0, 0.02) 0%, rgba(64, 158, 255, 0.03) 100%);
+    border-radius: 10px;
+    margin-bottom: 14px;
+    border: 1px solid rgba(64, 158, 255, 0.08);
+
+    .author-avatar {
+      border-radius: 10px;
+      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+      flex-shrink: 0;
+    }
+
+    .author-meta {
+      flex: 1;
+      min-width: 0;
+
+      .author-name {
+        font-size: 16px;
+        font-weight: 600;
+        color: #303133;
+        margin-bottom: 4px;
+      }
+
+      .author-signature {
+        font-size: 13px;
+        color: #909399;
+        line-height: 1.5;
+        white-space: pre-wrap;
+        word-break: break-word;
+      }
+    }
+  }
+
+  .info-grid {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: 8px;
+    margin-bottom: 14px;
+
+    .info-row {
+      display: flex;
+      align-items: center;
+      padding: 10px 14px;
+      background: rgba(245, 247, 250, 0.5);
+      border-radius: 6px;
+      transition: all 0.25s ease;
+
+      &:hover {
+        background: rgba(64, 158, 255, 0.06);
+        transform: translateX(3px);
+      }
+
+      .info-label {
+        min-width: 70px;
+        font-size: 13px;
+        color: #909399;
+        font-weight: 500;
+        margin-right: 10px;
+      }
+
+      .info-value {
+        flex: 1;
+        font-size: 13px;
+        color: #606266;
+        word-break: break-all;
+        font-family: 'Courier New', monospace;
+
+        &.primary {
+          color: #409eff;
+          font-weight: 600;
+          font-size: 14px;
+        }
+
+        &.text-sm {
+          font-size: 12px;
+        }
+      }
+    }
+  }
+
+  .video-title {
+    padding: 12px 14px;
+    background: linear-gradient(135deg, rgba(245, 108, 108, 0.04) 0%, rgba(250, 173, 133, 0.04) 100%);
+    border-left: 3px solid #f56c6c;
+    border-radius: 6px;
+    margin-bottom: 14px;
+
+    .title-label {
+      display: block;
+      font-size: 12px;
+      color: #909399;
+      margin-bottom: 6px;
+      font-weight: 500;
+    }
+
+    .title-content {
+      display: block;
+      font-size: 14px;
+      color: #303133;
+      line-height: 1.6;
+      font-weight: 500;
+    }
+  }
+
+  .stats-bar {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 8px;
+
+    .stat-badge {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      padding: 12px 8px;
+      background: linear-gradient(135deg, rgba(64, 158, 255, 0.04) 0%, rgba(103, 194, 58, 0.04) 100%);
+      border: 1px solid rgba(64, 158, 255, 0.1);
+      border-radius: 8px;
+      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+      cursor: default;
+
+      &:hover {
+        transform: translateY(-3px);
+        box-shadow: 0 6px 16px rgba(64, 158, 255, 0.15);
+        border-color: rgba(64, 158, 255, 0.3);
+        background: linear-gradient(135deg, rgba(64, 158, 255, 0.08) 0%, rgba(103, 194, 58, 0.08) 100%);
+
+        .stat-num {
+          color: #409eff;
+          transform: scale(1.05);
+        }
+
+        ::v-deep svg {
+          transform: scale(1.1);
+          color: #409eff;
+        }
+      }
+
+      .stat-svg {
+        width: 22px;
+        height: 22px;
+        margin-bottom: 6px;
+        color: #606266;
+        transition: all 0.3s ease;
+      }
+
+      .stat-num {
+        font-size: 16px;
+        font-weight: 700;
+        color: #303133;
+        margin-bottom: 2px;
+        transition: all 0.3s ease;
+        font-family: 'Barlow-Medium', 'HarmonyOS_Sans_SC_Medium', sans-serif;
+      }
+
+      .stat-text {
+        font-size: 11px;
+        color: #909399;
+        font-weight: 500;
+      }
+    }
   }
 }
 </style>

Some files were not shown because too many files changed in this diff