vue3组件 - 大文件上传

示例
github代码

特性

  1. 分片
  2. 秒传
  3. 断点续传
  4. 显示上传进度

接口

需要3个接口:

  1. GET /system/file/uploadChunk 请求:通过query参数传递文件信息内容,文件MD5、文件总大小、文件分片数、文件每片的大小等。
    响应:是否秒传,待上传的分片。
  2. POST /system/file/uploadChunk 分片发送文件。
  3. POST /system/file/merge 文件分片传输完成,合并分片文件。

小文件上传(单个)

请求3个接口:

GET /system/file/uploadChunk请求参数:

接口响应数据:

POST /system/file/uploadChunk请求数据(返回数据为true或false):

POST /system/file/merge请求数据:

大文件上传(分片)

文件分片上传,所有分片的文件上传完成后,调用merge接口合并。

秒传

请求2个接口:

GET /system/file/uploadChunk请求参数:

接口响应数据:

exists为true时表示秒传。

POST /system/file/merge请求数据:

代码

FileUploader.vue

html 复制代码
<template>
  <div id="global-uploader">
    <!-- 上传 -->
    <uploader
      ref="uploader"
      :options="options"
      :autoStart="false"
      @file-added="onFileAdded"
      @file-success="onFileSuccess"
      @file-progress="onFileProgress"
      @file-error="onFileError"
      class="uploader-app">
      <uploader-unsupport></uploader-unsupport>

      <uploader-btn id="global-uploader-btn" :attrs="attrs" ref="uploadBtn">选择文件</uploader-btn>
      
      <uploader-list></uploader-list>
      <!-- <uploader-list v-slot:default="props" v-show="panelShow">
        <div class="file-panel" :class="{'collapse': collapse}">
          <div class="file-title">
            <h2>文件列表1</h2>
            <div class="operate">
              <n-button @click="fileListShow" type="text" :title="collapse ? '展开':'折叠' ">
                <i class="el-icon-d-caret" style="color:black;font-size: 18px"
                   :class="collapse ? 'inuc-fullscreen': 'inuc-minus-round'"></i>
              </n-button>
              <n-button @click="close" type="text" title="关闭">
                <i class="el-icon-close" style="color:black;font-size: 18px"></i>
              </n-button>
            </div>
          </div>

          <ul class="file-list">
            <li v-for="file in props.fileList" :key="file.id">
              <uploader-file :class="'file_' + file.id" ref="files" :file="file" :list="true"></uploader-file>
            </li>
            <div class="no-file" v-if="!props.fileList.length"><i class="iconfont icon-empty-file"></i> 暂无待上传文件</div>
          </ul>
        </div>
      </uploader-list> -->

    </uploader>

  </div>
</template>

<script>
  /**
   *   全局上传插件
   *   调用方法:Bus.$emit('openUploader', {}) 打开文件选择框,参数为需要传递的额外参数
   *   监听函数:Bus.$on('fileAdded', fn); 文件选择后的回调
   *            Bus.$on('fileSuccess', fn); 文件上传成功的回调
   */
