阿里云OSS直传、分片上传、断点续传。

本文以VUE2、axios为例,后端通过签名的方式与OSS交互

OSS的STS 方式更为简单,封装了好多方法,支持STS的OSS可查看Browser.js完成上传等相关需求


Browser.js文档

  1. 定义FileUploader类:
import axios from 'axios'
import TrainingService from '后端提供签名服务文件'

export class FileUploader {
  constructor (file, options) {
    this.client = axios.create({
      timeout: 5000,
      withCredentials: false // 跨域请求时不携带cookie
    })
    this.file = file // 文件对象
    this.progress = 0 // 进度
    this.isStop = false // 是否停止上传
    this.chunkIndex = 0 // 当前分片索引
    this.chunkSize = options.chunkSize || 1024 * 1024 * 5 // 5MB
    this.chunkNumber = options.chunkSize ? Math.ceil(this.file.size / this.chunkSize) : 1 // 计算分片数量

    this.categoryId = options.categoryId // 分类id
    this.uploadId = options.uploadId // 上传id
    this.objectName = options.objectName // 文件名(分片获取ID时,文件名由后端生成)
    this.extension = options.extension // 文件后缀
    this.originalFilename = options.originalFilename
    this.totalPages = options.totalPages // 总页数
    this.duration = options.duration // 视频时长(秒)
    this.fileSize = this.file.size // 文件大小

    this.uploadStatus = 0
    this.unloadRemark = '' // 卸载备注

    this.uploadedChunks = options.uploadId ? JSON.parse(localStorage.getItem(options.uploadId)) || [] : [] // 已上传的分片
    this.retryCount = 0 // 重试次数
    this.maxRetryCount = options.maxRetryCount || 3 // 最大重试次数
    this.partETags = [] // 新增partETags数组

    this.isChunkUpload = !!options.chunkSize // 是否使用分片上传
  }

  on (event, handle) {
    if (event === 'error') {
      this.onError = handle
    } else if (event === 'success') {
      this.onSuccess = handle
    } else if (event === 'progress') {
      this.onProgress = handle
    } else if (event === 'retry') {
      this.onRetry = handle
    }
  }

  async start () {
    // 如果已经停止,则不执行上传
    if (this.isStop) {
      return
    }
    if (this.isChunkUpload) {
      // 检测是否已经上传完成
      if (this.chunkIndex >= this.chunkNumber) {
        this.uploadCompleted()
        return
      }
      // 跳过已上传的分片
      while (this.uploadedChunks.includes(this.chunkIndex)) {
        this.chunkIndex += 1
      }
      // 读取分片数据
      const start = this.chunkIndex * this.chunkSize // 计算当前分片起始位置
      const chunkData = this.file.slice(start, start + this.chunkSize) // 读取当前分片数据
      const boolname = this.file.name + '-' + this.chunkIndex // 临时文件名
      const tmpFile = new File([chunkData], boolname) // 创建临时文件
      await this.getSignUrlFun(tmpFile, true) // 后端对分片、普通,区分两种签名
    } else {
      await this.getSignUrlFun(this.file, false)  // 后端对分片、普通,区分两种签名
    }
  }

  // 获取签名url并上传数据
  async getSignUrlFun (file, isChunk) {
    let params
    if (isChunk) {
      params = {
        objectName: this.objectName,
        uploadId: this.uploadId,
        partNumber: this.chunkIndex + 1
      }
    } else {
      params = {
        extension: this.extension
      }
    }
    console.log('获取签名参数:', params)
    try {
      const resSign = isChunk ? await TrainingService.getSignUrl(params) : await TrainingService.getsimpleSignUrl(params)
      console.log(resSign)
      if (resSign.code === 200) {
        const config = {
          headers: {
            'Content-Type': 'multipart/form-data'
          }
        }
        if (!isChunk) {
          config.onUploadProgress = (progressEvent) => {
            const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
            this.uploadProgressUpdated(percentCompleted)
          }
        }
        return this.client.put(resSign.result.url, file, config).then((res) => {
          // 上传成功
          if (res.status === 200) {
            console.log('上传成功')
            if (isChunk) {
              // 记录已上传的分片
              this.uploadedChunks.push(this.chunkIndex)
              localStorage.setItem(this.uploadId, JSON.stringify(this.uploadedChunks))
              // 记录eTag
              const eTag = res.headers.etag
              this.partETags.push({
                partNumber: this.chunkIndex + 1,
                etag: eTag
              })
              this.chunkIndex++ // 更新分片索引
              this.uploadProgressUpdated() // 更新进度
              this.retryCount = 0 // 重置重试次数
              return this.start()
            } else {
              this.objectName = resSign.result.objectName
              this.uploadCompleted()
            }
          } else {
            throw new Error('上传失败')
          }
        }).catch((err) => {
          // 上传失败,重试
          console.error(err)
        })
      }
    } catch (error) {
      console.log(error)
    }
  }

  isOver () {
    return this.uploadStatus === 5 || this.uploadStatus === 7
  }

