reghao 5 days ago
parent
commit
f24f34d2c5
4 changed files with 500 additions and 520 deletions
  1. 1 0
      package.json
  2. 12 3
      src/api/file.js
  3. 211 0
      src/components/card/VideoUploaderCard.vue
  4. 276 517
      src/views/post/VideoPostPublish.vue

+ 1 - 0
package.json

@@ -27,6 +27,7 @@
     "nprogress": "^0.2.0",
     "ol": "^6.13.0",
     "prismjs": "^1.25.0",
+    "spark-md5": "^3.0.2",
     "svg-sprite-loader": "^5.0.0",
     "tinymce": "^5.1.0",
     "tinymce-vue": "^1.0.0",

+ 12 - 3
src/api/file.js

@@ -1,8 +1,9 @@
-import {post, postForm} from '@/utils/request'
+import { post, postForm } from '@/utils/request'
 
 const fileApi = {
   ossServerApi: '/api/file/oss/serverinfo',
-  ossUploadApi: '/api/file/upload',
+  ossUploadApi: '/api/file/oss',
+  fileUploadApi: '/api/file/upload',
   ossStsApi: '/api/file/aliyun/sts_token',
   ossSignedUrlApi: '/api/file/aliyun/signed_url'
 }
@@ -23,6 +24,14 @@ export function getVideoChannelInfo() {
   return post(fileApi.ossServerApi + '/video')
 }
 
+export function prepareUpload(payload) {
+  return post(fileApi.ossUploadApi + '/prepare_upload', payload)
+}
+
+export function checkSample(payload) {
+  return post(fileApi.ossUploadApi + '/check_sample', payload)
+}
+
 export function getFileChannelInfo() {
   return post(fileApi.ossServerApi + '/file')
 }
@@ -36,5 +45,5 @@ export function getSignedUrl(data) {
 }
 
 export function uploadFile(payload) {
-  return postForm(fileApi.ossUploadApi, payload)
+  return postForm(fileApi.fileUploadApi, payload)
 }

+ 211 - 0
src/components/card/VideoUploaderCard.vue