import $ from 'jquery';
import SparkMD5 from 'spark-md5';
import uploader from 'vue-simple-uploader';
// import Bus from '../../../../assets/js/bus';
import { fileMerge } from '@/apis/fileuploader';
import { merge } from 'lodash';
import { ACCEPT_CONFIG } from './config';
let type = 1;
  export default {
    props: {
      type: {
        type: Number,
        required: false
      }
    },
    data() {
      return {
        options: {
          // 目标上传 URL
          target: '/system/file/upload',
          //分块大小
          chunkSize: 5 * 1024 * 1000,
          //上传文件时文件的参数名,默认file
          fileParameterName: 'file',
          //并发上传数
          //simultaneousUploads: 1,
          //最大自动失败重试上传次数
          maxChunkRetries: 2,
          //重试间隔 单位毫秒
          //chunkRetryInterval: 5000,
          //是否开启服务器分片校验
          testChunks: true,
          // 服务器分片校验函数,秒传及断点续传基础
          checkChunkUploadedByResponse: function (chunk, message) {
            let objMessage = JSON.parse(message);
            if (objMessage.exists) {
              return true;
            }
            // if (objMessage.skipUpload) {
            //   return true;
            // }
            return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
          },
          headers: {
            'Authorization-Token': '',
          },
          // 额外的自定义查询参数
          query: { upload_token: 'my_token' }
        },
        attrs: {
          accept: ACCEPT_CONFIG.getAll()
        },
        panelShow: false,   //选择文件后,展示上传panel
        collapse: false
      }
    },
    mounted() {
      //接收子组件触发的事件
      // Bus.$on('openUploader', query => {
      //   this.params = query || {};
      //   this.options.headers.Authorization = 'Bearer ' + query.token


      //   if (this.$refs.uploadBtn) {
      //     $("#global-uploader-btn").click();
      //   }
      // });
    },
    computed: {
      //Uploader实例
      uploader() {
        return this.$refs.uploader.uploader;
      }
    },
    methods: {
      getUrl() {
        return `/lightning-web/upload/uploadChunk?type=${this.type}`
      },
      openUploader() {
        if (this.$refs.uploadBtn) {
          document.getElementById("global-uploader-btn").click();
        }
      },
      test() {
        alert();
      },
      onFileAdded(file) {
        this.panelShow = true;
        this.computeMD5(file);

        // Bus.$emit('fileAdded');
      },
      //上传过程中,会不断触发file-progress上传进度的回调
      onFileProgress(rootFile, file, chunk) {
        console.log(`上传中 ${file.name},chunk:${chunk.startByte / 1024 / 1024} ~ ${chunk.endByte / 1024 / 1024}`)
      },
      onFileSuccess(rootFile, file, response, chunk) {

        let res = JSON.parse(response);

        // TODO 如有需要 解开注释 和后台协议如何处理这种情况:服务器自定义的错误(即虽返回200,但是是错误的情况),这种错误是Uploader无法拦截的
        // if (!res.result) {
        //   this.$message({message: res.message, type: 'error'});
        //   // 文件状态设为"失败"
        //   this.statusSet(file.id, 'failed');
        //   return
        // }
        // 如果服务端返回需要合并
        if (res) {
          // 文件状态设为"合并中"
          this.statusSet(file.id, 'merging');
          let param = {
            'filename': rootFile.name,
            'identifier': rootFile.uniqueIdentifier,
            'totalSize': rootFile.size,
            'type': type
          }
          fileMerge(param).then(res => {
            // 文件合并成功
            // Bus.$emit('fileSuccess');

            this.statusRemove(file.id);
          }).catch(e => {
            console.log("合并异常,重新发起请求,文件名为:", file.name)
            //由于网络或服务器原因,导致合并过程中断线,此时如果不重新发起请求,就会进入失败的状态,导致该文件无法重试
            file.retry();
          });

          // 不需要合并
        } else {
          // Bus.$emit('fileSuccess');
          console.log('上传成功');
        }
      },
      onFileError(rootFile, file, response, chunk) {
        // this.$message({
        //   message: response,
        //   type: 'error'
        // })
      },

      /**
       * 计算md5,实现断点续传及秒传
       * @param file
       */
      computeMD5(file) {
        let fileReader = new FileReader();
        let time = new Date().getTime();
        let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
        let currentChunk = 0;
        const chunkSize = 10 * 1024 * 1000;
        let chunks = Math.ceil(file.size / chunkSize);
        let spark = new SparkMD5.ArrayBuffer();

        // 文件状态设为"计算MD5"
        this.statusSet(file.id, 'md5');
        file.pause();

        loadNext();

        fileReader.onload = (e => {

          spark.append(e.target.result);

          if (currentChunk < chunks) {
            currentChunk++;
            loadNext();

            // 实时展示MD5的计算进度
            this.$nextTick(() => {
              $(`.myStatus_${file.id}`).text('校验MD5 ' + ((currentChunk / chunks) * 100).toFixed(0) + '%')
            })
          } else {
            let md5 = spark.end();
            this.computeMD5Success(md5, file);
            console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
          }
        });

        fileReader.onerror = function () {
          this.error(`文件${file.name}读取出错,请检查该文件`)
          file.cancel();
        };

        function loadNext() {
          let start = currentChunk * chunkSize;
          let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;

          fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
        }
      },

      computeMD5Success(md5, file) {
        // 将自定义参数直接加载uploader实例的opts上
        Object.assign(this.uploader.opts, {
          query: {
            ...this.params,
          }
        })

        file.uniqueIdentifier = md5;
        file.resume();
        this.statusRemove(file.id);
      },

      fileListShow() {
        let $list = $('#global-uploader .file-list');

        if ($list.is(':visible')) {
          $list.slideUp();
          this.collapse = true;
        } else {
          $list.slideDown();
          this.collapse = false;
        }
      },
      close() {
        this.uploader.cancel();

        this.panelShow = false;
      },

      /**
       * 新增的自定义的状态: 'md5'、'transcoding'、'failed'
       * @param id
       * @param status
       */
      statusSet(id, status) {
        let statusMap = {
          md5: {
            text: '校验MD5',
            bgc: '#fff'
          },
          merging: {
            text: '合并中',
            bgc: '#e2eeff'
          },
          transcoding: {
            text: '转码中',
            bgc: '#e2eeff'
          },
          failed: {
            text: '上传失败',
            bgc: '#e2eeff'
          }
        }

        this.$nextTick(() => {
          $(`<p class="myStatus_${id}"></p>`).appendTo(`.file_${id} .uploader-file-status`).css({
            'position': 'absolute',
            'top': '0',
            'left': '0',
            'right': '0',
            'bottom': '0',
            'zIndex': '1',
            'line-height': 'initial',
            'backgroundColor': statusMap[status].bgc
          }).text(statusMap[status].text);
        })
      },
      statusRemove(id) {
        this.$nextTick(() => {
          $(`.myStatus_${id}`).remove();
        })
      },

      error(msg) {
        this.$notify({
          title: '错误',
          message: msg,
          type: 'error',
          duration: 2000
        })
      }
    },
    watch: {
    },
    destroyed() {},
    components: {}
  }
</script>
相关推荐
lovepenny4 分钟前
Failed to resolve entry for package "js-demo-tools". The package may have ......
前端·npm
超凌12 分钟前
threejs 创建了10w条THREE.Line,销毁数据,等待了10秒
前端
车厘小团子29 分钟前
🎨 前端多主题最佳实践:用 Less Map + generate-css 打造自动化主题系统
前端·架构·less
芒果12534 分钟前
SVG图片通过img引入修改颜色
前端
海云前端11 小时前
前端面试ai对话聊天通信怎么实现?面试实际经验
前端
一枚前端小能手1 小时前
🔧 半夜被Bug叫醒的痛苦,错误监控帮你早发现
前端
Juchecar1 小时前
Vue 3 单页应用Router路由跳转示例
前端
这人是玩数学的1 小时前
在 Cursor 中规范化生成 UI 稿实践
前端·ai编程·cursor
UncleKyrie1 小时前
🎨 市面上主流 Figma to Code MCP 对比
前端
南半球与北海道#1 小时前
前端引入vue-super-flow流程图插件
前端·vue.js·流程图