  cancel () {
    this.isStop = true
    this.onError && this.onError('已取消')
  }

  // 重试上传
  retry () {
    this.isStop = false
    this.uploadStatus = 0
    this.start()
    this.onRetry && this.onRetry()
  }

  // 更新上传进度
  uploadProgressUpdated (percentCompleted) {
    if (this.uploadStatus === 0) {
      this.uploadStatus = 3
    }
    if (this.isChunkUpload) {
      percentCompleted = parseInt((this.chunkIndex / this.chunkNumber) * 100 + '')
    }
    this.onProgress && this.onProgress(percentCompleted)
  }
// 分片需要最后有merge接口、来完成所传分片的合并
  async merge () {
    const params = {
      uploadId: this.uploadId,
      objectName: this.objectName,
      partETags: this.partETags
    }
    console.log('归类合并参数:', params)
    try {
      const res = await TrainingService.mergeFile(params)
      console.log(res)
      if (res.code === 200) {
        console.log('文件合并成功')
      } else {
        throw new Error('文件合并失败')
      }
    } catch (error) {
      console.error('文件合并出错:', error)
      this.uploadStatus = 5
      this.onError && this.onError('文件合并失败: ' + error.message)
    }
  }
  async pushFileData (fileInfo) {
    const { totalPages, extension, size, objectName, originalFilename, duration } = fileInfo
    const params = {
      categoryId: this.categoryId, // 此处可删,我做了分类需求
      totalPages,
      extension,
      size,
      objectName,
      originalFilename,
      duration
    }
    try {
      const res = await TrainingService.createFile(params)
      if (res.code === 200) {
        this.$message.success('数据新增成功')
      }
    } catch (error) {
      this.$message.error(error)
      return false
    }
  }
  // 上传完成
  async uploadCompleted () {
    this.uploadStatus = 7
    if (this.isChunkUpload) {
      localStorage.removeItem(this.uploadId) // 上传完成后清除记录信息
      this.merge()
    }
    this.onSuccess && this.onSuccess()
     // 业务代码===》〉上传成功告诉后端
    this.pushFileData({
      totalPages: this.totalPages,  // PDF所需页码
      duration: this.duration,  // video所需时长
      extension: this.extension,
      size: this.fileSize,
      objectName: this.objectName,
      originalFilename: this.originalFilename
    })
  }

  // 上传失败
  uploadedFail (e) {
    console.log('上传失败,错误信息:', e)
    this.uploadStatus = 5
    this.onError && this.onError('失败.2')
  }

  // 获取上传状态
  getUploadStatus () {
    return this.uploadStatus
  }

  // 获取上传进度
  getUploadProgress () {
    if (this.isChunkUpload) {
      if (this.chunkNumber === 0) {
        return 0
      }
      return (this.chunkIndex / this.chunkNumber) * 100
    } else {
      return this.progress
    }
  }

  // 获取上传备注
  getUploadRemark () {
    return this.unloadRemark
  }
}

如何使用?以ant-deisgn-vue的upload组件为例,定义上传组件并用之前定义的FileUploader

// html代码
<template>
  <div class="wrapper">
    <a-upload-dragger
      :customRequest="customRequest"
      :beforeUpload="beforeUpload"
      :showUploadList="false"
      name="file"
      :multiple="true"
    >
      <p class="ant-upload-drag-icon">
        <a-icon type="inbox" />
      </p>
      <p class="ant-upload-text">
        请将PDF拖拽到/点击此处上传文件
      </p>
      <p class="ant-upload-hint">
        支持一次上传多个 / 支持2G以内的PDF文件
      </p>
    </a-upload-dragger>
    <div style="height: 20px;"></div>
    <a-table :columns="columns" :data-source="data">
      <span slot="progress" slot-scope="text">
        <a-progress :percent="text" />
      </span>
      <span slot="uploadStatus" slot-scope="text,record">
        <a-tag color="#108ee9" v-if="text == '3'">上传中</a-tag>
        <a-tag color="#f50" @click="retryUpload(record)" v-if="text == '5'"> 失败重试</a-tag>
        <a-tag color="#108ee9" v-if="text == '7'">上传成功</a-tag>
      </span>
    </a-table>
  </div>
