Browse Source

将 UploaderCard.vue 抽离出来作为一个公共的分片上传组件

reghao 8 hours ago
parent
commit
f90a0f85b1

+ 56 - 54
src/components/card/VideoUploaderCard.vue → src/components/card/UploaderCard.vue

@@ -2,7 +2,7 @@
   <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>
+      <span style="margin-left: 8px">{{ uploadConfig.title }}</span>
       <el-tag v-if="uploadStatus" :type="statusTagType" size="mini" style="float: right">
         {{ uploadStatus }}
       </el-tag>
@@ -23,8 +23,8 @@
       <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>
+          <p>将文件拖到此处,或 <uploader-btn :attrs="uploadConfig.fileAttrs" class="uploader-btn">点击上传</uploader-btn></p>
+          <div class="upload-tip">文件最大不超过 10GB</div>
         </div>
       </uploader-drop>
       <uploader-list />
@@ -34,16 +34,21 @@
 
 <script>
 import SparkMD5 from 'spark-md5'
-import { getVideoChannelInfo, prepareUpload, checkSample } from '@/api/file'
-import { addVideoFile } from '@/api/video' // 假设接口已封装
+import { prepareUpload, checkSample } from '@/api/file'
 import { hashFile } from '@/utils/functions'
 
 export default {
-  name: 'VideoUploaderCard',
+  name: 'UploaderCard',
+  props: {
+    // 接收父组件传入的配置对象
+    uploadConfig: {
+      type: Object,
+      required: true
+    }
+  },
   data() {
     return {
       uploaderOptions: null,
-      fileAttrs: { accept: 'video/*' },
       uploadStatus: '', // 计算Hash, 预校验, 采样校验, 上传中, 已完成, 错误
       videoChannelCode: null
     }
@@ -54,40 +59,46 @@ export default {
       return map[this.uploadStatus] || 'info'
     }
   },
+  watch: {
+    // 确保在数据传入后初始化
+    uploadConfig: {
+      immediate: true,
+      handler(newVal) {
+        if (newVal) {
+          this.initUploader(newVal)
+        }
+      }
+    }
+  },
   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('加载上传配置失败')
+    initUploader(uploadConfig) {
+      this.videoChannelCode = uploadConfig.channelCode
+      var ossUrl = uploadConfig.ossUrl
+      var token = uploadConfig.token
+
+      this.uploaderOptions = {
+        target: 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 ' + token }
       }
     },
 
@@ -145,7 +156,6 @@ export default {
         // 4. 如果没能秒传,启动分片上传
         if (!isFastUpload) {
           this.uploadStatus = '上传中'
-          this.generateVideoFrame(file.file) // 截图
           file.resume()
         }
       } catch (e) {
@@ -174,17 +184,10 @@ export default {
 
     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)
-        }
+      this.$emit('success', {
+        uploadId: resData.uploadId,
+        file: file,
+        channelCode: this.videoChannelCode
       })
     },
 
@@ -192,6 +195,9 @@ export default {
       const res = JSON.parse(response)
       if (res.code === 0) {
         this.handleUploadSuccess(file, res.data)
+      } else {
+        this.uploadStatus = '错误'
+        this.$message.error(res.msg || '上传失败')
       }
     },
 
@@ -201,10 +207,6 @@ export default {
 
     onFileProgress() {
       this.uploadStatus = '上传中'
-    },
-
-    generateVideoFrame(file) {
-      // 截图逻辑同前...
     }
   }
 }

+ 153 - 146
src/views/admin/oss/AdminStoreObject.vue

@@ -1,204 +1,211 @@
 <template>
-  <el-container>
-    <el-header height="220">
-      <h3>对象列表</h3>
-      <el-row style="margin-top: 10px">
-        <el-button type="plain" icon="el-icon-files">存储节点</el-button>
-        <el-select
-          v-model="selectedValue"
-          style="margin-left: 5px"
-          @change="onSelectChange"
-        >
-          <el-option v-for="(item, index) in tableList" :key="index" :label="item.label" :value="item.value" />
+  <el-container class="admin-store-container">
+    <el-header height="auto" style="padding: 20px; background: #fff; border-bottom: 1px solid #e6e6e6;">
+      <div class="header-top">
+        <div class="title-section">
+          <i class="el-icon-folder-opened" style="color: #409EFF; font-size: 24px; margin-right: 10px;" />
+          <h3 style="margin: 0; display: inline-block;">对象列表</h3>
+        </div>
+        <div class="action-section">
+          <el-button type="primary" icon="el-icon-upload" @click="handleOpenUpload">上传文件</el-button>
+          <el-button type="success" icon="el-icon-refresh" plain @click="onRefresh">刷新</el-button>
+        </div>
+      </div>
+
+      <el-row type="flex" align="middle" style="margin-top: 20px; background: #f8f9fa; padding: 10px; border-radius: 4px;">
+        <span style="font-size: 14px; color: #606266; margin-right: 10px;">当前节点:</span>
+        <el-select v-model="selectedValue" placeholder="请选择存储节点" size="small" @change="onSelectChange">
+          <el-option v-for="(item, index) in tableList" :key="index" :label="item.label" :value="item.value">
+            <i class="el-icon-cpu" style="margin-right: 8px;" />
+            <span>{{ item.label }}</span>
+          </el-option>
         </el-select>
