Parcourir la source

更新 VideoPost.vue 和 VideoPostPublish.vue

reghao il y a 2 semaines
Parent
commit
c99e163b4e

+ 266 - 0
src/components/StepFormInfo.vue

@@ -0,0 +1,266 @@
+<template>
+  <div class="step-pane fade-in">
+    <el-form ref="postForm" :model="value" :rules="rules" label-position="top" class="custom-form">
+      <el-form-item label="视频标题" prop="title">
+        <el-input v-model="value.title" placeholder="给视频起个亮眼的名字吧" maxlength="50" show-word-limit />
+      </el-form-item>
+
+      <el-form-item label="视频简介" prop="description">
+        <el-input v-model="value.description" type="textarea" :rows="5" placeholder="介绍一下你的视频吧..." maxlength="200" show-word-limit />
+      </el-form-item>
+
+      <el-form-item v-if="!isEdit" label="选择分区" prop="categoryId">
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-select v-model="value.categoryPid" placeholder="一级分区" style="width: 100%;" @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="value.categoryId" placeholder="二级分区" style="width: 100%;">
+              <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 v-if="!isEdit" label="标签设置" prop="tags">
+        <el-select v-model="value.tags" multiple filterable allow-create default-first-option placeholder="输入标签后按回车添加" style="width: 100%;" class="tags-select">
+          <el-option v-for="tag in recommendTags" :key="tag" :label="tag" :value="tag" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item v-if="!isEdit" label="可见权限">
+        <el-radio-group v-model="value.scope" size="medium" class="custom-radio-group">
+          <el-radio-button label="1">私有</el-radio-button>
+          <el-radio-button label="2">公开</el-radio-button>
+          <el-radio-button label="3">VIP可见</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+
+    <div class="step-actions">
+      <el-button
+        type="success"
+        size="medium"
+        class="submit-btn"
+        :class="{'ml-auto': isSingle}"
+        :loading="submitting"
+        icon="el-icon-check"
+        @click="submitForm"
+      >
+        {{ isEdit ? '保存修改' : '立即发布稿件' }}
+      </el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import { videoRegion } from '@/api/vod'
+
+export default {
+  name: 'StepFormInfo',
+  props: {
+    value: { type: Object, required: true },
+    isEdit: { type: Boolean, default: false },
+    submitting: { type: Boolean, default: false },
+    isSingle: { type: Boolean, default: false }
+  },
+  data() {
+    return {
+      pCategoryList: [],
+      subCategoryList: [],
+      categoryRawData: [],
+      recommendTags: ['动画', '生活', '科技', '美食', '游戏'],
+      rules: {
+        title: [{ required: true, message: '请输入视频标题', trigger: 'blur' }],
+        categoryId: [{ required: true, message: '请选择完整的二级分区', trigger: 'change' }],
+        tags: [{ type: 'array', required: true, message: '请至少添加一个标签', trigger: 'change' }]
+      }
+    }
+  },
+  watch: {
+    categoryRawData(val) {
+      if (val && val.length > 0 && this.value.categoryPid) {
+        this.handlePCategoryChange(this.value.categoryPid, true)
+      }
+    }
+  },
+  created() {
+    this.initCategoryData()
+  },
+  methods: {
+    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 }))
+      }
+    },
+    handlePCategoryChange(pid, isInit = false) {
+      if (!isInit) { this.value.categoryId = null }
+      if (!pid) { this.subCategoryList = []; return }
+      const parent = this.categoryRawData.find(i => i.value === pid)
+      this.subCategoryList = parent ? parent.children : []
+    },
+    submitForm() {
+      this.$refs.postForm.validate((valid) => {
+        if (valid) {
+          this.$emit('submit')
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.video-post-container {
+  padding: 32px 20px;
+  background-color: #f6f8fa;
+  min-height: 100vh;
+  box-sizing: border-box;
+  display: flex;
+  justify-content: center;
+
+  .steps-wrapper {
+    width: 100%;
+    max-width: 880px; // 限制表单总宽度,防止宽屏下横向拉伸过长导致视觉松散
+    border: 1px solid #eaeefb;
+    border-radius: 12px;
+    box-shadow: 0 6px 16px -4px rgba(0, 0, 0, 0.04);
+    padding: 12px;
+  }
+
+  .custom-steps {
+    margin-bottom: 40px;
+    ::v-deep .el-step__title {
+      font-size: 14px;
+      font-weight: 500;
+    }
+  }
+
+  // 步骤内容切换区域
+  .step-content-router {
+    min-height: 320px;
+  }
+
+  // 淡入动效
+  .fade-in {
+    animation: fadeIn 0.35s ease-in-out;
+  }
+
+  @keyframes fadeIn {
+    from { opacity: 0; transform: translateY(6px); }
+    to { opacity: 1; transform: translateY(0); }
+  }
+
+  // 统一的下方操作栏
+  .step-actions {
+    margin-top: 36px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f2f5;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    &.center {
+      justify-content: center;
+    }
+  }
+}
+
+/* 封面上传样式优化 */
+.cover-upload-wrapper {
+  display: flex;
+  gap: 24px;
+  align-items: flex-start;
+  padding: 10px 0;
+
+  .avatar-uploader {
+    border: 2px dashed #e0e0e0;
+    border-radius: 10px;
+    cursor: pointer;
+    overflow: hidden;
+    width: 260px;
+    height: 155px;
+    background: #fafafa;
+    transition: all 0.25s ease-in-out;
+    flex-shrink: 0;
+
+    &:hover {
+      border-color: #409EFF;
+      background: #f5f9ff;
+    }
+  }
+
+  .uploader-placeholder {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: #909399;
+
+    .placeholder-icon { font-size: 26px; margin-bottom: 8px; }
+    .placeholder-text { font-size: 13px; }
+  }
+
+  .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(29, 33, 41, 0.6); color: #fff;
+      display: flex; flex-direction: column; justify-content: center; align-items: center;
+      gap: 6px; font-size: 13px; opacity: 0; transition: opacity 0.2s ease;
+    }
+    &:hover .cover-mask { opacity: 1; }
+  }
+
+  .cover-info-text {
+    flex: 1;
+
+    .tip-title {
+      font-size: 14px;
+      font-weight: 500;
+      color: #606266;
+      margin-bottom: 10px;
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      i { color: #409EFF; }
+    }
+    .tip-item { font-size: 13px; color: #8c929e; margin: 6px 0; line-height: 1.6; }
+  }
+}
+
+/* 表单精细化调整 */
+.custom-form {
+  ::v-deep .el-form-item__label {
+    padding-bottom: 6px;
+    font-weight: 500;
+    color: #303133;
+  }
+
+  .custom-radio-group {
+    width: 100%;
+    display: flex;
+    ::v-deep .el-radio-button {
+      flex: 1;
+      .el-radio-button__inner { width: 100%; text-align: center; }
+    }
+  }
+
+  .tags-select {
+    ::v-deep .el-select__tags { max-height: 80px; overflow-y: auto; }
+  }
+}
+
+.ml-auto {
+  margin-left: auto;
+}
+</style>
+

+ 333 - 0
src/components/StepUploadCover.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="step-pane fade-in">
+    <div class="cover-upload-wrapper">
+      <el-upload
+        v-if="imgOssUrl !== ''"
+        class="avatar-uploader"
+        :action="imgOssUrl"
+        :headers="imgHeaders"
+        :data="imgData"
+        :show-file-list="false"
+        :before-upload="beforeCoverUpload"
+        :on-success="handleManualCoverSuccess"
+      >
+        <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>
+        <div v-else class="uploader-placeholder">
+          <i class="el-icon-plus placeholder-icon" />
+          <span class="placeholder-text">上传封面</span>
+        </div>
+      </el-upload>
+
+      <div class="cover-info-text">
+        <div class="tip-title"><i class="el-icon-info" /> 封面设置说明:</div>
+        <p class="tip-item">① 优质的封面能帮您吸引更多观众点击。</p>
+        <p class="tip-item">② 视频上传后,系统会自动截取第一帧作为推荐封面,若不满意可点击左侧随时更换。</p>
+      </div>
+    </div>
+    <div v-if="!isSingle" class="step-actions">
+      <el-button type="primary" size="medium" @click="$emit('next')">下一步:填写基本信息 <i class="el-icon-arrow-right" /></el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import { updateVideoCover } from '@/api/vod'
+import { getVideoCoverChannelInfo } from '@/api/file'
+
+export default {
+  name: 'StepUploadCover',
+  props: {
+    value: { type: Object, required: true }, // 表单数据 form
+    videoFile: { type: File, default: null }, // 接收第一步传过来的视频文件
+    coverUrl: { type: String, default: null }, // 外部同步的图片预览地址
+    isEdit: { type: Boolean, default: false },
+    isSingle: { type: Boolean, default: false }
+  },
+  data() {
+    return {
+      imgOssUrl: '',
+      imgHeaders: { Authorization: '' },
+      imgData: { clientSha256sum: null }
+    }
+  },
+  watch: {
+    // 监听视频文件的传入:一旦有新文件进来且处于发布模式,自动截帧
+    videoFile: {
+      handler(newFile) {
+        if (newFile && !this.isEdit) {
+          // 在配置初始化完毕后再执行截帧上传,防止配置未加载时发送请求
+          this.ensureConfigAndAutoFrame(newFile)
+        }
+      },
+      immediate: true
+    }
+  },
+  created() {
+    this.initCoverConfig()
+  },
+  methods: {
+    async initCoverConfig() {
+      try {
+        const res = await getVideoCoverChannelInfo()
+        if (res.code === 0) {
+          this.imgOssUrl = res.data.ossUrl
+          this.imgHeaders.Authorization = 'Bearer ' + res.data.token
+          return true
+        } else {
+          this.$message.warning(res.msg)
+        }
+      } catch (e) {
+        console.error('获取封面上传通道失败', e)
+      }
+      return false
+    },
+    // 确保配置加载完后再执行截帧逻辑
+    async ensureConfigAndAutoFrame(file) {
+      if (!this.imgOssUrl) {
+        const success = await this.initCoverConfig()
+        if (!success) return
+      }
+      this.generateLocalCover(file)
+    },
+    // 核心逻辑:本地异步视频自动截帧
+    generateLocalCover(rawFile) {
+      const video = document.createElement('video')
+      video.src = URL.createObjectURL(rawFile)
+      video.muted = true
+      video.play()
+
+      video.onloadeddata = () => {
+        video.currentTime = 1 // 截取第一秒
+      }
+
+      video.onseeked = () => {
+        const canvas = document.createElement('canvas')
+        canvas.width = video.videoWidth
+        canvas.height = video.videoHeight
+        const ctx = canvas.getContext('2d')
+        if (ctx) {
+          ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
+          canvas.toBlob((blob) => {
+            if (blob) {
+              this.handleAutoFrameUpload(blob)
+            }
+            URL.revokeObjectURL(video.src)
+          }, 'image/jpeg', 0.85)
+        }
+      }
+    },
+    // 核心逻辑:自动截帧得到的 Blob 直接在此处上传至 OSS
+    async handleAutoFrameUpload(blobFile) {
+      const formData = new FormData()
+      formData.append('file', blobFile)
+      formData.append('clientSha256sum', '')
+
+      fetch(this.imgOssUrl, {
+        method: 'POST',
+        headers: this.imgHeaders,
+        body: formData
+      })
+        .then(res => res.json())
+        .then(json => {
+          if (json.code === 0) {
+            this.value.coverFileId = json.data.uploadId
+            // 更新父组件中的预览图
+            this.$emit('update:coverUrl', URL.createObjectURL(blobFile))
+
+            const videoCover = {
+              videoId: this.value.videoId,
+              coverFileId: this.value.coverFileId
+            }
+            updateVideoCover(videoCover).then(() => {
+              this.$message.success('系统已自动为您截取视频首帧作为推荐封面')
+            }).catch(error => {
+              this.$message.error(error.message)
+            })
+          }
+        })
+    },
+    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!')
+
+      if (isType && isLt10M) {
+        this.imgData.clientSha256sum = ''
+      }
+      return isType && isLt10M
+    },
+    // 手动更换封面的回调
+    handleManualCoverSuccess(res, file) {
+      if (res.code === 0) {
+        const videoCover = { coverFileId: res.data.uploadId }
+        if (this.isEdit) {
+          videoCover.videoId = this.value.videoId
+        } else {
+          videoCover.videoId = this.value.videoId
+          this.value.coverFileId = res.data.uploadId
+          this.$emit('update:coverUrl', URL.createObjectURL(file.raw))
+        }
+
+        updateVideoCover(videoCover).then(() => {
+          this.$message.success('视频封面更新成功')
+        }).catch(error => {
+          this.$message.error(error.message)
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.video-post-container {
+  padding: 32px 20px;
+  background-color: #f6f8fa;
+  min-height: 100vh;
+  box-sizing: border-box;
+  display: flex;
+  justify-content: center;
+
+  .steps-wrapper {
+    width: 100%;
+    max-width: 880px; // 限制表单总宽度,防止宽屏下横向拉伸过长导致视觉松散
+    border: 1px solid #eaeefb;
+    border-radius: 12px;
+    box-shadow: 0 6px 16px -4px rgba(0, 0, 0, 0.04);
+    padding: 12px;
+  }
+
+  .custom-steps {
+    margin-bottom: 40px;
+    ::v-deep .el-step__title {
+      font-size: 14px;
+      font-weight: 500;
+    }
+  }
+
+  // 步骤内容切换区域
+  .step-content-router {
+    min-height: 320px;
+  }
+
+  // 淡入动效
+  .fade-in {
+    animation: fadeIn 0.35s ease-in-out;
+  }
+
+  @keyframes fadeIn {
+    from { opacity: 0; transform: translateY(6px); }
+    to { opacity: 1; transform: translateY(0); }
+  }
+
+  // 统一的下方操作栏
+  .step-actions {
+    margin-top: 36px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f2f5;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    &.center {
+      justify-content: center;
+    }
+  }
+}
+
+/* 封面上传样式优化 */
+.cover-upload-wrapper {
+  display: flex;
+  gap: 24px;
+  align-items: flex-start;
+  padding: 10px 0;
+
+  .avatar-uploader {
+    border: 2px dashed #e0e0e0;
+    border-radius: 10px;
+    cursor: pointer;
+    overflow: hidden;
+    width: 260px;
+    height: 155px;
+    background: #fafafa;
+    transition: all 0.25s ease-in-out;
+    flex-shrink: 0;
+
+    &:hover {
+      border-color: #409EFF;
+      background: #f5f9ff;
+    }
+  }
+
+  .uploader-placeholder {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: #909399;
+
+    .placeholder-icon { font-size: 26px; margin-bottom: 8px; }
+    .placeholder-text { font-size: 13px; }
+  }
+
+  .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(29, 33, 41, 0.6); color: #fff;
+      display: flex; flex-direction: column; justify-content: center; align-items: center;
+      gap: 6px; font-size: 13px; opacity: 0; transition: opacity 0.2s ease;
+    }
+    &:hover .cover-mask { opacity: 1; }
+  }
+
+  .cover-info-text {
+    flex: 1;
+
+    .tip-title {
+      font-size: 14px;
+      font-weight: 500;
+      color: #606266;
+      margin-bottom: 10px;
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      i { color: #409EFF; }
+    }
+    .tip-item { font-size: 13px; color: #8c929e; margin: 6px 0; line-height: 1.6; }
+  }
+}
+
+/* 表单精细化调整 */
+.custom-form {
+  ::v-deep .el-form-item__label {
+    padding-bottom: 6px;
+    font-weight: 500;
+    color: #303133;
+  }
+
+  .custom-radio-group {
+    width: 100%;
+    display: flex;
+    ::v-deep .el-radio-button {
+      flex: 1;
+      .el-radio-button__inner { width: 100%; text-align: center; }
+    }
+  }
+
+  .tags-select {
+    ::v-deep .el-select__tags { max-height: 80px; overflow-y: auto; }
+  }
+}
+</style>

+ 247 - 0
src/components/StepUploadVideo.vue

@@ -0,0 +1,247 @@
+<template>
+  <div class="step-pane fade-in">
+    <uploader-card
+      v-if="uploadConfig"
+      ref="videoUploader"
+      :upload-config="uploadConfig"
+      @before-upload="handleVideoAdded"
+      @success="handleVideoSuccess"
+    />
+    <div v-if="!isSingle" class="step-actions center">
+      <el-button
+        type="primary"
+        size="medium"
+        :disabled="!value.videoId"
+        suffix-icon="el-icon-arrow-right"
+        @click="$emit('next')"
+      >
+        下一步:设置封面 {{ !value.videoId ? '(请先上传视频)' : '' }}
+      </el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import UploaderCard from 'components/card/UploaderCard.vue'
+import { addVideoFile, updateVideoFile } from '@/api/vod'
+import { getVideoChannelInfo } from '@/api/file'
+
+export default {
+  name: 'StepUploadVideo',
+  components: { UploaderCard },
+  props: {
+    value: { type: Object, required: true },
+    isEdit: { type: Boolean, default: false },
+    // 🌟 新增:是否作为独立组件单页使用
+    isSingle: { type: Boolean, default: false }
+  },
+  data() {
+    return {
+      uploadConfig: null
+    }
+  },
+  created() {
+    this.initUploadConfig()
+  },
+  methods: {
+    initUploadConfig() {
+      getVideoChannelInfo().then(resp => {
+        if (resp.code === 0) {
+          this.uploadConfig = resp.data
+          this.uploadConfig.title = this.isEdit ? '重新上传视频文件' : '上传视频文件'
+          this.uploadConfig.fileAttrs = { accept: 'video/*' }
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      })
+    },
+    handleVideoAdded(filename) {
+      if (!this.isEdit && !this.value.title) {
+        // 直接修改父组件传进来的引用对象属性
+        this.value.title = filename.replace(/\.[^/.]+$/, '').substring(0, 50)
+      }
+    },
+    handleVideoSuccess(uploadResult) {
+      const { uploadId, file } = uploadResult
+      if (this.isEdit) {
+        updateVideoFile({
+          videoId: this.value.videoId,
+          videoFileId: uploadId,
+          filename: file.name
+        }).then(res => {
+          this.$message.success('视频资产更新成功')
+          // 🌟 修改:只有在非独立单页时,才触发 next 自动流转步骤
+          if (!this.isSingle) {
+            this.$emit('next')
+          } else {
+            // 独立使用时,抛出通知让列表页感知刷新
+            this.$emit('success')
+          }
+        })
+      } else {
+        addVideoFile({
+          videoFileId: uploadId,
+          filename: file.name
+        }).then(resp => {
+          if (resp.code === 0) {
+            this.value.videoId = resp.data
+            this.$message.success('视频文件上传成功!')
+
+            // 核心改动:把原生的本地 File 对象抛给父组件
+            this.$emit('video-success', file.file)
+            this.$emit('next')
+          } else {
+            this.$message.warning(resp.msg)
+          }
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.video-post-container {
+  padding: 32px 20px;
+  background-color: #f6f8fa;
+  min-height: 100vh;
+  box-sizing: border-box;
+  display: flex;
+  justify-content: center;
+
+  .steps-wrapper {
+    width: 100%;
+    max-width: 880px; // 限制表单总宽度,防止宽屏下横向拉伸过长导致视觉松散
+    border: 1px solid #eaeefb;
+    border-radius: 12px;
+    box-shadow: 0 6px 16px -4px rgba(0, 0, 0, 0.04);
+    padding: 12px;
+  }
+
+  .custom-steps {
+    margin-bottom: 40px;
+    ::v-deep .el-step__title {
+      font-size: 14px;
+      font-weight: 500;
+    }
+  }
+
+  // 步骤内容切换区域
+  .step-content-router {
+    min-height: 320px;
+  }
+
+  // 淡入动效
+  .fade-in {
+    animation: fadeIn 0.35s ease-in-out;
+  }
+
+  @keyframes fadeIn {
+    from { opacity: 0; transform: translateY(6px); }
+    to { opacity: 1; transform: translateY(0); }
+  }
+
+  // 统一的下方操作栏
+  .step-actions {
+    margin-top: 36px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f2f5;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    &.center {
+      justify-content: center;
+    }
+  }
+}
+
+/* 封面上传样式优化 */
+.cover-upload-wrapper {
+  display: flex;
+  gap: 24px;
+  align-items: flex-start;
+  padding: 10px 0;
+
+  .avatar-uploader {
+    border: 2px dashed #e0e0e0;
+    border-radius: 10px;
+    cursor: pointer;
+    overflow: hidden;
+    width: 260px;
+    height: 155px;
+    background: #fafafa;
+    transition: all 0.25s ease-in-out;
+    flex-shrink: 0;
+
+    &:hover {
+      border-color: #409EFF;
+      background: #f5f9ff;
+    }
+  }
+
+  .uploader-placeholder {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: #909399;
+
+    .placeholder-icon { font-size: 26px; margin-bottom: 8px; }
+    .placeholder-text { font-size: 13px; }
+  }
+
+  .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(29, 33, 41, 0.6); color: #fff;
+      display: flex; flex-direction: column; justify-content: center; align-items: center;
+      gap: 6px; font-size: 13px; opacity: 0; transition: opacity 0.2s ease;
+    }
+    &:hover .cover-mask { opacity: 1; }
+  }
+
+  .cover-info-text {
+    flex: 1;
+
+    .tip-title {
+      font-size: 14px;
+      font-weight: 500;
+      color: #606266;
+      margin-bottom: 10px;
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      i { color: #409EFF; }
+    }
+    .tip-item { font-size: 13px; color: #8c929e; margin: 6px 0; line-height: 1.6; }
+  }
+}
+
+/* 表单精细化调整 */
+.custom-form {
+  ::v-deep .el-form-item__label {
+    padding-bottom: 6px;
+    font-weight: 500;
+    color: #303133;
+  }
+
+  .custom-radio-group {
+    width: 100%;
+    display: flex;
+    ::v-deep .el-radio-button {
+      flex: 1;
+      .el-radio-button__inner { width: 100%; text-align: center; }
+    }
+  }
+
+  .tags-select {
+    ::v-deep .el-select__tags { max-height: 80px; overflow-y: auto; }
+  }
+}
+</style>

+ 9 - 5
src/views/admin/aaa/VideoAudit.vue

@@ -72,7 +72,7 @@
 
         <el-form ref="auditForm" :model="auditForm" label-position="top" size="small">
           <el-form-item label="违规标签(不通过时必选)" prop="reasonTags">
-            <el-checkbox-group v-model="auditForm.reasonTags" :disabled="auditForm.decision === 2">
+            <el-checkbox-group v-model="auditForm.reasonTags" :disabled="auditForm.decision === auditResult.REVIEW_REJECTED">
               <el-checkbox label="涉政违规" />
               <el-checkbox label="色情低俗" />
               <el-checkbox label="血腥暴力" />
@@ -158,6 +158,10 @@ export default {
         2: '所有人可见',
         3: 'VIP 可见'
       },
+      auditResult: {
+        PUBLISHED: 3,
+        REVIEW_REJECTED: 4
+      },
       // 当前待审核的视频数据模型
       currentVideo: {
         videoId: '',
@@ -176,7 +180,7 @@ export default {
       // 审核提交表单
       auditForm: {
         videoId: null,
-        decision: null, // 2:通过, 3:不通过
+        decision: null,
         reasonTags: [],
         remark: ''
       }
@@ -226,7 +230,7 @@ export default {
       this.auditForm.decision = status
 
       // 校验逻辑:如果不通过,必须选择至少一个违规标签或填写备注
-      if (status === 3 && this.auditForm.reasonTags.length === 0 && !this.auditForm.remark.trim()) {
+      if (status === this.auditResult.REVIEW_REJECTED && this.auditForm.reasonTags.length === 0 && !this.auditForm.remark.trim()) {
         this.$message.error('请选择违规标签或填写不通过的具体审核意见!')
         return
       }
@@ -237,9 +241,9 @@ export default {
           var nextVideoId = resp.data
           this.submitting = false
           this.$notify({
-            title: status === 2 ? '审核通过' : '已驳回',
+            title: status === this.auditResult.PUBLISHED ? '审核通过' : '已驳回',
             message: `视频 [${this.currentVideo.videoId}] 处理完毕,已自动加载下一条。`,
-            type: status === 2 ? 'success' : 'warning',
+            type: status === this.auditResult.PUBLISHED ? 'success' : 'warning',
             duration: 2500
           })
           this.$router.push('/vod_audit/' + nextVideoId)

+ 1 - 1
src/views/my/MyMessage.vue

@@ -18,7 +18,7 @@
             style="width: 100%"
           >
             <el-table-column
-              prop="createAt"
+              prop="createTime"
               label="时间"
             />
             <el-table-column

+ 298 - 123
src/views/post/VideoPost.vue

@@ -6,19 +6,13 @@
           <i class="el-icon-collection" /> 稿件管理
         </h3>
         <div class="filter-controls">
-          <el-select
-            v-model="queryInfo.scope"
-            clearable
-            placeholder="可见范围筛选"
+          <el-button
+            type="success"
+            icon="el-icon-refresh"
             size="small"
-            class="filter-select"
-            @change="handleFilterChange"
-          >
-            <el-option label="全部范围" :value="null" />
-            <el-option label="本人可见" :value="1" />
-            <el-option label="所有人可见" :value="2" />
-            <el-option label="VIP 可见" :value="3" />
-          </el-select>
+            class="publish-btn"
+            @click="handleFilterChange"
+          >刷新</el-button>
           <el-button
             type="primary"
             icon="el-icon-plus"
@@ -40,18 +34,35 @@
         >
           <el-table-column label="No" type="index" width="60" align="center" />
 
-          <el-table-column label="封面" width="120" align="center">
+          <el-table-column label="封面" width="140" align="center">
             <template slot-scope="scope">
-              <el-image
-                class="video-cover"
-                :src="scope.row.coverUrl"
-                fit="cover"
-                :preview-src-list="[scope.row.coverUrl]"
+              <el-popover
+                placement="right"
+                trigger="hover"
+                popper-class="cover-popover-panel"
+                :open-delay="200"
               >
-                <div slot="error" class="image-slot">
-                  <i class="el-icon-picture-outline" />
+                <div class="popover-big-cover">
+                  <img :src="scope.row.coverUrl" class="big-target-img">
+                </div>
+
+                <div slot="reference" class="table-cover-wrapper" @click="handleVideoCover(scope.row)">
+                  <el-image
+                    class="video-cover"
+                    :src="scope.row.coverUrl"
+                    fit="cover"
+                  >
+                    <div slot="error" class="image-slot">
+                      <i class="el-icon-picture-outline" />
+                    </div>
+                  </el-image>
+
+                  <div class="cover-action-mask">
+                    <i class="el-icon-edit" />
+                    <span>修改封面</span>
+                  </div>
                 </div>
-              </el-image>
+              </el-popover>
             </template>
           </el-table-column>
 
@@ -85,16 +96,29 @@
 
           <el-table-column label="稿件状态" width="120" align="center">
             <template slot-scope="scope">
-              <span :class="['status-dot', getStatusClass(scope.row.status)]">
+              <el-tag
+                :type="getStatusClass(scope.row.status)"
+                size="medium"
+                effect="light"
+              >
                 {{ getStatusText(scope.row.status) }}
-              </span>
+              </el-tag>
             </template>
           </el-table-column>
 
-          <el-table-column label="操作" width="220" fixed="right" align="center">
+          <el-table-column label="操作" fixed="right" align="center">
             <template slot-scope="scope">
-              <el-button type="text" size="small" icon="el-icon-view" @click="handlePreview(scope.$index, scope.row)">预览</el-button>
-              <el-button type="text" size="small" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
+              <el-button type="text" size="small" icon="el-icon-video-play" @click="handlePreview(scope.$index, scope.row)">预览</el-button>
+              <el-button type="text" size="small" icon="el-icon-upload" @click="handleVideoFile(scope.row)">重传</el-button>
+              <el-button
+                type="text"
+                size="small"
+                :icon="scope.row.status === 1 ? 'el-icon-circle-check' : 'el-icon-edit'"
+                :class="scope.row.status === 1 ? 'success-text' : ''"
+                @click="handleEdit(scope.row)"
+              >
+                {{ scope.row.status === 1 ? '发布' : '编辑' }}
+              </el-button>
               <el-button type="text" size="small" icon="el-icon-delete" class="danger-text" @click="handleDelete(scope.$index, scope.row)">删除</el-button>
             </template>
           </el-table-column>
@@ -123,16 +147,82 @@
       @close="editingVideo = null"
     >
       <div slot="title" class="dialog-header-custom">
-        <i :class="editingVideo ? 'el-icon-edit' : 'el-icon-circle-plus-outline'" />
-        <span>{{ editingVideo ? '编辑视频稿件' : '发布新视频稿件' }}</span>
+        <i class="el-icon-circle-plus-outline" />
+        <span>发布新视频稿件</span>
       </div>
-
       <video-post-publish
-        :video-info="editingVideo"
+        :video-info="null"
         @video-publish="onVideoPublish"
       />
     </el-dialog>
 
+    <el-dialog
+      :visible.sync="coverDialogVisible"
+      width="600px"
+      custom-class="custom-glass-dialog"
+      append-to-body
+      destroy-on-close
+      @close="currentSelectVideo = null"
+    >
+      <div slot="title" class="dialog-header-custom">
+        <i class="el-icon-picture" /><span>更新视频封面</span>
+      </div>
+      <div v-if="currentSelectVideo" class="dialog-body-padding">
+        <step-upload-cover
+          v-model="editForm"
+          :cover-url.sync="currentSelectVideo.coverUrl"
+          :is-edit="true"
+          :is-single="true"
+          @update:coverUrl="onSingleUpdateSuccess('封面修改成功')"
+        />
+      </div>
+    </el-dialog>
+
+    <el-dialog
+      :visible.sync="videoFileDialogVisible"
+      width="600px"
+      custom-class="custom-glass-dialog"
+      append-to-body
+      destroy-on-close
+      @close="currentSelectVideo = null"
+    >
+      <div slot="title" class="dialog-header-custom">
+        <i class="el-icon-upload" /><span>重新上传视频文件</span>
+      </div>
+      <div v-if="currentSelectVideo" class="dialog-body-padding">
+        <step-upload-video
+          v-model="editForm"
+          :is-edit="true"
+          :is-single="true"
+          @success="onSingleUpdateSuccess('视频重传成功,正在重新转码')"
+        />
+      </div>
+    </el-dialog>
+
+    <el-dialog
+      :visible.sync="infoEditDialogVisible"
+      width="700px"
+      custom-class="custom-glass-dialog"
+      append-to-body
+      destroy-on-close
+      @close="currentSelectVideo = null"
+    >
+      <div slot="title" class="dialog-header-custom">
+        <i class="el-icon-edit" />
+        <span>{{ currentSelectVideo && currentSelectVideo.status === 1 ? '完善并发布稿件信息' : '修改稿件基本信息' }}</span>
+      </div>
+      <div v-if="currentSelectVideo" class="dialog-body-padding">
+        <step-form-info
+          v-model="editForm"
+          :is-edit="currentSelectVideo.status !== 1"
+          :is-single="true"
+          :submitting="submitting"
+          @prev="infoEditDialogVisible = false"
+          @submit="onSingleInfoSubmit"
+        />
+      </div>
+    </el-dialog>
+
     <el-dialog :visible.sync="showEditScopeDialog" width="400px" custom-class="custom-glass-dialog compact-dialog" append-to-body center>
       <div slot="title" class="dialog-header-custom">
         <i class="el-icon-lock" /><span>权限设置</span>
@@ -165,18 +255,25 @@
 <script>
 import VideoPreviewPlayer from 'components/VideoPreviewPlayer'
 import VideoPostPublish from '@/views/post/VideoPostPublish'
+import StepUploadVideo from '@/components/StepUploadVideo.vue'
+import StepUploadCover from '@/components/StepUploadCover.vue'
+import StepFormInfo from '@/components/StepFormInfo.vue'
+
 import { updateVideoScope, deleteVideoPost, getVideoPosts, addVideoPost, updateVideoInfo, getVideoPost } from '@/api/vod'
 
 export default {
   name: 'VideoPost',
-  components: { VideoPreviewPlayer, VideoPostPublish },
+  components: {
+    VideoPreviewPlayer,
+    VideoPostPublish,
+    StepUploadVideo,
+    StepUploadCover,
+    StepFormInfo
+  },
   data() {
     return {
       loading: false,
-      queryInfo: {
-        scope: null, // 从 URL 获取
-        pn: 1 // 从 URL 获取
-      },
+      queryInfo: { scope: null, pn: 1 },
       screenWidth: document.body.clientWidth,
       pageSize: 12,
       totalSize: 0,
@@ -185,80 +282,181 @@ export default {
       showEditScopeDialog: false,
       showPreviewDialog: false,
       form: { videoId: null, scope: 1 },
-      publishVideoDiaglog: false,
-      editingVideo: null
+
+      // 弹窗状态控制定义
+      publishVideoDiaglog: false, // 纯净发布弹窗
+      coverDialogVisible: false, // 独立修改封面弹窗
+      videoFileDialogVisible: false, // 独立重传视频弹窗
+      infoEditDialogVisible: false, // 独立修改信息弹窗
+
+      editingVideo: null,
+      currentSelectVideo: null, // 当前操作的表格行原始行数据
+      submitting: false,
+
+      // 核心封装:提供给独立的编辑子组件双向绑定的临时表单
+      editForm: {
+        videoId: null,
+        coverFileId: null,
+        title: '',
+        description: '',
+        categoryPid: null,
+        categoryId: null,
+        tags: [],
+        scope: '2'
+      }
     }
   },
   computed: {
-    // 映射当前页码给 el-pagination 使用
-    currentPage() {
-      return parseInt(this.queryInfo.pn) || 1
-    }
+    currentPage() { return parseInt(this.queryInfo.pn) || 1 }
   },
   watch: {
-    // 核心优化:监听路由变化,一旦 URL 参数变了,就重新抓取数据
-    '$route': {
-      handler: 'syncParamsAndLoad',
-      immediate: true
-    }
+    '$route': { handler: 'syncParamsAndLoad', immediate: true }
   },
   methods: {
-    // 1. 同步 URL 参数到 data,并执行请求
     syncParamsAndLoad() {
       const { pn, scope } = this.$route.query
       this.queryInfo.pn = pn ? parseInt(pn) : 1
       this.queryInfo.scope = scope ? (isNaN(scope) ? scope : parseInt(scope)) : null
       this.getData()
     },
-
-    // 2. 更新 URL 的通用方法
     updateRouter() {
       this.$router.push({
         path: this.$route.path,
-        query: {
-          pn: this.queryInfo.pn,
-          scope: this.queryInfo.scope || undefined // 为 null 时不显示在 URL 中
-        }
-      }).catch(err => {
-        // 捕获冗余导航错误(点击同一页时)
-        if (err.name !== 'NavigationDuplicated') throw err
-      })
+        query: { pn: this.queryInfo.pn, scope: this.queryInfo.scope || undefined }
+      }).catch(err => { if (err.name !== 'NavigationDuplicated') throw err })
     },
-
-    // 3. 交互触发:切换分页
     handlePageChange(pageNumber) {
       this.queryInfo.pn = pageNumber
       this.updateRouter()
       window.scrollTo({ top: 0, behavior: 'smooth' })
     },
-
-    // 4. 交互触发:切换筛选
     handleFilterChange() {
-      this.queryInfo.pn = 1 // 筛选条件变了,重置回第一页
-      this.updateRouter()
+      this.queryInfo.pn = 1
+      this.getData()
     },
-
     getData() {
       this.loading = true
-      // 注意:这里建议给后端接口也加上 scope 参数,此处假设你原有接口支持
       getVideoPosts(this.queryInfo).then(resp => {
         if (resp.code === 0) {
           this.dataList = resp.data.list
           this.totalSize = resp.data.totalSize
         }
-      }).finally(() => {
-        this.loading = false
-      })
+      }).finally(() => { this.loading = false })
+    },
+
+    // 初始化编辑表单数据映射器(深拷贝隔离引用)
+    prepareEditForm(row) {
+      this.currentSelectVideo = row
+      this.editForm = {
+        videoId: row.videoId,
+        coverFileId: row.coverFileId || null,
+        title: row.title || '',
+        description: row.description || '',
+        categoryPid: row.categoryPid || null,
+        categoryId: row.categoryId || null,
+        tags: Array.isArray(row.tags) ? row.tags : (row.tags ? row.tags.split(',') : []),
+        scope: String(row.scope || '2')
+      }
+    },
+
+    // 交互一:点击表格封面 -> 弹出修改封面
+    handleVideoCover(row) {
+      this.prepareEditForm(row)
+      this.coverDialogVisible = true
+    },
+
+    // 交互二:点击操作列中的“重传” -> 弹出视频上传
+    handleVideoFile(row) {
+      this.prepareEditForm(row)
+      this.videoFileDialogVisible = true
+    },
+
+    // 交互三:点击操作列中的“编辑” -> 弹出信息表单组件
+    handleEdit(row) {
+      this.prepareEditForm(row)
+      this.infoEditDialogVisible = true
     },
 
-    /* --- 其余 UI 逻辑保持不变 --- */
+    // 封面和视频更新成功后的公共回调(因为底层组件内部已调用过 updateVideoCover/updateVideoFile 接口)
+    onSingleUpdateSuccess(msg) {
+      this.$message.success(msg)
+      this.coverDialogVisible = false
+      this.videoFileDialogVisible = false
+      this.getData() // 刷新列表
+    },
+
+    // 信息修改的独立提交保存逻辑
+    onSingleInfoSubmit() {
+      this.submitting = true
+      // 核心判断:根据当前操作的视频状态来决定行为
+      const isDraftMode = this.currentSelectVideo.status === 1
+      if (isDraftMode) {
+        // 场景 A:文件和封面都有了,属于第一次正式“提交发布”
+        addVideoPost(this.editForm).then(res => {
+          if (res.code === 0) {
+            this.$message.success('稿件信息提交成功,已成功发布!')
+            this.infoEditDialogVisible = false
+            this.getData() // 刷新列表
+          } else {
+            this.$message.warning(res.msg)
+          }
+        }).catch(error => {
+          this.$message.error(error.message)
+        }).finally(() => { this.submitting = false })
+      } else {
+        // 场景 B:本身就是线上已有的稿件,属于正常的“修改更新”
+        updateVideoInfo(this.editForm).then(res => {
+          if (res.code === 0) {
+            this.$message.success('稿件基本信息更新成功!')
+            this.infoEditDialogVisible = false
+            this.getData()
+          } else {
+            this.$message.warning(res.msg)
+          }
+        }).catch(error => {
+          this.$message.error(error.message)
+        }).finally(() => { this.submitting = false })
+      }
+    },
+
+    // 原发布的控制保持不动
+    handlePost() {
+      this.editingVideo = null
+      this.publishVideoDiaglog = true
+    },
+    onVideoPublish({ data, mode }) {
+      if (mode === 'publish') {
+        addVideoPost(data).then(res => {
+          if (res.code === 0) {
+            this.publishVideoDiaglog = false
+            this.$message.info('投稿成功,请等待审核')
+            this.getData()
+          }
+        })
+      }
+    },
+
+    /* --- 其余基础状态逻辑保持不变 --- */
     getStatusClass(status) {
-      const map = { 1: 'info', 2: 'warning', 3: 'success', 4: 'danger', 5: 'info' }
+      const map = {
+        1: 'info', // 待发布:灰色(草稿或未开始状态,保持低调)
+        2: 'warning', // 待审核:黄色(进行中,提示用户需要等待或平台正在处理)
+        3: 'success', // 已发布:绿色(终态,最健康的成功颜色)
+        4: 'danger', // 审核未通过:红色(高警示色,提醒创作者需要修改稿件)
+        5: 'info' // 下架:灰色(已失效状态,冷色调处理)
+      }
       return map[status] || 'info'
     },
+
     getStatusText(status) {
-      const map = { 1: '待发布', 2: '待审核', 3: '已发布', 4: '审核未通过', 5: '下架' }
-      return map[status] || `未知(${status})`
+      const map = {
+        1: '待发布',
+        2: '审核中',
+        3: '已发布',
+        4: '审核未通过',
+        5: '已下架'
+      }
+      return map[status] || `未知状态(${status})`
     },
     handleScope(index, row) {
       this.form.videoId = row.videoId
@@ -277,22 +475,12 @@ export default {
         }
       })
     },
-    handleEdit(row) {
-      this.editingVideo = row
-      this.publishVideoDiaglog = true
-    },
-    handlePost() {
-      this.editingVideo = null
-      this.publishVideoDiaglog = true
-    },
     handleDelete(index, row) {
       this.$confirm(`确定要删除《${row.title}》?`, '警告', {
         type: 'warning',
         confirmButtonClass: 'el-button--danger'
       }).then(() => {
-        const payload = {}
-        payload.videoId = row.videoId
-        deleteVideoPost(payload).then(res => {
+        deleteVideoPost({ videoId: row.videoId }).then(res => {
           if (res.code === 0) {
             this.$message.info('稿件已删除')
             this.getData()
@@ -310,37 +498,13 @@ export default {
           this.$message.info('可见范围已更新')
         }
       })
-    },
-    onVideoPublish({ data, mode }) {
-      const videoForm = data
-      if (mode === 'edit') {
-        updateVideoInfo(videoForm).then(res => {
-          this.$message.info(res.msg)
-        }).catch(error => {
-          this.$message.error(error.message)
-        })
-      } else {
-        addVideoPost(videoForm).then(res => {
-          if (res.code === 0) {
-            this.publishVideoDiaglog = false
-            this.$message.info('投稿成功,请等待审核')
-            this.getData()
-          }
-        })
-      }
-      this.finishPublish()
-    },
-    finishPublish() {
-      this.publishVideoDiaglog = false
-      this.editingVideo = null
-      this.getData()
     }
   }
 }
 </script>
 
-<style scoped>
-/* 样式部分同上一版,保持“三层”视觉感 */
+<style scoped lang="scss">
+/* 保留你原有全部美化过的高级视觉 SCSS 样式 */
 .video-post-container { padding: 24px; background-color: #f5f7fa; min-height: 100%; }
 .post-header { background: white; padding: 20px 24px; border-radius: 12px 12px 0 0; box-shadow: 0 4px 12px 0 rgba(0,0,0,0.03); }
 .header-wrapper { display: flex; justify-content: space-between; align-items: center; }
@@ -349,26 +513,37 @@ export default {
 .publish-btn { box-shadow: 0 4px 10px rgba(64, 158, 255, 0.3); }
 .post-main { padding: 0; }
 .table-card { background: white; border-radius: 0 0 12px 12px; padding: 10px 24px 24px; box-shadow: 0 8px 24px 0 rgba(0,0,0,0.04); min-height: 400px; }
-.video-cover { width: 90px; height: 50px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); transition: transform 0.3s; }
-.video-cover:hover { transform: scale(1.08); }
-.video-title-link { color: #303133; text-decoration: none; font-weight: 600; font-size: 14px; }
-.video-title-link:hover { color: #409EFF; }
+.video-title-link { color: #303133; text-decoration: none; font-weight: 600; font-size: 14px; &:hover { color: #409EFF; } }
 .video-meta { margin-top: 6px; font-size: 12px; color: #909399; }
 .vid-tag { background: #f0f2f5; padding: 2px 6px; border-radius: 4px; margin-right: 10px; }
-.status-dot { position: relative; padding-left: 16px; font-size: 13px; color: #606266; }
-.status-dot::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 7px; height: 7px; border-radius: 50%; }
-.status-dot.success::before { background-color: #67C23A; box-shadow: 0 0 6px #67C23A; }
-.status-dot.warning::before { background-color: #E6A23C; box-shadow: 0 0 6px #E6A23C; }
-.status-dot.danger::before { background-color: #F56C6C; box-shadow: 0 0 6px #F56C6C; }
 .danger-text { color: #F56C6C; }
 .pagination-container { margin-top: 30px; display: flex; justify-content: center; }
 
-::v-deep .custom-glass-dialog { border-radius: 16px !important; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.15) !important; }
-::v-deep .custom-glass-dialog .el-dialog__header { padding: 20px 24px; background: #fcfcfd; border-bottom: 1px solid #f0f0f0; }
-.dialog-header-custom { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 600; color: #303133; }
-.dialog-header-custom i { color: #409EFF; font-size: 20px; }
-::v-deep .custom-glass-dialog .el-dialog__body { padding: 24px; }
+/* 弹窗样式 */
+::v-deep .custom-glass-dialog { border-radius: 16px !important; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.15) !important; .el-dialog__header { padding: 20px 24px; background: #fcfcfd; border-bottom: 1px solid #f0f0f0; } .el-dialog__body { padding: 24px; } }
+.dialog-header-custom { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 600; color: #303133; i { color: #409EFF; font-size: 20px; } }
+.dialog-body-padding { padding: 8px 4px; }
 .input-label-tip { font-size: 13px; color: #909399; margin-bottom: 12px; }
 .preview-wrapper { background: #1a1a1a; border-radius: 12px; overflow: hidden; line-height: 0; }
 ::v-deep .table-header-cell { background-color: #f8f9fb !important; color: #303133; font-weight: 600; padding: 12px 0; }
+
+/* 封面组件特效 */
+.table-cover-wrapper {
+  position: relative; width: 100px; height: 62px; border-radius: 6px; overflow: hidden; cursor: pointer; border: 1px solid #f0f2f5; display: inline-block; vertical-align: middle;
+  .video-cover { width: 100%; height: 100%; transition: transform 0.3s ease; }
+  .cover-action-mask { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(29, 33, 41, 0.65); color: #ffffff; opacity: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 4px; font-size: 11px; transition: opacity 0.25s ease-in-out; i { font-size: 14px; } }
+  &:hover { .video-cover { transform: scale(1.06); } .cover-action-mask { opacity: 1; } }
+  .image-slot { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; background: #f5f7fa; color: #909399; font-size: 18px; }
+}
+.popover-big-cover { width: 320px; height: 180px; overflow: hidden; border-radius: 8px; background: #f5f7fa; .big-target-img { width: 100%; height: 100%; object-fit: cover; } }
+
+.success-text {
+  color: #67C23A;
+}
+.success-text:hover, .success-text:focus {
+  color: #85ce61;
+}
+.danger-text {
+  color: #F56C6C;
+}
 </style>

+ 164 - 390
src/views/post/VideoPostPublish.vue

@@ -1,172 +1,60 @@
 <template>
   <div class="video-post-container">
-    <el-row :gutter="20">
-      <el-col :md="14" :sm="24">
-        <uploader-card
-          v-if="uploadConfig"
-          ref="videoUploader"
-          :upload-config="uploadConfig"
-          @before-upload="handleVideoAdded"
-          @success="handleVideoSuccess"
+    <el-card class="box-card shadow-box steps-wrapper">
+      <el-steps :active="currentStep" finish-status="success" align-center class="custom-steps">
+        <el-step title="上传视频" icon="el-icon-upload" />
+        <el-step title="设置封面" icon="el-icon-picture" />
+        <el-step title="完善稿件信息" icon="el-icon-tickets" />
+      </el-steps>
+
+      <div class="step-content-router">
+        <step-upload-video
+          v-show="currentStep === 0"
+          v-model="form"
+          :is-edit="isEdit"
+          @next="currentStep++"
+          @video-success="handleVideoSuccess"
         />
 
-        <el-card class="box-card shadow-box" style="margin-top: 20px;">
-          <div slot="header" class="clearfix">
-            <span>视频封面</span>
-            <span class="header-tip">(支持 JPG/PNG,小于 10MB)</span>
-          </div>
-          <div class="cover-upload-wrapper">
-            <el-upload
-              v-if="imgOssUrl !== ''"
-              class="avatar-uploader"
-              :action="imgOssUrl"
-              :headers="imgHeaders"
-              :data="imgData"
-              :show-file-list="false"
-              :before-upload="beforeCoverUpload"
-              :on-success="handleManualCoverSuccess"
-            >
-              <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-col :md="10" :sm="24">
-        <el-card class="box-card shadow-box">
-          <div slot="header" class="clearfix">
-            <span>稿件信息</span>
-            <el-button
-              type="primary"
-              size="small"
-              style="float: right;"
-              :loading="submitting"
-              @click="submitForm"
-            >立即发布</el-button>
-          </div>
-
-          <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 v-if="!isEdit" 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 v-if="!isEdit" 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-select>
-            </el-form-item>
+        <step-upload-cover
+          v-show="currentStep === 1"
+          v-model="form"
+          :video-file="videoRawFile"
+          :cover-url.sync="coverUrl"
+          :is-edit="isEdit"
+          @next="currentStep++"
+          @prev="currentStep--"
+        />
 
-            <el-form-item v-if="!isEdit" 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>
+        <step-form-info
+          v-show="currentStep === 2"
+          v-model="form"
+          :is-edit="isEdit"
+          :submitting="submitting"
+          @prev="currentStep--"
+          @submit="handleFinalSubmit"
+        />
+      </div>
+    </el-card>
   </div>
 </template>
 
 <script>
-import UploaderCard from 'components/card/UploaderCard.vue'
-import { addVideoFile, updateVideoCover, updateVideoFile, videoRegion } from '@/api/vod'
-import { getVideoChannelInfo, getVideoCoverChannelInfo } from '@/api/file'
+import StepUploadVideo from '@/components/StepUploadVideo.vue'
+import StepUploadCover from '@/components/StepUploadCover.vue'
+import StepFormInfo from '@/components/StepFormInfo.vue'
 
 export default {
   name: 'VideoPostPublish',
-  components: { UploaderCard },
+  components: { StepUploadVideo, StepUploadCover, StepFormInfo },
   props: {
-    // 接收视频信息,null 为发布,非 null 为编辑
-    videoInfo: {
-      type: Object,
-      default: null
-    }
+    videoInfo: { type: Object, default: null }
   },
   data() {
     return {
-      uploadConfig: null,
-      // 封面上传相关
-      imgOssUrl: '',
-      imgHeaders: { Authorization: '' },
-      imgData: { clientSha256sum: null },
+      currentStep: 0,
       coverUrl: null,
-
-      // 分区数据
-      pCategoryList: [],
-      subCategoryList: [],
-      categoryRawData: [],
-      recommendTags: ['动画', '生活', '科技', '美食', '游戏'],
-
-      // 表单数据
+      videoRawFile: null, // 核心:用于暂存第一步返回的本地 File 对象
       submitting: false,
       form: {
         videoId: null,
@@ -177,44 +65,19 @@ export default {
         categoryId: null,
         tags: [],
         scope: '2'
-      },
-
-      // 校验规则
-      rules: {
-        title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
-        categoryId: [{ required: true, message: '请选择完整的分区', trigger: 'change' }],
-        tags: [{ type: 'array', required: true, message: '请至少添加一个标签', trigger: 'change' }]
       }
     }
   },
   computed: {
-    isEdit() {
-      return this.videoInfo !== null
-    }
+    isEdit() { return this.videoInfo !== null }
   },
   watch: {
-    // 监听 props 变化,防止父组件异步获取数据后子组件不更新
     videoInfo: {
-      handler(val) {
-        if (val) {
-          this.initEditData(val)
-        }
-      },
+      handler(val) { if (val) this.initEditData(val) },
       immediate: true
-    },
-    // 监听原始分区数据加载完毕后,如果处于编辑模式,初始化二级分区列表
-    categoryRawData(val) {
-      if (val && val.length > 0 && this.form.categoryPid) {
-        this.handlePCategoryChange(this.form.categoryPid, true)
-      }
     }
   },
-  created() {
-    this.initCategoryData()
-    this.initUploadConfig()
-  },
   methods: {
-    // 初始化编辑数据
     initEditData(info) {
       this.form = {
         videoId: info.videoId,
@@ -227,193 +90,20 @@ export default {
         scope: String(info.scope || '2')
       }
       this.coverUrl = info.coverUrl || null
-
-      // 如果数据已准备好,立即触发二级分区列表
-      if (this.categoryRawData.length > 0) {
-        this.handlePCategoryChange(this.form.categoryPid, true)
-      }
-    },
-    // 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 }))
-      }
-    },
-
-    initUploadConfig() {
-      getVideoChannelInfo().then(resp => {
-        if (resp.code === 0) {
-          this.uploadConfig = resp.data
-          this.uploadConfig.title = this.isEdit ? '重新上传视频文件' : '上传视频文件'
-          this.uploadConfig.fileAttrs = { accept: 'video/*' }
-        } else {
-          this.$message.warning(resp.msg)
-        }
-      })
-
-      getVideoCoverChannelInfo().then(res => {
-        if (res.code === 0) {
-          this.imgOssUrl = res.data.ossUrl
-          this.imgHeaders.Authorization = 'Bearer ' + res.data.token
-        } else {
-          this.$message.warning(res.msg)
-        }
-      })
-    },
-
-    // 2. 视频上传组件回调
-    handleVideoAdded(filename) {
-      // 仅在发布模式下,上传视频后自动填充标题
-      if (!this.isEdit && !this.form.title) {
-        this.form.title = filename.replace(/\.[^/.]+$/, '').substring(0, 50)
-      }
-    },
-
-    handleVideoSuccess(uploadResult) {
-      if (this.isEdit) {
-        const videoFile = {
-          videoId: this.videoInfo.videoId,
-          videoFileId: uploadResult.uploadId
-        }
-        updateVideoFile(videoFile).then(res => {
-          this.$message.info(res.msg)
-        }).catch(error => {
-          this.$message.error(error.message)
-        })
-      } else {
-        const { uploadId, file } = uploadResult
-        addVideoFile({
-          videoFileId: uploadId,
-          filename: file.name
-        }).then(resp => {
-          if (resp.code === 0) {
-            this.form.videoId = resp.data
-            this.$message.info('视频文件已上传')
-            // 自动截取视频封面
-            this.generateLocalCover(file.file)
-          } else {
-            this.$message.warning(resp.msg)
-          }
-        })
-      }
-    },
-    generateLocalCover(rawFile) {
-      const video = document.createElement('video')
-      video.src = URL.createObjectURL(rawFile)
-      video.muted = true
-      video.play() // 必须播放或设置 currentTime 才能截取
-
-      video.onloadeddata = () => {
-        video.currentTime = 1 // 截取第 1 秒的画面
-      }
-
-      video.onseeked = () => {
-        const canvas = document.createElement('canvas')
-        canvas.width = video.videoWidth
-        canvas.height = video.videoHeight
-        canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
-
-        canvas.toBlob((blob) => {
-          // 这里的 blob 才是 handleAutoFrame 真正需要的参数
-          this.handleAutoFrame(blob)
-          URL.revokeObjectURL(video.src)
-        }, 'image/jpeg', 0.9)
-      }
-    },
-    // 处理自动截取的封面
-    async handleAutoFrame(blobFile) {
-      // 封装 FormData 手动上传截取的封面
-      const formData = new FormData()
-      formData.append('file', blobFile)
-      formData.append('clientSha256sum', '')
-
-      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 videoCover = {
-              videoId: this.form.videoId,
-              coverFileId: this.form.coverFileId
-            }
-            updateVideoCover(videoCover).then(res => {
-              this.$message.success('视频封面已上传')
-            }).catch(error => {
-              this.$message.error(error.message)
-            })
-          }
-        })
-    },
-
-    // 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!')
-
-      if (isType && isLt10M) {
-        this.imgData.clientSha256sum = ''
-      }
-
-      return isType && isLt10M
+      this.videoRawFile = null
+      this.currentStep = 0
     },
-
-    handleManualCoverSuccess(res, file) {
-      if (res.code === 0) {
-        var videoCover = {}
-        videoCover.coverFileId = res.data.uploadId
-        if (this.isEdit) {
-          videoCover.videoId = this.videoInfo.videoId
-        } else {
-          videoCover.videoId = this.form.videoId
-
-          this.form.coverFileId = res.data.uploadId
-          this.coverUrl = URL.createObjectURL(file.raw)
-        }
-
-        updateVideoCover(videoCover).then(res => {
-          this.$message.success('视频封面已上传')
-        }).catch(error => {
-          this.$message.error(error.message)
-        })
-      }
+    // 第一步视频上传成功,拿到本地原始文件
+    handleVideoSuccess(rawFile) {
+      this.videoRawFile = rawFile
     },
-
-    // 4. 分区联动
-    handlePCategoryChange(pid, isInit = false) {
-      if (!isInit) {
-        this.form.categoryId = null
-      }
-      const parent = this.categoryRawData.find(i => i.value === pid)
-      this.subCategoryList = parent ? parent.children : []
-    },
-
-    // 5. 最终提交
-    submitForm() {
-      this.$refs.postForm.validate((valid) => {
-        if (!valid) return
-        if (!this.form.videoId) {
-          return this.$message.warning('请等待视频上传完成')
-        }
-
-        this.submitting = true
-        // 向上抛出事件,带上模式标识
-        this.$emit('video-publish', {
-          data: this.form,
-          mode: this.isEdit ? 'edit' : 'publish'
-        })
-
-        setTimeout(() => { this.submitting = false }, 1000)
+    handleFinalSubmit() {
+      this.submitting = true
+      this.$emit('video-publish', {
+        data: this.form,
+        mode: this.isEdit ? 'edit' : 'publish'
       })
+      setTimeout(() => { this.submitting = false }, 1000)
     }
   }
 }
@@ -421,37 +111,94 @@ export default {
 
 <style scoped lang="scss">
 .video-post-container {
-  padding: 20px;
-  background-color: #f4f5f7;
+  padding: 32px 20px;
+  background-color: #f6f8fa;
   min-height: 100vh;
+  box-sizing: border-box;
+  display: flex;
+  justify-content: center;
+
+  .steps-wrapper {
+    width: 100%;
+    max-width: 880px; // 限制表单总宽度,防止宽屏下横向拉伸过长导致视觉松散
+    border: 1px solid #eaeefb;
+    border-radius: 12px;
+    box-shadow: 0 6px 16px -4px rgba(0, 0, 0, 0.04);
+    padding: 12px;
+  }
+
+  .custom-steps {
+    margin-bottom: 40px;
+    ::v-deep .el-step__title {
+      font-size: 14px;
+      font-weight: 500;
+    }
+  }
+
+  // 步骤内容切换区域
+  .step-content-router {
+    min-height: 320px;
+  }
 
-  .shadow-box {
-    border: none;
-    border-radius: 8px;
-    box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
+  // 淡入动效
+  .fade-in {
+    animation: fadeIn 0.35s ease-in-out;
   }
 
-  .header-tip {
-    font-size: 12px;
-    color: #9499a0;
-    margin-left: 10px;
+  @keyframes fadeIn {
+    from { opacity: 0; transform: translateY(6px); }
+    to { opacity: 1; transform: translateY(0); }
+  }
+
+  // 统一的下方操作栏
+  .step-actions {
+    margin-top: 36px;
+    padding-top: 20px;
+    border-top: 1px solid #f0f2f5;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    &.center {
+      justify-content: center;
+    }
   }
 }
 
-/* 封面上传样式 */
+/* 封面上传样式优化 */
 .cover-upload-wrapper {
   display: flex;
-  gap: 20px;
-  align-items: center;
+  gap: 24px;
+  align-items: flex-start;
+  padding: 10px 0;
 
   .avatar-uploader {
-    border: 1px dashed #d9d9d9;
-    border-radius: 6px;
+    border: 2px dashed #e0e0e0;
+    border-radius: 10px;
     cursor: pointer;
     overflow: hidden;
-    width: 240px;
-    height: 150px;
-    &:hover { border-color: #409EFF; }
+    width: 260px;
+    height: 155px;
+    background: #fafafa;
+    transition: all 0.25s ease-in-out;
+    flex-shrink: 0;
+
+    &:hover {
+      border-color: #409EFF;
+      background: #f5f9ff;
+    }
+  }
+
+  .uploader-placeholder {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: #909399;
+
+    .placeholder-icon { font-size: 26px; margin-bottom: 8px; }
+    .placeholder-text { font-size: 13px; }
   }
 
   .cover-preview {
@@ -461,22 +208,49 @@ export default {
     .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;
+      background: rgba(29, 33, 41, 0.6); color: #fff;
       display: flex; flex-direction: column; justify-content: center; align-items: center;
-      opacity: 0; transition: opacity 0.3s;
+      gap: 6px; font-size: 13px; opacity: 0; transition: opacity 0.2s ease;
     }
     &: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; }
+
+    .tip-title {
+      font-size: 14px;
+      font-weight: 500;
+      color: #606266;
+      margin-bottom: 10px;
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      i { color: #409EFF; }
+    }
+    .tip-item { font-size: 13px; color: #8c929e; margin: 6px 0; line-height: 1.6; }
+  }
+}
+
+/* 表单精细化调整 */
+.custom-form {
+  ::v-deep .el-form-item__label {
+    padding-bottom: 6px;
+    font-weight: 500;
+    color: #303133;
+  }
+
+  .custom-radio-group {
+    width: 100%;
+    display: flex;
+    ::v-deep .el-radio-button {
+      flex: 1;
+      .el-radio-button__inner { width: 100%; text-align: center; }
+    }
+  }
+
+  .tags-select {
+    ::v-deep .el-select__tags { max-height: 80px; overflow-y: auto; }
   }
 }
 </style>