Browse Source

feat(douyin): 抖音视频解析功能增强

- 添加批量解析模式支持多视频同时解析
- 实现解析按钮加载状态避免重复提交
- 新增视频预览占位符提升用户体验
- 集成flv.js和hls.js支持更多视频格式
- 优化响应式布局适配不同屏幕尺寸
- 添加空状态提示和解析统计信息展示
- 引入视频播放控制图标组件
JX.Li 6 days ago
parent
commit
6d92c08160

+ 2 - 0
nexo-ui/package.json

@@ -42,8 +42,10 @@
     "echarts": "5.4.0",
     "echarts": "5.4.0",
     "element-ui": "2.15.13",
     "element-ui": "2.15.13",
     "file-saver": "2.0.5",
     "file-saver": "2.0.5",
+    "flv.js": "^1.6.2",
     "fuse.js": "6.4.3",
     "fuse.js": "6.4.3",
     "highlight.js": "9.18.5",
     "highlight.js": "9.18.5",
+    "hls.js": "^1.6.16",
     "js-beautify": "1.13.0",
     "js-beautify": "1.13.0",
     "js-cookie": "3.0.1",
     "js-cookie": "3.0.1",
     "jsencrypt": "3.0.0-rc.1",
     "jsencrypt": "3.0.0-rc.1",

+ 1 - 0
nexo-ui/src/assets/icons/svg/live_2.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780470272460" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13904" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M889.6 489.685333c40.789333-24.917333 91.733333 6.186667 91.733333 55.893334v262.528c0 49.792-51.072 80.896-91.861333 55.978666l-76.117333-46.336v30.08c0 49.92-38.698667 90.837333-86.101334 90.837334H128.768C81.493333 938.666667 42.666667 897.834667 42.666667 847.786667v-372.906667C42.666667 424.96 81.365333 384 128.768 384h598.613333c47.274667 0 86.058667 40.832 86.058667 90.88v61.141333l76.117333-46.336z m-502.784 27.477334c-20.138667-14.677333-45.354667 3.669333-45.482667 33.024v222.165333c0 29.482667 25.344 47.786667 45.482667 33.152l152.746667-111.104c20.138667-14.677333 20.138667-51.456 0-66.133333l-152.746667-111.104zM341.333333 256a85.333333 85.333333 0 1 1-170.666666 0 85.333333 85.333333 0 0 1 170.666666 0M597.333333 341.333333a128 128 0 1 0 0.042667-255.957333A128 128 0 0 0 597.333333 341.333333z" p-id="13905"></path></svg>

+ 1 - 1
nexo-ui/src/layout/index.vue