+        <el-divider direction="vertical" />
+        <el-tag size="small" type="info">共 {{ dataList.length }} 个对象</el-tag>
       </el-row>
     </el-header>
+
     <el-main>
       <el-table
+        v-loading="loading"
         :data="dataList"
         border
-        height="480"
-        style="width: 100%"
+        stripe
+        height="calc(100vh - 280px)"
+        style="width: 100%; border-radius: 8px; overflow: hidden;"
+        :header-cell-style="{background:'#f5f7fa', color:'#606266'}"
       >
-        <el-table-column
-          fixed="left"
-          label="No"
-          type="index"
-        />
-        <el-table-column
-          prop="nodeAddr"
-          label="文件名"
-        />
-        <el-table-column
-          prop="httpPort"
-          label="修改时间"
-        />
-        <el-table-column
-          prop="rpcPort"
-          label="大小"
-        />
-        <el-table-column
-          prop="status"
-          label="文件类型"
-        />
-        <el-table-column
-          fixed="right"
-          label="操作"
-          width="280"
-        >
+        <el-table-column prop="nodeAddr" label="对象名" min-width="200" />
+        <el-table-column prop="nodeAddr" label="文件名" min-width="200">
+          <template slot-scope="scope">
+            <div style="display: flex; align-items: center">
+              <i class="el-icon-document" style="font-size: 18px; margin-right: 10px; color: #909399;" />
+              <span style="font-weight: 500">{{ scope.row.nodeAddr }}</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="rpcPort" label="大小" width="120">
+          <template slot-scope="scope">{{ formatSize(scope.row.rpcPort) }}</template>
+        </el-table-column>
+        <el-table-column prop="status" label="类型" width="120" align="center" />
+        <el-table-column prop="httpPort" label="修改时间" width="180" />
+        <el-table-column fixed="right" label="操作" width="260" align="center">
           <template slot-scope="scope">
-            <el-button
-              size="mini"
-              @click="handleEdit(scope.$index, scope.row)"
-            >磁盘详情</el-button>
-            <el-button
-              size="mini"
-              @click="handleEdit(scope.$index, scope.row)"
-            >禁用</el-button>
-            <el-button
-              size="mini"
-              type="danger"
-              @click="handleDelete(scope.$index, scope.row)"
-            >删除</el-button>
+            <el-button size="mini" type="text" icon="el-icon-monitor" @click="handleMonitor">磁盘详情</el-button>
+            <el-button size="mini" type="text" icon="el-icon-delete" style="color: #F56C6C" @click="handleDelete(scope.$index, scope.row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
     </el-main>
 
     <el-dialog
-      append-to-body
-      :visible.sync="showEditScopeDialog"
+      title="上传对象至存储节点"
+      :visible.sync="showUploadDialog"
+      width="550px"
       center
+      :close-on-click-modal="false"
+      custom-class="upload-dialog"
     >
-      <div>
-        <h3>详情</h3>
-        <el-table
-          :data="tableList"
-          border
-          height="480"
-          style="width: 100%"
-        >
-          <el-table-column
-            fixed="left"
-            label="No"
-            type="index"
-          />
-          <el-table-column
-            prop="fsType"
-            label="文件系统"
-          />
-          <el-table-column
-            prop="volume"
-            label="磁盘分区"
-          />
-          <el-table-column
-            prop="storeDir"
-            label="存储目录"
-          />
-          <el-table-column
-            prop="total"
-            label="分区容量"
-          >
-            <template slot-scope="scope">
-              <el-progress :percentage="scope.row.percent"></el-progress>
-            </template>
-          </el-table-column>
-          <el-table-column
-            prop="totalInode"
-            label="inode 容量"
-          >
-            <template slot-scope="scope">
-              <el-progress :percentage="scope.row.percentInode"></el-progress>
-            </template>
-          </el-table-column>
-        </el-table>
+      <uploader-card
+        v-if="showUploadDialog && uploadConfig"
+        :upload-config="uploadConfig"
+        @success="handleUploadSuccess"
+      />
+      <div v-else style="text-align: center; padding: 40px;">
+        <el-button v-if="!uploadConfig" type="text" loading>正在获取上传凭证...</el-button>
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="showUploadDialog = false">关闭</el-button>
       </div>
     </el-dialog>