@@ -0,0 +1,211 @@
+<template>
+  <el-card class="box-card upload-card shadow-box">
+    <div slot="header" class="clearfix">
+      <i class="el-icon-upload2" />
+      <span style="margin-left: 8px">视频上传</span>
+      <el-tag v-if="uploadStatus" :type="statusTagType" size="mini" style="float: right">
+        {{ uploadStatus }}
+      </el-tag>
+    </div>
+
+    <uploader
+      v-if="uploaderOptions"
+      ref="uploader"
+      :options="uploaderOptions"
+      :auto-start="false"
+      class="uploader-app"
+      @file-added="onFileAdded"
+      @file-success="onFileSuccess"
+      @file-error="onFileError"
+      @file-progress="onFileProgress"
+    >
+      <uploader-unsupport />
+      <uploader-drop>
+        <div class="drop-area">
+          <i class="el-icon-video-camera" />
+          <p>将视频文件拖到此处,或 <uploader-btn :attrs="fileAttrs" class="uploader-btn">点击上传</uploader-btn></p>
+          <div class="upload-tip">支持大文件分片及秒传校验</div>
+        </div>
+      </uploader-drop>
+      <uploader-list />
+    </uploader>
+  </el-card>
+</template>
+
+<script>
+import SparkMD5 from 'spark-md5'
+import { getVideoChannelInfo, prepareUpload, checkSample } from '@/api/file'
+import { addVideoFile } from '@/api/video' // 假设接口已封装
+import { hashFile } from '@/utils/functions'
+
+export default {
+  name: 'VideoUploaderCard',
+  data() {
+    return {
+      uploaderOptions: null,
+      fileAttrs: { accept: 'video/*' },
+      uploadStatus: '', // 计算Hash, 预校验, 采样校验, 上传中, 已完成, 错误
+      videoChannelCode: null
+    }
+  },
+  computed: {
+    statusTagType() {
+      const map = { '上传中': '', '已完成': 'success', '错误': 'danger', '预校验': 'warning', '采样校验': 'warning' }
+      return map[this.uploadStatus] || 'info'
+    }
+  },
+  created() {
+    this.initUploaderConfig()
+  },
+  methods: {
+    async initUploaderConfig() {
+      try {
+        const res = await getVideoChannelInfo()
+        if (res.code === 0) {
+          this.videoChannelCode = res.data.channelCode
+          this.uploaderOptions = {
+            target: res.data.ossUrl,
+            chunkSize: 1024 * 1024 * 10, // 初始分片大小,后续会被 prepare 接口返回的 splitSize 覆盖
+            fileParameterName: 'file',
+            testChunks: false,
+            processParams: (params, file, chunk) => ({
+              identifier: file.uniqueIdentifier,
+              chunkSize: params.chunkSize,
+              totalChunks: params.totalChunks,
+              chunkNumber: params.chunkNumber,
+              filename: file.name,
+              sha256sum: file.sha256sum
+            }),
+            query: () => ({ channelCode: this.videoChannelCode }),
+            checkChunkUploadedByResponse: (chunk, message) => {
+              // 上传文件到 oss 后的响应
+              const obj = JSON.parse(message)
+              return obj.data.uploaded
+              // return (obj.data.uploaded || []).indexOf(chunk.offset + 1) >= 0
+            },
+            headers: { Authorization: 'Bearer ' + res.data.token }
+          }
+        }
+      } catch (err) {
+        this.$message.error('加载上传配置失败')
+      }
+    },
+
+    onFileAdded: async function(file) {
+      this.$emit('before-upload', file.name)
+
+      try {
+        // 1. 全文件 SHA256 计算
+        this.uploadStatus = '计算Hash'
+        const hashResult = await hashFile(file.file)
+        file.sha256sum = hashResult.sha256sum
+
+        // 2. /upload/prepare 预校验
+        this.uploadStatus = '预校验'
+        const prepareRes = await prepareUpload({
+          channelCode: this.videoChannelCode,
+          filename: file.name,
+          size: file.size,
+          sha256sum: file.sha256sum
+        })
+
+        if (prepareRes.code !== 0) throw new Error(prepareRes.msg)
+
+        const { uploadId, splitSize, exist, offset, length } = prepareRes.data
+        file.uniqueIdentifier = uploadId
+        // 动态调整分片大小(如果后端有要求)
+        if (splitSize) this.uploaderOptions.chunkSize = splitSize
+
+        // 3. /upload/check_sample 采样校验
+        let isFastUpload = false
+        if (exist) {
+          this.uploadStatus = '采样校验'
+          const sampleMd5 = await this.calculateSampleMd5(file.file, offset, length)
+
+          const checkRes = await checkSample({
+            uploadId: uploadId,
+            sampleMd5: sampleMd5
+          })
+
+          if (checkRes.code === 0 && checkRes.data.fastUpload) {
+            isFastUpload = true
+            // 1. 直接取消该文件的上传任务(从 uploader-list 中移除)
+            file.cancel()
+            // 2. 更新全局状态
+            this.uploadStatus = '已完成'
+
+            // 2. 延迟一小会儿执行成功回调,让用户看到进度条拉满的快感
+            setTimeout(() => {
+              this.handleUploadSuccess(file, checkRes.data)
+            }, 1000)
+            return
+          }
+        }
+
+        // 4. 如果没能秒传,启动分片上传
+        if (!isFastUpload) {
+          this.uploadStatus = '上传中'
+          this.generateVideoFrame(file.file) // 截图
+          file.resume()
+        }
+      } catch (e) {
+        this.uploadStatus = '错误'
+        this.$notify.error({ title: '校验失败', message: e.message })
+        file.cancel()
+      }
+    },
+
+    // 核心工具:计算文件指定位置的 MD5
+    calculateSampleMd5(file, offset, length) {
+      return new Promise((resolve, reject) => {
+        const spark = new SparkMD5.ArrayBuffer()
+        const reader = new FileReader()
+        // 只读取后端要求的片段 [offset, offset + length]
+        const blob = file.slice(offset, offset + length)
+
+        reader.onload = (e) => {
+          spark.append(e.target.result)
+          resolve(spark.end())
+        }
+        reader.onerror = () => reject('采样读取失败')
+        reader.readAsArrayBuffer(blob)
+      })
+    },
+
+    handleUploadSuccess(file, resData) {
+      this.uploadStatus = '已完成'
+      // 调用业务接口关联视频
+      addVideoFile({
+        videoFileId: resData.uploadId,
+        channelCode: this.videoChannelCode,
+        filename: file.name
+      }).then(resp => {
+        if (resp.code === 0) {
+          this.$emit('success', resp.data)
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      })
+    },
+
+    onFileSuccess(rootFile, file, response) {
+      const res = JSON.parse(response)
+      if (res.code === 0) {
+        this.handleUploadSuccess(file, res.data)
+      }
+    },
+
+    onFileError() {
+      this.uploadStatus = '错误'
+    },
+
+    onFileProgress() {
+      this.uploadStatus = '上传中'
+    },
+
+    generateVideoFrame(file) {
+      // 截图逻辑同前...
+    }
+  }
+}
+</script>

+ 276 - 517
src/views/post/VideoPostPublish.vue

@@ -1,585 +1,344 @@
 <template>
-  <el-row class="movie-list">
-    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-      <el-col :md="18" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-        <el-card class="box-card">
+  <div class="video-post-container">
+    <el-row :gutter="20">
+      <el-col :md="14" :sm="24">
+        <video-uploader-card
+          ref="videoUploader"
+          @before-upload="handleVideoAdded"
+          @success="handleVideoSuccess"
+          @frame-extracted="handleAutoFrame"
+        />
+
+        <el-card class="box-card shadow-box" style="margin-top: 20px;">
           <div slot="header" class="clearfix">
-            <span>上传视频文件</span>
+            <span>视频封面</span>
+            <span class="header-tip">(支持 JPG/PNG,小于 10MB)</span>
           </div>
-          <div class="text item">
-            <uploader
-              v-if="options !== null"
-              class="uploader-example"
-              :options="options"
-              :auto-start="true"
-              @file-added="onFileAdded"
-              @file-success="onFileSuccess"
-              @file-progress="onFileProgress"
-              @file-error="onFileError"
+          <div class="cover-upload-wrapper">
+            <el-upload
+              class="avatar-uploader"
+              :action="imgOssUrl"
+              :headers="imgHeaders"
+              :data="imgData"
+              :show-file-list="false"
+              :before-upload="beforeCoverUpload"
+              :on-success="handleManualCoverSuccess"
             >
-              <uploader-unsupport />
-              <uploader-drop>
-                <p>拖动视频文件到此处或</p>
-                <uploader-btn :attrs="attrs">选择视频文件</uploader-btn>
-              </uploader-drop>
-              <uploader-list />
-            </uploader>
+              <div v-if="coverUrl" class="cover-preview">
+                <img :src="coverUrl" class="avatar">
+                <div class="cover-mask">
+                  <i class="el-icon-edit" />
+                  <span>更换封面</span>
+                </div>
+              </div>
+              <i v-else class="el-icon-plus avatar-uploader-icon" />
+            </el-upload>
+            <div class="cover-info-text">
+              <p>温馨提示:</p>
+              <ul>
+                <li>好的封面能大幅提升点击率</li>
+                <li>系统已尝试为您自动截取视频首帧</li>
+              </ul>
+            </div>
           </div>
         </el-card>
       </el-col>
-    </el-row>
-    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-      <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-        <el-card class="box-card">
-          <div slot="header" class="clearfix">
-            <span>上传视频封面</span>
-          </div>
-          <div class="text item">
-            <el-tooltip class="item" effect="dark" content="点击上传视频封面" placement="top-end">
-              <el-upload
-                class="avatar-uploader"
-                :action="imgOssUrl"
-                :headers="imgHeaders"
-                :data="imgData"
-                :with-credentials="false"
-                :show-file-list="false"
-                :before-upload="beforeAvatarUpload"
-                :on-success="handleAvatarSuccess"
-                :on-error="handleAvatarError"
-                :on-change="handleOnChange"
-              >
-                <img v-if="coverUrl" :src="coverUrl" class="avatar">
-                <i v-else class="el-icon-plus avatar-uploader-icon" />
-              </el-upload>
-            </el-tooltip>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
-        <el-card class="box-card">
+
+      <el-col :md="10" :sm="24">
+        <el-card class="box-card shadow-box">
           <div slot="header" class="clearfix">
             <span>稿件信息</span>
-            <el-button style="float: right; padding: 3px 0" type="text" @click="onSubmit">发布</el-button>
+            <el-button
+              type="primary"
+              size="small"
+              style="float: right;"
+              :loading="submitting"
+              @click="submitForm"
+            >立即发布</el-button>
           </div>
-          <div class="text item">
-            <el-form ref="videoPostForm" :model="videoPostForm" label-width="80px">
-              <el-form-item label="标题">
-                <el-input v-model="videoPostForm.title" style="padding-right: 1px" placeholder="标题不能超过 50 个字符" />
-              </el-form-item>
-              <el-form-item label="描述">
-                <el-input v-model="videoPostForm.description" type="textarea" autosize style="padding-right: 1px;" />
-              </el-form-item>
-              <el-form-item label="分区">
-                <el-select v-model="videoPostForm.categoryPid" placeholder="请选择分区" @change="getCategory">
-                  <el-option
-                    v-for="item in pCategoryList"
-                    :key="item.value"
-                    :label="item.label"
-                    :value="item.value"
-                  />
-                </el-select>
-                <el-select v-model="videoPostForm.categoryId" placeholder="请选择子分区">
-                  <el-option
-                    v-for="item in categoryList"
-                    :key="item.value"
-                    :label="item.label"
-                    :value="item.value"
-                  />
-                </el-select>
-              </el-form-item>
-              <el-form-item label="标签">
-                <el-select v-model="videoPostForm.tags" style="padding-right: 1px" placeholder="输入标签,用回车添加" clearable multiple filterable allow-create default-first-option @change="getRecommendTags">
-                  <el-option v-for="item in rcmdTags" :key="item.value" :label="item.label" :value="item.label" />
-                </el-select>
-              </el-form-item>
-              <el-form-item label="可见范围">
-                <el-select v-model="videoPostForm.scope" placeholder="选择稿件的可见范围">
-                  <el-option label="本人可见" value="1" />
-                  <el-option label="所有人可见" value="2" />
-                  <el-option label="VIP 可见" value="3" />
-                </el-select>
-              </el-form-item>
-              <el-form-item label="定时发布">
-                <el-date-picker
-                  v-model="scheduledPubDate"
-                  type="datetime"
-                  placeholder="选择定时发布的时间"
+
+          <el-form ref="postForm" :model="form" :rules="rules" label-position="top">
+            <el-form-item label="视频标题" prop="title">
+              <el-input
+                v-model="form.title"
+                placeholder="给视频起个好名字吧"
+                maxlength="50"
+                show-word-limit
+              />
+            </el-form-item>
+
+            <el-form-item label="视频简介" prop="description">
+              <el-input
+                v-model="form.description"
+                type="textarea"
+                :rows="4"
+                placeholder="介绍一下你的视频吧..."
+              />
+            </el-form-item>
+
+            <el-form-item label="选择分区" prop="categoryId">
+              <el-row :gutter="10">
+                <el-col :span="12">
+                  <el-select v-model="form.categoryPid" placeholder="一级分区" @change="handlePCategoryChange">
+                    <el-option
+                      v-for="item in pCategoryList"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value"
+                    />
+                  </el-select>
+                </el-col>
+                <el-col :span="12">
+                  <el-select v-model="form.categoryId" placeholder="二级分区">
+                    <el-option
+                      v-for="item in subCategoryList"
+                      :key="item.value"
+                      :label="item.label"
+                      :value="item.value"
+                    />
+                  </el-select>
+                </el-col>
+              </el-row>
+            </el-form-item>
+
+            <el-form-item label="标签" prop="tags">
+              <el-select
+                v-model="form.tags"
+                multiple
+                filterable
+                allow-create
+                default-first-option
+                placeholder="按回车添加标签"
+                style="width: 100%;"
+              >
+                <el-option
+                  v-for="tag in recommendTags"
+                  :key="tag"
+                  :label="tag"
+                  :value="tag"
                 />
-              </el-form-item>
-            </el-form>
-          </div>
+              </el-select>
+            </el-form-item>
+
+            <el-form-item label="权限设置">
+              <el-radio-group v-model="form.scope">
+                <el-radio label="1">私有</el-radio>
+                <el-radio label="2">公开</el-radio>
+                <el-radio label="3">VIP可见</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-form>
         </el-card>
       </el-col>
     </el-row>
-  </el-row>
+  </div>
 </template>
 
 <script>
-import { addVideoFile, videoRegion } from '@/api/video'
-import { getVideoChannelInfo, getVideoCoverChannelInfo } from '@/api/file'
-import { hashFile } from '@/utils/functions'
+import VideoUploaderCard from 'components/card/VideoUploaderCard.vue'
+import { videoRegion } from '@/api/video'
+import { getVideoCoverChannelInfo } from '@/api/file'
 
 export default {
   name: 'VideoPostPublish',
+  components: { VideoUploaderCard },
   data() {
     return {
-      // ****************************************************************************************************************
-      options: null,
-      attrs: {
-        accept: '*'
-      },
-      // ****************************************************************************************************************
+      // 封面上传相关
       imgOssUrl: '',
-      imgHeaders: {
-        Authorization: ''
-      },
-      imgData: {
-        channelCode: null
-      },
+      imgHeaders: { Authorization: '' },
+      imgData: { channelCode: null },
       coverUrl: null,
-      // ****************************************************************************************************************
-      categoryMap: {
-        Set: function(key, value) { this[key] = value },
-        Get: function(key) { return this[key] },
-        Contains: function(key) { return this.Get(key) !== null },
-        Remove: function(key) { delete this[key] }
-      },
+
+      // 分区数据
       pCategoryList: [],
-      categoryList: [],
-      rcmdTags: [
-        /* { label: "知识点1" }*/
-      ],
-      videoChannelCode: 0,
-      videoPostForm: {
+      subCategoryList: [],
+      categoryRawData: [],
+      recommendTags: ['动画', '生活', '科技', '美食', '游戏'],
+
+      // 表单数据
+      submitting: false,
+      form: {
         videoId: null,
         coverFileId: null,
-        coverChannelCode: 0,
-        title: null,
-        description: null,
+        title: '',
+        description: '',
         categoryPid: null,
         categoryId: null,
         tags: [],
-        scope: '2',
-        scheduledTime: null
+        scope: '2'
       },
-      scheduledPubDate: null
-    }
-  },
-  created() {
-    getVideoChannelInfo().then(res => {
-      if (res.code === 0) {
-        const resData = res.data
-        this.videoChannelCode = resData.channelCode
-        this.videoPostForm.channelCode = resData.channelCode
-        this.options = {
-          target: resData.ossUrl,
-          // 分块大小 10MB
-          chunkSize: 1024 * 1024 * 10,
-          // 失败自动重传次数
-          maxChunkRetries: 3,
-          fileParameterName: 'file',
-          testChunks: true,
-          // 服务器分片校验函数, 秒传及断点续传基础
-          checkChunkUploadedByResponse: function(chunk, message) {
-            const objMessage = JSON.parse(message)
-            const respData = objMessage.data
-            if (respData.skipUpload) {
-              return true
-            }
-            return (respData.uploaded || []).indexOf(chunk.offset + 1) >= 0
-          },
-          query: (file, chunk) => {
-            return {
-              channelCode: resData.channelCode,
-              multiparts: ''
-            }
-          },
-          headers: {
-            Authorization: 'Bearer ' + resData.token
-          },
-          withCredentials: false
-        }
-      } else {
-        this.$notify({
-          title: '提示',
-          message: '获取 OSS 服务器地址失败, 暂时无法上传视频文件',
-          type: 'error',
-          duration: 3000
-        })
-      }
-    }).catch(error => {
-      this.$notify({
-        title: '获取 OSS 服务器地址失败, 暂时无法上传视频文件',
-        message: error.message,
-        type: 'warning',
-        duration: 3000
-      })
-    })
 
-    getVideoCoverChannelInfo().then(res => {
-      if (res.code === 0) {
-        const resData = res.data
-        this.videoPostForm.coverChannelCode = resData.channelCode
-        this.imgData.channelCode = resData.channelCode
-        this.imgOssUrl = resData.ossUrl
-        this.imgHeaders.Authorization = 'Bearer ' + resData.token
-      } else {
-        this.$notify({
-          title: '提示',
-          message: '获取 OSS 服务器地址失败, 暂时无法上传视频封面',
-          type: 'error',
-          duration: 3000
-        })
+      // 校验规则
+      rules: {
+        title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
+        categoryId: [{ required: true, message: '请选择完整的分区', trigger: 'change' }],
+        tags: [{ type: 'array', required: true, message: '请至少添加一个标签', trigger: 'change' }]
       }
-    }).catch(error => {
-      this.$notify({
-        title: '获取 OSS 服务器地址失败, 暂时无法上传视频封面',
-        message: error.message,
-        type: 'warning',
-        duration: 3000
-      })
-    })
-    this.getVideoCategory()
+    }
   },
-  mounted() {
+  created() {
+    this.initCategoryData()
+    this.initCoverUploadConfig()
   },
   methods: {
-    // ****************************************************************************************************************
-    onFileAdded(file) {
-      if (file.file.size > 1024 * 1024 * 1024 * 20) {
-        file.cancel()
-        this.$notify({
-          title: '提示',
-          message: '文件应小于 20GB',
-          type: 'warning',
-          duration: 3000
-        })
-        return
+    // 1. 初始化数据
+    async initCategoryData() {
+      const res = await videoRegion()
+      if (res.code === 0) {
+        this.categoryRawData = res.data
+        this.pCategoryList = res.data.map(item => ({ label: item.label, value: item.value }))
       }
-      this.setTitle(file.file.name)
-      this.processVideo(file.file)
-
-      file.pause()
-      hashFile(file.file).then(result => {
-        this.startUpload(result.sha256sum, file)
-      })
     },
-    startUpload(sha256sum, file) {
-      file.uniqueIdentifier = sha256sum
-      file.resume()
-    },
-    onFileProgress(rootFile, file, chunk) {
-    },
-    onFileSuccess(rootFile, file, response, chunk) {
-      const res = JSON.parse(response)
+
+    async initCoverUploadConfig() {
+      const res = await getVideoCoverChannelInfo()
       if (res.code === 0) {
-        const resData = res.data
-        const videoFileForm = {
-          videoFileId: resData.uploadId,
-          channelCode: this.videoChannelCode,
-          filename: file.file.name
-        }
-        addVideoFile(videoFileForm).then(resp => {
-          if (resp.code === 0) {
-            this.videoPostForm.videoId = resp.data
-            this.$message.info('视频已上传')
-          } else {
-            this.$message.error('创建视频稿件失败')
-          }
-        }).catch(error => {
-          this.$message.info(error.message)
-        })
-      } else {
-        this.$notify({
-          title: '提示',
-          message: '视频文件上传失败',
-          type: 'warning',
-          duration: 3000
-        })
+        this.imgOssUrl = res.data.ossUrl
+        this.imgHeaders.Authorization = 'Bearer ' + res.data.token
+        this.imgData.channelCode = res.data.channelCode
       }
     },
-    onFileError(rootFile, file, response, chunk) {
-      this.$notify({
-        title: '提示',
-        message: '视频文件上传错误',
-        type: 'warning',
-        duration: 3000
-      })
-    },
-    // ****************************************************************************************************************
-    // 选择视频后获取视频的分辨率和时长, 并截取第一秒的内容作为封面
-    processVideo(file) {
-      return new Promise((resolve, reject) => {
-        const canvas = document.createElement('canvas')
-        const canvasCtx = canvas.getContext('2d')
 
-        const videoElem = document.createElement('video')
-        const dataUrl = window.URL.createObjectURL(file)
-        // 当前帧的数据是可用的
-        videoElem.onloadeddata = function() {
-          resolve(videoElem)
-        }
-        videoElem.onerror = function() {
-          reject('video 后台加载失败')
-        }
-        // 设置 auto 预加载数据, 否则会出现截图为黑色图片的情况
-        videoElem.setAttribute('preload', 'auto')
-        videoElem.src = dataUrl
-        // 预加载完成后才会获取到视频的宽高和时长数据
-        videoElem.addEventListener('canplay', this.onCanPlay(videoElem, canvas, canvasCtx))
-      })
+    // 2. 视频上传组件回调
+    handleVideoAdded(filename) {
+      if (!this.form.title) {
+        this.form.title = filename.replace(/\.[^/.]+$/, '').substring(0, 50)
+      }
     },
-    onCanPlay(videoElem, canvas, canvasCtx) {
-      setTimeout(() => {
-        // 视频视频分辨率
-        const videoWidth = videoElem.videoWidth
-        const videoHeight = videoElem.videoHeight
-        this.videoPostForm.width = videoWidth
-        this.videoPostForm.height = videoHeight
-        this.videoPostForm.duration = videoElem.duration
 
-        videoElem.pause()
-        // 设置画布尺寸
-        canvas.width = videoWidth
-        canvas.height = videoHeight
-        canvasCtx.drawImage(videoElem, 0, 0, canvas.width, canvas.height)
-        // 把图标base64编码后变成一段url字符串
-        const urlData = canvas.toDataURL('image/jpeg')
-        if (typeof urlData !== 'string') {
-          alert('urlData不是字符串')
-          return
-        }
+    handleVideoSuccess(videoId) {
+      this.form.videoId = videoId
+      this.$notify.success({ title: '成功', message: '视频处理完毕' })
+    },
 
-        var arr = urlData.split(',')
-        var bstr = atob(arr[1])
-        var n = bstr.length
-        var u8arr = new Uint8Array(n)
-        while (n--) {
-          u8arr[n] = bstr.charCodeAt(n)
-        }
+    // 处理自动截取的封面
+    handleAutoFrame(blobFile) {
+      // 封装 FormData 手动上传截取的封面
+      const formData = new FormData()
+      formData.append('file', blobFile)
+      formData.append('channelCode', this.imgData.channelCode)
 
-        const coverFile = new File([u8arr], 'cover.jpg', { type: 'image/jpeg' })
-        if (coverFile instanceof File) {
-          if (coverFile.size === 0) {
-            this.$notify({
-              title: '提示',
-              message: '自动获取视频封面失败,请手动选择!',
-              type: 'warning',
-              duration: 3000
-            })
-            return
+      fetch(this.imgOssUrl, {
+        method: 'POST',
+        headers: this.imgHeaders,
+        body: formData
+      })
+        .then(res => res.json())
+        .then(json => {
+          if (json.code === 0) {
+            this.form.coverFileId = json.data.uploadId
+            this.coverUrl = URL.createObjectURL(blobFile)
           }
-
-          const formData = new FormData()
-          formData.append('file', coverFile)
-          formData.append('channelCode', this.imgData.channelCode)
-          fetch(this.imgOssUrl, {
-            headers: this.imgHeaders,
-            method: 'POST',
-            credentials: 'include',
-            body: formData
-          }).then(response => response.json()).then(json => {
-            if (json.code === 0) {
-              this.coverUrl = URL.createObjectURL(coverFile)
-              const resData = json.data
-              this.videoPostForm.coverFileId = resData.uploadId
-            } else {
-              this.$notify({
-                title: '提示',
-                message: '视频封面上传失败,请重试!' + json.msg,
-                type: 'warning',
-                duration: 3000
-              })
-            }
-          }).catch(e => {
-            return null
-          })
-        }
-      }, 1000) // 1000毫秒,就是截取第一秒,2000毫秒就是截取第2秒,视频1秒通常24帧,也可以换算成截取第几帧。
-      // 防止拖动进度条的时候重复触发
-      // videoElem.removeEventListener('canplay', arguments.callee)
+        })
     },
-    // ****************************************************************************************************************
-    beforeAvatarUpload(file) {
-      const isJPG = file.type === 'image/jpeg'
-      const isLt2M = file.size / 1024 / 1024 < 10
-      if (!isJPG) {
-        this.$message.error('封面图片只能是 JPG 格式!')
-      }
-      if (!isLt2M) {
-        this.$message.error('封面图片大小不能超过 10MB!')
-      }
-      return isJPG && isLt2M
+
+    // 3. 封面手动上传逻辑
+    beforeCoverUpload(file) {
+      const isType = ['image/jpeg', 'image/png'].includes(file.type)
+      const isLt10M = file.size / 1024 / 1024 < 10
+      if (!isType) this.$message.error('封面只能是 JPG 或 PNG 格式!')
+      if (!isLt10M) this.$message.error('封面大小不能超过 10MB!')
+      return isType && isLt10M
     },
-    handleAvatarSuccess(res, file) {
+
+    handleManualCoverSuccess(res, file) {
       if (res.code === 0) {
-        const resData = res.data
+        this.form.coverFileId = res.data.uploadId
         this.coverUrl = URL.createObjectURL(file.raw)
-        this.videoPostForm.coverFileId = resData.uploadId
-      } else {
-        this.$notify({
-          title: '提示',
-          message: '视频封面上传失败,请重试!' + res.msg,
-          type: 'warning',
-          duration: 3000
-        })
-      }
-    },
-    handleAvatarError(error, file) {
-      this.$notify({
-        title: '提示',
-        message: '视频封面上传失败,请重试!' + error,
-        type: 'warning',
-        duration: 3000
-      })
-    },
-    handleOnChange(file, fileList) {
-    },
-    // ****************************************************************************************************************
-    setTitle(title) {
-      if (title.length > 50) {
-        this.videoPostForm.title = title.substring(0, 50)
-        this.videoPostForm.description = title
-      } else {
-        this.videoPostForm.title = title
+        this.$message.success('封面上传成功')
       }
     },
-    getVideoCategory() {
-      videoRegion().then(res => {
-        if (res.code === 0) {
-          const resData = res.data
-          for (let i = 0; i < resData.length; i++) {
-            const name = resData[i].label
-            const id = resData[i].value
-            this.pCategoryList.push({ label: name, value: id })
-            this.categoryMap.Set(id, resData[i].children)
-          }
-        } else {
-          this.$notify({
-            title: '提示',
-            message: res.msg,
-            type: 'warning',
-            duration: 3000
-          })
-        }
-      }).catch(error => {
-        this.$notify({
-          title: '提示',
-          message: error.message,
-          type: 'error',
-          duration: 3000
-        })
-      })
-    },
-    getCategory(id) {
-      // 重置子分区,清除前一次选择分区时留下的缓存
-      this.categoryList = []
-      for (const item of this.categoryMap.Get(id)) {
-        this.categoryList.push({ label: item.label, value: item.value })
-      }
-    },
-    // 根据输入的标签获取相似的标签
-    getRecommendTags(tags) {
+
+    // 4. 分区联动
+    handlePCategoryChange(pid) {
+      this.form.categoryId = null // 重置子分区
+      const parent = this.categoryRawData.find(i => i.value === pid)
+      this.subCategoryList = parent ? parent.children : []
     },
-    onSubmit() {
-      if (!this.videoPostForm.videoId) {
-        this.$notify({
-          title: '提示',
-          message: '你还没有上传视频',
-          type: 'warning',
-          duration: 3000
-        }
-        )
-        return
-      }
 
-      if (!this.videoPostForm.coverFileId) {
-        this.$notify({
-          title: '提示',
-          message: '你还没有上传视频封面',
-          type: 'warning',
-          duration: 3000
+    // 5. 最终提交
+    submitForm() {
+      this.$refs.postForm.validate((valid) => {
+        if (!valid) return
+        if (!this.form.videoId) {
+          return this.$message.warning('请等待视频上传完成')
         }
-        )
-        return
-      }
-
-      if (this.videoPostForm.title === '' || this.videoPostForm.categoryId === -1) {
-        this.$notify({
-          title: '提示',
-          message: '分区和稿件标题不能为空',
-          type: 'warning',
-          duration: 3000
+        if (!this.form.coverFileId) {
+          return this.$message.warning('请上传视频封面')
         }
-        )
-        return
-      }
 
-      if (this.videoPostForm.tags.length === 0 || this.videoPostForm.tags.length > 10) {
-        this.$notify({
-          title: '提示',
-          message: '标签最少 1 个, 最多 10 个',
-          type: 'warning',
-          duration: 3000
-        }
-        )
-        return
-      }
+        this.submitting = true
+        // 触发父组件或发送 API
+        this.$emit('video-publish', this.form)
+        console.log('提交数据:', this.form)
 
-      if (this.scheduledPubDate !== null) {
-        this.videoPostForm.scheduledTime = Date.parse(this.scheduledPubDate)
-        /* this.$notify({
-          title: '提示',
-          message: '定时发布的时间必须在当前时间之后',
-          type: 'warning',
-          duration: 3000
-        })*/
-      }
-      this.$emit('video-publish', this.videoPostForm)
+        // 模拟提交完成
+        setTimeout(() => { this.submitting = false }, 1000)
+      })
     }
   }
 }
 </script>
 
-<style>
-.uploader-example {
-  width: 500px;
-  padding: 15px;
-  margin: 40px auto 0;
-  font-size: 12px;
-  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
-}
-.uploader-example .uploader-btn {
-  margin-right: 4px;
-}
-.uploader-example .uploader-list {
-  max-height: 440px;
-  overflow: auto;
-  overflow-x: hidden;
-  overflow-y: auto;
-}
+<style scoped lang="scss">
+.video-post-container {
+  padding: 20px;
+  background-color: #f4f5f7;
+  min-height: 100vh;
 
-.avatar-uploader .el-upload {
-  border: 1px dashed #d9d9d9;
-  border-radius: 6px;
-  cursor: pointer;
-  position: relative;
-  overflow: hidden;
-}
-.avatar-uploader .el-upload:hover {
-  border-color: #409EFF;
-}
-.avatar-uploader-icon {
-  font-size: 28px;
-  color: #8c939d;
-  width: 320px;
-  height: 240px;
-  line-height: 178px;
-  text-align: center;
+  .shadow-box {
+    border: none;
+    border-radius: 8px;
+    box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
+  }
+
+  .header-tip {
+    font-size: 12px;
+    color: #9499a0;
+    margin-left: 10px;
+  }
 }
-.avatar {
-  width: 320px;
-  height: 240px;
-  display: block;
+
+/* 封面上传样式 */
+.cover-upload-wrapper {
+  display: flex;
+  gap: 20px;
+  align-items: center;
+
+  .avatar-uploader {
+    border: 1px dashed #d9d9d9;
+    border-radius: 6px;
+    cursor: pointer;
+    overflow: hidden;
+    width: 240px;
+    height: 150px;
+    &:hover { border-color: #409EFF; }
+  }
+
+  .cover-preview {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    .avatar { width: 100%; height: 100%; object-fit: cover; }
+    .cover-mask {
+      position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+      background: rgba(0,0,0,0.5); color: #fff;
+      display: flex; flex-direction: column; justify-content: center; align-items: center;
+      opacity: 0; transition: opacity 0.3s;
+    }
+    &:hover .cover-mask { opacity: 1; }
+  }
+
+  .avatar-uploader-icon {
+    font-size: 28px; color: #8c939d;
+    width: 240px; height: 150px; line-height: 150px; text-align: center;
+  }
+
+  .cover-info-text {
+    flex: 1;
+    font-size: 13px; color: #61666d;
+    ul { padding-left: 18px; margin-top: 8px; color: #9499a0; }
+  }
 }
 </style>