@@ -92,7 +92,7 @@ export default {
     position: fixed;
     position: fixed;
     top: 0;
     top: 0;
     right: 0;
     right: 0;
-    z-index: 9;
+    z-index: 10;
     width: calc(100% - #{$base-sidebar-width});
     width: calc(100% - #{$base-sidebar-width});
     transition: width 0.28s;
     transition: width 0.28s;
 
 

+ 4 - 0
nexo-ui/src/views/components/video/svg/jiesuo.svg

@@ -0,0 +1,4 @@
+<svg t="1780456894027" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2871" width="25" height="25">
+  <path d="M585.92 445.62V317.6c0-74.77 60.61-135.38 135.38-135.38 74.76 0 135.37 60.61 135.37 135.38 0 25.63 20.78 46.41 46.41 46.41 25.63 0 46.41-20.78 46.41-46.41-5.02-121.86-105.27-218.05-227.23-218.05s-222.2 96.2-227.23 218.05v128.02" fill="#ffffff" p-id="2872"></path>
+  <path d="M667.57 894.44H203.43c-59.02 0-106.92-47.73-107.14-106.75V526.61c0-59.02 47.73-106.93 106.75-107.14h464.14c59.02 0.21 106.75 48.12 106.75 107.14v261.07c-0.2 58.72-47.64 106.33-106.36 106.76z m-157.8-298.98c0.11-35.66-25-66.42-59.96-73.43-34.96-7.01-70 11.68-83.65 44.61-13.65 32.94-2.1 70.93 27.57 90.7v94.76c0.21 22.71 18.68 41 41.39 41a41.386 41.386 0 0 0 42.16-41v-94.76a73.876 73.876 0 0 0 32.49-61.88z m0 0" fill="#ffffff" p-id="2873"></path>
+</svg>

+ 3 - 0
nexo-ui/src/views/components/video/svg/suo.svg

@@ -0,0 +1,3 @@
+<svg t="1780456268412" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9283" width="25" height="25">
+  <path d="M650 432.6v-98.4c0-76.3-61.8-138.1-138.1-138.1S373.8 258 373.8 334.3v98.4H650z m-382.9 1.1l-0.1-3.1V322.5c0-59.9 23.8-117.4 66.2-159.8C375.6 120.3 433 96.5 493 96.5h37.9c124.8 0 225.9 101.2 225.9 225.9v111.3c58.2 8.1 101.5 57.9 101.5 116.7v258.3c0 65.1-52.7 117.8-117.8 117.8H283.3c-65.1 0-117.8-52.7-117.8-117.8V550.5c0-58.8 43.3-108.6 101.6-116.8z m217.6 266v77c0 15 12.2 27.2 27.2 27.2s27.2-12.2 27.2-27.2v-77c33.5-13.1 53-48.2 46.3-83.5-6.7-35.4-37.5-61-73.5-61s-66.9 25.6-73.5 61c-6.7 35.3 12.8 70.4 46.3 83.5z m0 0" fill="#ffffff" p-id="9284"></path>
+</svg>

+ 8 - 0
nexo-ui/src/views/components/video/svg/xuanzhuan.svg

@@ -0,0 +1,8 @@
+<svg t="1780393054021" class="icon" viewBox="0 0 1024 1024" version="1.1"
+     xmlns="http://www.w3.org/2000/svg" p-id="10809" width="25" height="25"
+>
+  <path
+    d="M213.333 298.667L384 128v170.667h85.333c141.333 0 256 114.667 256 256s-114.667 256-256 256H384v-85.334h85.333c94.293 0 170.667-76.373 170.667-170.666 0-94.293-76.374-170.667-170.667-170.667H384V469.333L213.333 298.667z"
+    fill="#ffffff"
+  ></path>
+</svg>

File diff suppressed because it is too large
+ 944 - 106
nexo-ui/src/views/components/video/video_play.vue


+ 41 - 0
nexo-ui/src/views/module/douyin/live/live.vue

@@ -0,0 +1,41 @@
+<template>
+  <div class="app-container">
+    <nexo-radio v-model="type" :options="radioOptions" style="width: 350px"/>
+    <el-card shadow="never" class="mt10">
+      <el-input v-model="videoUrl" placeholder="请输入视频地址"/>
+      <el-button type="primary" class="mt10" @click="handleSubmit">提交</el-button>
+    </el-card>
+    <el-card shadow="never" class="mt10">
+      <video_play :video_url="videoUrl" v-if="videoUrl!=''" :autoplay="true" :loop="true" style="height: 50vh" :video_type="0"/>
+    </el-card>
+  </div>
+</template>
+<script>
+import Video_play from '@/views/components/video/video_play.vue'
+import NexoRadio from '@/views/components/radio/nexo-radio.vue'
+
+export default {
+  name: 'live',
+  components: { NexoRadio, Video_play },
+  data() {
+    return {
+      type: 'douyin',
+      radioOptions: [
+        { label: '抖音', value: 'douyin' },
+        { label: '快手', value: 'kuaishou' },
+        { label: '歪歪', value: 'yy' }
+      ],
+      videoUrl: ''
+    }
+  },
+  methods: {
+    handleSubmit() {
+      console.log('提交视频地址:', this.videoUrl)
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 325 - 7
nexo-ui/src/views/module/douyin/worksAanalysis/index.vue

@@ -15,7 +15,10 @@
               </span>
               </span>
             </el-form-item>
             </el-form-item>
             <el-form-item class="submit-but">
             <el-form-item class="submit-but">
-              <el-button type="primary" icon="el-icon-s-promotion" size="mini" @click="submitForm">提交解析</el-button>
+              <el-button type="primary" icon="el-icon-s-promotion" :loading="subLoading" size="mini"
+                         @click="submitForm"
+              >提交解析
+              </el-button>
               <el-button icon="el-icon-refresh" size="mini" @click="reset">重置地址</el-button>
               <el-button icon="el-icon-refresh" size="mini" @click="reset">重置地址</el-button>
             </el-form-item>
             </el-form-item>
           </el-form>
           </el-form>
@@ -24,12 +27,18 @@
           <div slot="header" class="clearfix">
           <div slot="header" class="clearfix">
             <span>作品信息</span>
             <span>作品信息</span>
           </div>
           </div>
-          <div class="video-info">
+          <div class="video-info" v-if="type === 0">
+            <div v-if="data.desc === undefined" class="empty-state">
+              <svg-icon icon-class="video" class="empty-icon"/>
+              <p class="empty-text">暂无解析数据</p>
+              <p class="empty-hint">请在上方输入视频地址并点击提交解析</p>
+            </div>
+
             <div class="info-header" v-if="data.avatar || data.nickname">
             <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"/>
               <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-meta" v-if="data.nickname || data.signature">
                 <div class="author-name" v-if="data.nickname">{{ data.nickname }}</div>
                 <div class="author-name" v-if="data.nickname">{{ data.nickname }}</div>
-                <div class="author-signature" v-if="data.signature" >{{ data.signature }}</div>
+                <div class="author-signature" v-if="data.signature">{{ data.signature }}</div>
               </div>
               </div>
             </div>
             </div>
 
 
@@ -76,14 +85,81 @@
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>
+          <div class="video-info batch-view" v-if="type === 1">
+            <div v-if="batchData.length === 0" class="empty-state">
+              <svg-icon icon-class="video" class="empty-icon"/>
+              <p class="empty-text">暂无解析数据</p>
+              <p class="empty-hint">请在上方输入视频地址并点击提交解析</p>
+            </div>
+            <div v-else class="batch-grid">
+              <div
+                v-for="(item, index) in batchData"
+                :key="index"
+                class="batch-card"
+              >
+                <div class="card-body">
+                  <div class="author-row" v-if="item.avatar || item.nickname">
+                    <el-avatar :src="item.avatar" :size="36" shape="square" class="card-avatar" v-if="item.avatar"/>
+                    <div class="author-info">
+                      <div class="nickname" v-if="item.nickname">{{ item.nickname }}</div>
+                      <div class="unique-id" v-if="item.unique_id">@{{ item.unique_id }}</div>
+                    </div>
+                  </div>
+
+                  <div class="desc-preview" v-if="item.desc">
+                    {{ item.desc }}
+                  </div>
+                  <div>
+                    <video_play
+                      v-if="item.play_addr !== undefined "
+                      style="height: 300px"
+                      :video_url="item.play_addr"
+                      :poster="item.cover"
+                    ></video_play>
+                    <div v-else class="preview-placeholder">
+                      <svg-icon icon-class="video" class="placeholder-icon"/>
+                      <p class="placeholder-text">暂无可预览的视频</p>
+                    </div>
+                  </div>
+                  <div class="stats-row">
+                    <div class="stat-mini" v-if="item.digg_count !== undefined">
+                      <svg-icon icon-class="like" class="mini-icon"/>
+                      <span class="mini-value">{{ formatNumber(item.digg_count) }}</span>
+                    </div>
+                    <div class="stat-mini" v-if="item.comment_count !== undefined">
+                      <svg-icon icon-class="comment" class="mini-icon"/>
+                      <span class="mini-value">{{ formatNumber(item.comment_count) }}</span>
+                    </div>
+                    <div class="stat-mini" v-if="item.collect_count !== undefined">
+                      <svg-icon icon-class="collect" class="mini-icon"/>
+                      <span class="mini-value">{{ formatNumber(item.collect_count) }}</span>
+                    </div>
+                    <div class="stat-mini" v-if="item.share_count !== undefined">
+                      <svg-icon icon-class="share" class="mini-icon"/>
+                      <span class="mini-value">{{ formatNumber(item.share_count) }}</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
         </el-card>
         </el-card>
       </el-col>
       </el-col>
-      <el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="8">
+      <el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="8" v-if="type === 0">
         <el-card shadow="never">
         <el-card shadow="never">
           <div slot="header" class="clearfix">
           <div slot="header" class="clearfix">
             <span>视频预览</span>
             <span>视频预览</span>
           </div>
           </div>
-          <video_play style="height: 80vh" :video_url="data.play_addr" :poster="data.cover"></video_play>
+          <video_play
+            v-if="data.play_addr !== undefined "
+            style="height: 80vh"
+            :video_url="data.play_addr"
+            :poster="data.cover"
+          ></video_play>
+          <div v-else class="preview-placeholder">
+            <svg-icon icon-class="video" class="placeholder-icon"/>
+            <p class="placeholder-text">暂无可预览的视频</p>
+          </div>
         </el-card>
         </el-card>
       </el-col>
       </el-col>
     </el-row>
     </el-row>
@@ -99,6 +175,7 @@ export default {
   components: { NexoRadio, Video_play },
   components: { NexoRadio, Video_play },
   data() {
   data() {
     return {
     return {
+      subLoading: false,
       type: 0,
       type: 0,
       radioOptions: [
       radioOptions: [
         { label: '孤品 · 深度对谈', value: 0 },
         { label: '孤品 · 深度对谈', value: 0 },
@@ -110,7 +187,8 @@ export default {
           { required: true, message: '请输入需要解析的视频地址', trigger: 'blur' }
           { required: true, message: '请输入需要解析的视频地址', trigger: 'blur' }
         ]
         ]
       },
       },
-      data: {}
+      data: {},
+      batchData: []
     }
     }
   },
   },
   watch: {
   watch: {
@@ -121,12 +199,29 @@ export default {
   },
   },
   methods: {
   methods: {
     submitForm() {
     submitForm() {
+      this.subLoading = true
       console.log('submitForm', this.form)
       console.log('submitForm', this.form)
       this.form.type = this.type
       this.form.type = this.type
+      this.data = {}
+      this.batchData = []
       this.$refs['form'].validate((valid) => {
       this.$refs['form'].validate((valid) => {
         if (valid) {
         if (valid) {
           parse(this.form).then(response => {
           parse(this.form).then(response => {
-            this.data = response.data
+            if (this.type === 0) {
+              this.data = response.data
+              this.$message.success('解析成功')
+            } else if (this.type === 1) {
+              this.batchData = Array.isArray(response.data) ? response.data : [response.data]
+              const successCount = this.batchData.filter(item => !item.error).length
+              const failCount = this.batchData.filter(item => item.error).length
+              if (failCount > 0) {
+                this.$message.warning(`解析完成:成功 ${successCount} 个,失败 ${failCount} 个`)
+              } else {
+                this.$message.success(`成功解析 ${successCount} 个视频`)
+              }
+            }
+          }).finally(() => {
+            this.subLoading = false
           })
           })
         } else {
         } else {
           console.log('error submit!!')
           console.log('error submit!!')
@@ -139,6 +234,18 @@ export default {
         type: 0,
         type: 0,
         videoUrl: 'https://www.douyin.com/jingxuan?modal_id=7643814124878656421'
         videoUrl: 'https://www.douyin.com/jingxuan?modal_id=7643814124878656421'
       }
       }
+      this.data = {}
+      this.batchData = []
+    },
+    formatNumber(num) {
+      if (num === undefined || num === null) return '0'
+      if (num >= 10000) {
+        return (num / 10000).toFixed(1) + 'w'
+      }
+      if (num >= 1000) {
+        return (num / 1000).toFixed(1) + 'k'
+      }
+      return num.toString()
     }
     }
   }
   }
 }
 }
@@ -320,4 +427,215 @@ export default {
     }
     }
   }
   }
 }
 }
+
+.preview-indicator {
+  float: right;
+  font-size: 13px;
+  color: #409eff;
+  font-weight: 600;
+}
+
+.preview-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 80vh;
+  background: linear-gradient(135deg, rgba(64, 158, 255, 0.03) 0%, rgba(103, 194, 58, 0.03) 100%);
+  border-radius: 8px;
+
+  .placeholder-icon {
+    width: 80px;
+    height: 80px;
+    color: #dcdfe6;
+    margin-bottom: 16px;
+  }
+
+  .placeholder-text {
+    font-size: 15px;
+    color: #909399;
+    margin: 0;
+  }
+}
+
+.batch-view {
+  padding: 0;
+}
+
+.batch-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 16px;
+  padding: 4px;
+
+  .batch-card {
+    background: #ffffff;
+    border: 1px solid #e4e7ed;
+    border-radius: 12px;
+    padding: 16px;
+    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    position: relative;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+
+    &:hover {
+      border-color: #0138c6;
+      box-shadow: 0 6px 20px rgba(64, 158, 255, 0.15);
+      transform: translateY(-4px);
+    }
+
+    .card-body {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+
+      .author-row {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px;
+        background: linear-gradient(135deg, rgba(64, 158, 255, 0.04) 0%, rgba(103, 194, 58, 0.04) 100%);
+        border-radius: 8px;
+
+        .card-avatar {
+          border-radius: 8px;
+          box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+          flex-shrink: 0;
+        }
+
+        .author-info {
+          flex: 1;
+          min-width: 0;
+
+          .nickname {
+            font-size: 14px;
+            font-weight: 600;
+            color: #303133;
+            line-height: 1.3;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+
+          .unique-id {
+            font-size: 11px;
+            color: #409eff;
+            margin-top: 2px;
+          }
+        }
+      }
+
+      .desc-preview {
+        font-size: 12px;
+        color: #606266;
+        line-height: 1.6;
+        max-height: 60px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        display: -webkit-box;
+        -webkit-line-clamp: 3;
+        -webkit-box-orient: vertical;
+        word-break: break-word;
+        padding: 8px;
+        background: rgba(245, 247, 250, 0.5);
+        border-radius: 6px;
+      }
+
+      .stats-row {
+        display: grid;
+        grid-template-columns: repeat(2, 1fr);
+        gap: 6px;
+
+        .stat-mini {
+          display: flex;
+          align-items: center;
+          gap: 6px;
+          padding: 6px 8px;
+          background: linear-gradient(135deg, rgba(64, 158, 255, 0.05) 0%, rgba(103, 194, 58, 0.05) 100%);
+          border: 1px solid rgba(64, 158, 255, 0.1);
+          border-radius: 6px;
+          transition: all 0.2s ease;
+
+          &:hover {
+            transform: scale(1.02);
+            border-color: rgba(64, 158, 255, 0.25);
+          }
+
+          .mini-icon {
+            width: 16px;
+            height: 16px;
+            color: #606266;
+            flex-shrink: 0;
+          }
+
+          .mini-value {
+            font-size: 13px;
+            font-weight: 600;
+            color: #303133;
+            font-family: 'Barlow-Medium', 'HarmonyOS_Sans_SC_Medium', sans-serif;
+          }
+        }
+      }
+    }
+  }
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+  text-align: center;
+
+  .empty-icon {
+    width: 80px;
+    height: 80px;
+    color: #dcdfe6;
+    margin-bottom: 16px;
+  }
+
+  .empty-text {
+    font-size: 16px;
+    color: #909399;
+    margin: 0 0 8px 0;
+    font-weight: 500;
+  }
+
+  .empty-hint {
+    font-size: 13px;
+    color: #c0c4cc;
+    margin: 0;
+  }
+}
+
+@keyframes pulse {
+  0%, 100% {
+    box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4);
+  }
+  50% {
+    box-shadow: 0 0 0 8px rgba(64, 158, 255, 0);
+  }
+}
+
+@media (max-width: 1200px) {
+  .batch-grid {
+    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+  }
+}
+
+@media (max-width: 768px) {
+  .batch-grid {
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 12px;
+  }
+}
+
+@media (max-width: 480px) {
+  .batch-grid {
+    grid-template-columns: 1fr;
+  }
+}
 </style>
 </style>

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