+
+    <el-dialog title="节点磁盘资源监控" :visible.sync="showEditScopeDialog" width="70%">
+      <el-table :data="tableList" border stripe size="small">
+        <el-table-column label="No" type="index" width="50" />
+        <el-table-column prop="fsType" label="文件系统" width="100" />
+        <el-table-column prop="volume" label="磁盘分区" />
+        <el-table-column prop="storeDir" label="存储目录" />
+        <el-table-column label="分区容量使用率" width="200">
+          <template slot-scope="scope">
+            <el-progress :percentage="scope.row.percent || 0" :color="customColors" />
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-dialog>
   </el-container>
 </template>
 
 <script>
+import UploaderCard from 'components/card/UploaderCard.vue'
 import { getUserNodeKeyValue } from '@/api/oss'
+import { getVideoChannelInfo } from '@/api/file'
 
 export default {
   name: 'AdminStoreObject',
+  components: { UploaderCard },
   data() {
     return {
-      queryInfo: {
-        path: null
-      },
-      // 屏幕宽度, 为了控制分页条的大小
-      screenWidth: document.body.clientWidth,
-      currentPage: 1,
-      pageSize: 12,
-      totalSize: 0,
+      loading: false,
+      showUploadDialog: false,
+      showEditScopeDialog: false,
+      selectedValue: null,
+      uploadConfig: null, // 上传所需的通道配置
       dataList: [],
       tableList: [],
-      nextId: 0,
-      // **********************************************************************
-      showEditScopeDialog: false,
-      form: {
-        videoId: null,
-        scope: 1
-      },
-      selectedValue: null
+      customColors: [
+        { color: '#67C23A', percentage: 40 },
+        { color: '#E6A23C', percentage: 70 },
+        { color: '#F56C6C', percentage: 90 }
+      ]
     }
   },
   created() {
-    document.title = '我的节点'
+    document.title = '我的存储节点'
     this.getData()
   },
   methods: {
-    getData() {
-      this.tableList = []
-      getUserNodeKeyValue().then(resp => {
+    // 1. 初始化数据
+    async getData() {
+      this.loading = true
+      try {
+        const resp = await getUserNodeKeyValue()
         if (resp.code === 0) {
-          this.tableList = resp.data
+          this.tableList = resp.data || []
+          if (this.tableList.length > 0 && !this.selectedValue) {
+            this.selectedValue = this.tableList[0].value
+          }
+        }
+      } finally {
+        this.loading = false
+      }
+    },
 
-          this.selectedValue = this.tableList[0].value
-          this.dataList = []
+    // 2. 处理上传逻辑
+    async handleOpenUpload() {
+      this.showUploadDialog = true
+      // 仅在配置为空时请求,或每次打开都更新
+      if (!this.uploadConfig) {
+        const resp = await getVideoChannelInfo()
+        if (resp.code === 0) {
+          this.uploadConfig = resp.data
+          this.uploadConfig.title = '上传文件'
+          this.uploadConfig.fileAttrs = { accept: 'image/*' }
         } else {
-          this.$message.error(resp.msg)
+          this.$message.error('获取上传通道失败')
         }
+      }
+    },
+
+    handleUploadSuccess(result) {
+      const { uploadId, file } = result
+      this.$notify({
+        title: '上传成功',
+        message: `文件 [${file.name}] 已上传完成`,
+        type: 'success'
       })
+      // 可以在此处调用额外的业务 API 将 uploadId 与当前节点关联
+      // this.bindFileToNode(uploadId, this.selectedValue)
+
+      this.onRefresh() // 刷新列表
+    },
+
+    // 3. 通用方法
+    formatSize(bytes) {
+      if (!bytes) return '0 B'
+      const k = 1024
+      const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+      const i = Math.floor(Math.log(bytes) / Math.log(k))
+      return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
     },
     onRefresh() {
       this.getData()
     },
-    handleCurrentChange(pageNumber) {
-      this.currentPage = pageNumber
+    onSelectChange(val) {
       this.getData()
-      // 回到顶部
-      scrollTo(0, 0)
     },
-    handleEdit(index, row) {
+    handleMonitor() {
       this.showEditScopeDialog = true
     },
     handleDelete(index, row) {
-      this.$confirm('确定要删除 ' + row.title + '?', '提示', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        this.$message.info('handleDelete')
-      }).catch(() => {
-        this.$message({
-          type: 'info',
-          message: '已取消'
-        })
-      })
-    },
-    onAddChannel() {
-      this.showEditScopeDialog = false
-    },
-    onSelectChange() {
-      this.dataList = []
-    },
-    handleClose() {
+      this.$confirm(`永久删除文件 [${row.nodeAddr}], 是否继续?`, '提示', { type: 'warning' })
+        .then(() => { this.$message.success('模拟删除成功') })
     }
   }
 }
 </script>
 
-<style>
+<style scoped lang="scss">
+.admin-store-container {
+  background-color: #f0f2f5;
+  min-height: 100vh;
+  .header-top { display: flex; justify-content: space-between; align-items: center; }
+  .el-main { padding: 20px; }
+  .shadow-box { border: none; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
+}
+
+/* 样式穿透:让弹窗内的 Card 看起来像对话框的一部分 */
+::v-deep .upload-dialog {
+  .el-dialog__body { padding: 10px 20px 30px; }
+  .upload-card { box-shadow: none !important; border: 1px solid #ebeef5; }
+}
 </style>

+ 56 - 12
src/views/post/VideoPostPublish.vue

@@ -2,11 +2,12 @@
   <div class="video-post-container">
     <el-row :gutter="20">
       <el-col :md="14" :sm="24">
-        <video-uploader-card
+        <uploader-card
+          v-if="uploadConfig"
           ref="videoUploader"
+          :upload-config="uploadConfig"
           @before-upload="handleVideoAdded"
           @success="handleVideoSuccess"
-          @frame-extracted="handleAutoFrame"
         />
 
         <el-card class="box-card shadow-box" style="margin-top: 20px;">
@@ -135,15 +136,16 @@
 </template>
 
 <script>
-import VideoUploaderCard from 'components/card/VideoUploaderCard.vue'
-import { videoRegion } from '@/api/video'
-import { getVideoCoverChannelInfo } from '@/api/file'
+import UploaderCard from 'components/card/UploaderCard.vue'
+import { addVideoFile, videoRegion } from '@/api/video'
+import { getVideoChannelInfo, getVideoCoverChannelInfo } from '@/api/file'
 
 export default {
   name: 'VideoPostPublish',
-  components: { VideoUploaderCard },
+  components: { UploaderCard },
   data() {
     return {
+      uploadConfig: null,
       // 封面上传相关
       imgOssUrl: '',
       imgHeaders: { Authorization: '' },
@@ -192,6 +194,14 @@ export default {
     },
 
     async initCoverUploadConfig() {
+      getVideoChannelInfo().then(resp => {
+        if (resp.code === 0) {
+          this.uploadConfig = resp.data
+          this.uploadConfig.title = '上传视频文件'
+          this.uploadConfig.fileAttrs = { accept: 'video/*' }
+        }
+      })
+
       const res = await getVideoCoverChannelInfo()
       if (res.code === 0) {
         this.imgOssUrl = res.data.ossUrl
@@ -207,13 +217,49 @@ export default {
       }
     },
 
-    handleVideoSuccess(videoId) {
-      this.form.videoId = videoId
-      this.$notify.success({ title: '成功', message: '视频处理完毕' })
+    handleVideoSuccess(uploadResult) {
+      const { uploadId, file, channelCode } = uploadResult
+      this.generateLocalCover(file.file)
+
+      // 调用业务接口关联视频
+      addVideoFile({
+        videoFileId: uploadId,
+        channelCode: channelCode,
+        filename: file.name
+      }).then(resp => {
+        if (resp.code === 0) {
+          this.form.videoId = resp.data
+          this.$notify.success({ title: '成功', message: '视频处理完毕' })
+        } 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)
+      }
+    },
     // 处理自动截取的封面
-    handleAutoFrame(blobFile) {
+    async handleAutoFrame(blobFile) {
       // 封装 FormData 手动上传截取的封面
       const formData = new FormData()
       formData.append('file', blobFile)
@@ -271,8 +317,6 @@ export default {
         this.submitting = true
         // 触发父组件或发送 API
         this.$emit('video-publish', this.form)
-        console.log('提交数据:', this.form)
-
         // 模拟提交完成
         setTimeout(() => { this.submitting = false }, 1000)
       })