</template>
// js代码
  data () {
    return {
      isChunk: false,
      columns: [
        {
          title: '文件名',
          dataIndex: 'originalFilename'
        },
        {
          title: '文件大小(MB)',
          width: '140px',
          dataIndex: 'size',
          customRender: (text) => {
            const sizeInMB = (text / (1024 * 1024)).toFixed(2)
            return `${sizeInMB} MB`
          }
        },
        {
          title: '进度',
          dataIndex: 'progress',
          width: '130px',
          scopedSlots: { customRender: 'progress' }
        },
        {
          title: '操作',
          width: '100px',
          dataIndex: 'uploadStatus',
          scopedSlots: { customRender: 'uploadStatus' }
        }
      ],
      allowedTypes: ['application/pdf'],
      data: []
    }
  },
  methods: {
    // 解析 PDF 文件并获取页数 引用 import pdf from 'vue-pdf'
    async getPdfPageCount (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = () => {
          const loadingTask = pdf.createLoadingTask(reader.result)
          loadingTask.promise
            .then((pdf) => {
              resolve(pdf.numPages) // 返回 PDF 页数
            })
            .catch((error) => {
              reject(error)
            })
        }
        reader.onerror = (error) => {
          reject(error)
        }
        reader.readAsDataURL(file) // 读取文件为 Data URL
      })
    },
    async customRequest ({ file }) {
      let uploader = null // 初始化上传器实例为 null
      // 获取文件后缀名和原始文件名
      const fileName = file.name
      const lastDotIndex = fileName.lastIndexOf('.')
      const extension = lastDotIndex !== -1 ? fileName.slice(lastDotIndex + 1) : ''
      const originalFilename = lastDotIndex !== -1 ? fileName.slice(0, lastDotIndex) : fileName
      // 创建文件数据对象
      const fileData = {
        categoryId: this.categoryId,
        originalFilename,
        size: file.size, // 文件大小(字节)
        extension, // 文件后缀名
        totalPages: 0, // PDF 页数,默认为 0(稍后赋值)
        progress: 0, // 上传进度
        uploadStatus: 0, // 上传状态
        file, // 文件对象
        uploadId: '', // 上传ID(稍后赋值)
        objectName: '' // 后端返回对象名(稍后赋值)
      }
      fileData.totalPages = await this.getPdfPageCount(file)
      if (this.isChunk) {
        // 如果是分片上传,则执行相关逻辑 // 获取上传ID和文件名等信息
        try {
          const res = await TrainingService.getUploadId({ extension })
          fileData.uploadId = res.uploadId
          fileData.objectName = res.objectName
        } catch (error) {
          console.error('获取上传ID失败:', error)
          this.$message.error('获取上传ID失败')
          return
        }
        uploader = new FileUploader(file, {
          chunkSize: 5 * 1024 * 1024,
          uploadId: fileData.uploadId,
          objectName: fileData.objectName || '',
          extension: fileData.extension,
          originalFilename,
          totalPages: fileData.totalPages
        })
      } else {
        // 如果是非分片上传,则执行相关逻辑
        uploader = new FileUploader(file, {
          extension: fileData.extension,
          originalFilename,
          totalPages: fileData.totalPages
        })
      }
      this.data.push(fileData) // 将文件数据添加到列表中

      const index = this.data.length - 1 // 获取最新添加的文件数据索引
      this.data[index].uploader = uploader // 将上传器实例保存到文件数据中

      // 监听上传事件
      uploader.on('error', error => {
        console.error('上传错误:', error)
        fileData.uploadStatus = 5 // 上传失败
      })

      uploader.on('success', async () => {
        console.log('上传成功函数:上传成功!Wxz')
        this.$set(this.data[index], 'uploadStatus', 7) // 上传成功状态更新,确保响应式地更新视图
        this.$set(this.data[index], 'progress', 100) // 使用 Vue.set 更新进度
      })

      uploader.on('progress', progress => {
        console.log('上传进度:', progress + '%')
        this.$set(this.data[index], 'progress', progress) // 使用 Vue.set 更新进度
        this.$set(this.data[index], 'uploadStatus', 3) // 使用 Vue.set 更新状态
      })

      uploader.on('retry', () => {
        console.log('正在重试上传...')
        this.$set(this.data[index], 'uploadStatus', 5) // 上传成功状态更新,确保响应式地更新视图
      })

      // 开始上传
      uploader.start()
    },
    beforeUpload (file) {
      if (!this.allowedTypes.includes(file.type)) {
        this.$message.error('只支持上传PDF文件')
        return false
      }
      const fileSize = file.size
      // 2G 对应的字节数
      const maxSize = 2 * 1024 * 1024 * 1024
      // 判断文件大小是否超过 2G
      if (fileSize > maxSize) {
        this.$message.error('文件大小超过 2G,无法上传')
        // 阻止文件上传
        return false
      }
      if (fileSize > 5 * 1024 * 1024) {
        this.isChunk = true
      } else {
        this.isChunk = false
      }
      // 允许文件上传
      return true
    },
    async retryUpload (record) {
      const index = this.data.findIndex(item => item.originalFilename === record.originalFilename)
      if (index !== -1) {
        this.data[index].uploadStatus = 3
        const uploader = this.data[index].uploader
        uploader.retry()
      }
    }
  },

引用组件,完成上传

<a-modal
      title="PDF上传"
      :visible="visible"
      width="700px"
      @ok="handleOk"
      @cancel="handleCancel"
    >
      <upload-pdf :categoryId="categoryId"></upload-pdf>
</a-modal>
    
import uploadPdf from '@/iop-ntsp/views/training/components/uploadPdf'
components: {
    uploadPdf
  },
// 注 categoryId为分类ID,对于上传文件的类型有规划
  • 后端返回样例:
    普通上传
    三个接口
    分片上传
    在这里插入图片描述
    整体效果
    在这里插入图片描述
Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