vue2+elementui解决大量文件上传没反应问题

本文介绍了一个基于Vue.js和Element UI的拖拽上传组件实现。该组件支持文件拖拽上传和点击上传两种方式,具有以下核心功能:

  1. 支持多文件上传,可设置文件大小限制(默认5000MB)和文件类型过滤
  2. 提供上传进度显示、失败重试(最多3次)和取消上传功能
  3. 自动检测重复文件并提示
  4. 可配置并发上传数(默认3个)和超时时间(默认5秒)
  5. 实时统计上传状态(总文件数、成功数、失败数、重复数)

组件采用响应式设计,包含上传区域和文件列表两个面板,使用Promise实现异步上传控制,并通过AbortController支持上传取消操作。上传过程中会实时更新进度条和状态提示,完成上传后会触发相应的事件回调。

javascript 复制代码
<template>
  <div class="drag-uploader" v-if="uploadShow">
    <div
      class="drag-area"
      @drop.prevent="handleDrop"
      @dragover.prevent="dragover = true"
      @dragleave.prevent="dragover = false"
      :class="{ 'drag-over': dragover }"
    >
      <el-upload
        ref="upload"
        action="#"
        :multiple="true"
        :show-file-list="false"
        :on-change="handleFileChange"
        :http-request="handleHttpRequest"
        :accept="accept"
      >
        <div class="upload-content">
          <i class="el-icon-upload"></i>
          <div class="upload-text">
            将文件拖到此处,或<em>点击上传</em>
          </div>
          <!-- <div class="upload-hint">
            支持多文件上传,最大文件大小: {{ maxSize }}MB
          </div> -->
        </div>
      </el-upload>
    </div>

    <!-- <div class="upload-controls">
      <el-button
        type="primary"
        size="small"
        @click="startUpload"
        :disabled="uploading || !hasFilesToUpload"
      >
        <span v-if="uploading">上传中</span>
        <span v-else>开始上传</span>
      </el-button>
      <el-button
        size="small"
        @click="cancelUpload"
        :disabled="!uploading"
      >
        取消上传
      </el-button>
      <el-button
        size="small"
        @click="clearCompleted"
        :disabled="uploading"
      >
        清空已完成
      </el-button>
      <el-button
        size="small"
        @click="files = []"
        :disabled="uploading"
      >
        全部清空
      </el-button>
    </div> -->
    <div class="file-list" v-if="uploadShow && clickFiles.length > 0">
      <div v-if="clickFiles.length > 0" class="file-title">
        <div>
          <span v-if="uploading">文件上传中:
            <span>还剩{{ clickFiles.length - getSuccessFiles().length - this.againFiles.length }}个,</span>
          </span>
          <span  v-if="!uploading">文件上传完成:</span> 
          <span>共选择文件{{ clickFiles.length }}个,</span>
          <span v-if="!uploading">
            <span>成功{{ getSuccessFiles().length }}个,</span>
            <span>重复{{ clickFiles.length - getSuccessFiles().length - getFailFiles().length }}个
              <el-popover
                placement="top-start"
                width="200"
                class="again-popover"
                trigger="hover"
                :content="`选择的上传文件中有重复文件会自动过滤掉;\n选择的上传文件与已经添加过的文件有重复的会在列表中展示。`">
                <i slot="reference" class="el-icon-warning"></i>
              </el-popover>
              ,
            </span>
            <span>失败{{ getFailFiles().length }}个。</span>
            <el-popover
              placement="top-start"
              width="100"
              trigger="hover"
              content="重复文件和失败文件确认添加时,自动过滤。">
              <i slot="reference" class="el-icon-question"></i>
            </el-popover>
          </span>
        </div>
        <slot name="file-button"></slot>
      </div>
      <div class="fileList-item">
        <div v-for="(file, index) in reversedFiles" :key="file.uid" class="file-item">
          <div class="file-info">
            <div class="file-name">{{ file.name }}</div>
            <div class="file-size">{{ formatFileSize(file.size) }}</div>
            <div class="file-status">
              <span v-if="file.status === 'waiting'">等待上传</span>
              <span v-if="file.status === 'uploading' && file.retryCount > 0">重新上传中 ({{ file.percentage }}%)</span>
              <span v-else-if="file.status === 'uploading'">上传中 ({{ file.percentage }}%)</span>
              <span v-if="file.status === 'success'" class="success">上传成功</span>
              <span v-if="file.status === 'error'" class="error">上传失败</span>
              <span v-if="file.status === 'canceled'">已取消</span>
              <span v-if="file.status === 'again'">重复</span>
            </div>
          </div>
          <div class="file-progress">
            <el-progress
              :percentage="file.percentage || 0"
              :status="getProgressStatus(file.status)"
              :stroke-width="6"
            ></el-progress>
          </div>
          <div class="file-actions">
            <el-button
              v-if="file.status === 'error' && file.retryCount < 3"
              size="mini"
              @click="retryUpload(index)"
            >
              重试 ({{ 3 - file.retryCount }})
            </el-button>
            <!-- <el-button
              v-if="file.status !== 'uploading' && file.status !== 'success'"
              size="mini"
              type="danger"
              @click="removeFile(index)"
            >
              移除
            </el-button> -->
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'DragUploader',
  props: {
    // 上传API函数,需要返回Promise
    uploadApi: {
      type: Function,
      required: true
    },
    // 初始并发数
    initialConcurrent: {
      type: Number,
      default: 3
    },
    // 文件大小限制(MB)
    maxSize: {
      type: Number,
      default: '5000'
    },
    // 请求超时时间(毫秒)
    timeout: {
      type: Number,
      default: 5000
    },
    // 是否自动开始上传
    autoUpload: {
      type: Boolean,
      default: true
    },
    // 已经成功上传添加的文件
    successFileList: {
      type: Array,
      default: () => []
    },
    // 初始化上传列表
    uploadShow: {
      type: Boolean,
      default: true
    },
    // 上传文件类型
    accept: {
      type: String,
      default: ''
    },
  },
  data() {
    return {
      dragover: false,
      files: [], // 文件列表
      uploading: false, // 是否正在上传
      activeUploads: 0, // 当前正在上传的文件数
      maxConcurrent: this.initialConcurrent, // 最大并发数
      cancelToken: null, // 取消上传的token
      uploadTimeouts: {}, // 存储每个上传的超时定时器
      uploadControllers: {}, // 存储每个上传的AbortController
      isUploading: false,  // 上传状态标记
      againFiles: [], //  重新上传的文件列表
      allFiles: [], // 所有文件列表
      debounceTimer: null, // 存储防抖的定时器
      clickFiles: [], // 存储点击上传的文件列表
      againFile: [], //每次重复上传的文件列表
      dragFiles: [], // 拖拽文件数
      dragAgainFile: [], // 拖拽重复数
      lastFileList: [],
      uniqueDuplicatesHistory: [], // 点击上传重复的文件
    };
  },
  watch: {
    // 监听上传文件列表的变化
    files(newFiles) {
      this.allFiles = newFiles;
    },
    uploadShow(newVal) {
      if(!newVal) {
        this.clickFiles = [];
        this.files = [];
        this.againFiles = [];
        this.allFiles = [];
        this.againFile = [];
        this.dragFiles = [];
        this.dragAgainFile = [];
        this.lastFileList = [];
        this.uniqueDuplicatesHistory = [];
      }
    },
  },
  computed: {
    // 是否有文件需要上传
    hasFilesToUpload() {
      return this.files.some(
        file => file.status === 'waiting' || (file.status === 'error' && file.retryCount < 3)
      );
    },
    reversedFiles() {
      return [...this.files].reverse();
    }
  },
  methods: {
    // 处理拖放文件
    handleDrop(e) {
      this.againFile = [];
      this.dragAgainFile = this.againFiles;
      this.dragover = false;
      const files = Array.from(e.dataTransfer.files);
      // 增加文件类型过滤逻辑
      // 判断 accept 是否为空,如果为空则不进行类型过滤
      const allowedTypes = this.accept ? this.accept.split(',').map(type => type.trim().replace(/^\./, '')) : [];
      
      // 如果有设置 allowedTypes,进行文件类型过滤,否则允许所有文件
      const filteredFiles = files.filter(file => {
        if (allowedTypes.length === 0 || allowedTypes.includes(file.type.split('/').pop())) {
          return true;
        } else {
          this.$message.warning(`文件 ${file.name} 类型不支持`);
          return false;
        }
      });
      if(filteredFiles.length > 0) {
        this.allFiles = filteredFiles;
        filteredFiles.forEach(file => {
          this.clickFiles.push(file); // 将每个 file 添加到 clickFiles 数组中
          this.dragFiles.push(file);
        });
        this.addFiles(filteredFiles);
      }
    },
    
    // 处理文件选择
    handleFileChange(file, fileList) {
      // 清除上一次的定时器
      clearTimeout(this.debounceTimer);

      // 设置新的定时器,确保只有在最后一次文件变化时触发
      this.debounceTimer = setTimeout(() => {
        // this.$nextTick(() => {
          const uploadFiles = fileList.map(item => item.raw);
          if (uploadFiles.length > 0 ) {
            // 获取新选择的文件,比较当前和上次的文件列表
            const newFiles = fileList.filter(f => !this.lastFileList.some(lastFile => lastFile.name === f.name));
            const duplicateFiles = fileList.filter(f => 
              this.lastFileList.some(uploadedFile => uploadedFile.name === f.name)
            );
            // 对重复的文件进行去重,保留第一次出现的文件
            const uniqueDuplicates = [...new Set(duplicateFiles.map(f => f.name))].map(name => 
              duplicateFiles.find(f => f.name === name)
            );
            // console.log(uniqueDuplicates,'重复的文件');
            
            // 更新上次的文件列表
            this.lastFileList = [...fileList];
            // console.log(this.lastFileList);
            this.uniqueDuplicatesHistory = uniqueDuplicates;
            this.againFiles = this.uniqueDuplicatesHistory;
            
            // this.isUploading = true;  // 设置上传状态为进行中
            setTimeout(() => {
              this.allFiles = this.removeDuplicateFiles(uploadFiles);
              this.clickFiles = [];
              this.clickFiles = uploadFiles.concat(this.dragFiles);
              
              this.againFile = [];
              // console.log(newFiles,this.uniqueDuplicatesHistory,1111);
              this.addFiles(newFiles);
            }, 100)
          }
        // });
      }, 300); // 防抖时间设置为 300ms
    },
    // 点击上传列表去重
    removeDuplicateFiles(uploadFiles) {
      return uploadFiles.reduce((uniqueFiles, file) => {
        // 如果文件的标识符没有在 uniqueFiles 中出现过,就添加它
        const fileIdentifier = `${file.lastModified}-${file.size}`;
        if (!uniqueFiles.some(f => `${f.lastModified}-${f.size}` === fileIdentifier)) {
          uniqueFiles.push(file);
        }
        return uniqueFiles;
      }, []);
    },
    // 添加文件到队列
    addFiles(files) {
      files.forEach(file => {
        // 检查文件大小
        if (this.maxSize && file.size > this.maxSize * 1024 * 1024) {
          this.$message.error(`文件 ${file.name} 超过 ${this.maxSize}MB 限制`);
          return;
        }
        // 检查是否已存在
        if (this.files.some(f => f.raw.lastModified === file.lastModified && f.raw.size === file.size && f.raw.uid === file.uid && f.raw.name === file.name)) {
          // this.$message.warning(`文件 ${file.name} 已存在`);
          // 重复的文件列表
          if(this.uniqueDuplicatesHistory > 0) {
            return
          } else {
            this.againFiles.push({
              name: file.name,
            });
            this.againFile.push({
              name: file.name,
            });
          }
          return;
        }

        this.files.push({
          uid: file.uid || Date.now() + Math.random().toString(36).substr(2),
          raw: file,
          name: file.name,
          size: file.size,
          status: 'waiting',
          percentage: 0,
          retryCount: 0
        });
      });
      this.$emit('upload-again', this.againFile, this.allFiles,);
      // 如果开启自动上传且有文件需要上传,且当前没有在上传,则开始上传
      if (this.hasFilesToUpload) {
        this.startUpload();
      }
    },
    
    // 自定义上传请求
    handleHttpRequest(options) {
      // 这里不做实际处理,统一在startUpload中处理
      return Promise.resolve();
    },
    
    // 开始上传
    startUpload() {
      // if (this.uploading) return;
      if (this.uploading) {
        // 如果已经在传,只更新队列不重新开始
        const newFiles = this.files.filter(
          file => file.status === 'waiting' || (file.status === 'error' && file.retryCount < 3)
        );
        if (newFiles.length > 0) {
          this.uploadQueue(newFiles);
        }
        return;
      }
      
      this.uploading = true;
      this.cancelToken = { cancel: false };
      
      // 过滤出需要上传的文件
      const filesToUpload = this.files.filter(
        file => file.status === 'waiting' || (file.status === 'error' && file.retryCount < 3)
      );
      
      if (filesToUpload.length === 0) {
        this.uploading = false;
        return;
      }
      
      // 开始上传队列
      this.uploadQueue(filesToUpload);
    },
    
    // 上传队列管理
    uploadQueue(files) {
      const queue = [...files];
      let active = 0;
      
      const processNext = () => {
        if (this.cancelToken.cancel) {
          // 取消上传
          this.files.forEach(file => {
            if (file.status === 'uploading') {
              file.status = 'canceled';
              file.percentage = 0;
            }
          });
          this.uploading = false;
          this.activeUploads = 0;
          return;
        }
        
        if (queue.length === 0 && active === 0) {
          // 所有文件上传完成
          this.uploading = false;
          this.$emit('upload-complete', this.getSuccessFiles(), this.clickFiles, this.getFailFiles(), this.againFiles);
          return;
        }
        
        while (active < this.maxConcurrent && queue.length > 0) {
          const file = queue.shift();
          active++;
          this.activeUploads = active;
          
          this.uploadFile(file).finally(() => {
            active--;
            this.activeUploads = active;
            processNext();
          });
        }
      };
      
      processNext();
    },
    
    // 上传单个文件
    uploadFile(file) {
      const fileIndex = this.files.findIndex(f => f.uid === file.uid);
      
      if (fileIndex === -1) {
        return Promise.resolve();
      }
      
      // 更新文件状态
      this.files[fileIndex].status = 'uploading';
      this.files[fileIndex].percentage = 0;
      
      const formData = new FormData();
      // 统一处理文件对象,无论来自拖拽还是点击上传
      const rawFile = this.files[fileIndex].raw.raw || this.files[fileIndex].raw;
      formData.append('file', rawFile);
      
      // 创建AbortController用于取消请求
      const controller = new AbortController();
      this.uploadControllers[file.uid] = controller;
      
      // 创建超时处理
      const timeoutPromise = new Promise((_, reject) => {
        this.uploadTimeouts[file.uid] = setTimeout(() => {
          controller.abort();
          reject(new Error('上传超时'));
        }, this.timeout);
      });
      
      // 创建上传Promise
      const uploadPromise = this.uploadApi(formData, {
        signal: controller.signal,
        onUploadProgress: (progressEvent) => {
          if (progressEvent.lengthComputable) {
            const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
            this.files[fileIndex].percentage = percent;
            
            // 每次有进度更新时,重置超时计时器
            if (this.uploadTimeouts[file.uid]) {
              clearTimeout(this.uploadTimeouts[file.uid]);
              this.uploadTimeouts[file.uid] = setTimeout(() => {
                controller.abort();
              }, this.timeout);
            }
          }
        }
      });
      
      // 使用Promise.race实现超时控制
      return Promise.race([
        uploadPromise,
        timeoutPromise
      ]).then(response => {
        if (this.successFileList.some(file => file.fileId === response.data.result)) {
          this.files[fileIndex].percentage = 99;
          this.files[fileIndex].status = 'again';
          this.againFiles.length += 1;
        } else {
          // 清除超时定时器和控制器
          this.cleanupUpload(file.uid);
          
          this.files[fileIndex].status = 'success';
          this.files[fileIndex].percentage = 100;
          this.$emit('upload-success', response, this.files[fileIndex]);
          return response;
        }
      }).catch(error => {
        // 清除超时定时器和控制器
        this.cleanupUpload(file.uid);
        
        this.files[fileIndex].retryCount++;
        
        if (this.files[fileIndex].retryCount >= 3) {
          this.files[fileIndex].status = 'error';
          this.$emit('upload-error', error, this.files[fileIndex]);
        } else {
          // 自动重试
          this.files[fileIndex].status = 'waiting';
          // this.$message.warning(`文件 ${file.name} 上传失败,正在尝试第 ${this.files[fileIndex].retryCount} 次重试...`);
          
          // 延迟1秒后重试
          return new Promise(resolve => {
            setTimeout(() => {
              if (this.uploading) {
                this.uploadFile(this.files[fileIndex]).then(resolve);
              } else {
                resolve();
              }
            }, 1000);
          });
        }
      }).finally(() => {
        this.isUploading = false;  // 上传结束后重置状态
      });
    },
    
    // 清理上传资源
    cleanupUpload(fileUid) {
      clearTimeout(this.uploadTimeouts[fileUid]);
      delete this.uploadTimeouts[fileUid];
      
      if (this.uploadControllers[fileUid]) {
        this.uploadControllers[fileUid].abort();
        delete this.uploadControllers[fileUid];
      }
    },
    
    // 取消上传
    cancelUpload() {
      if (this.cancelToken) {
        this.cancelToken.cancel = true;
      }
      
      // 清除所有超时定时器
      Object.keys(this.uploadTimeouts).forEach(uid => {
        clearTimeout(this.uploadTimeouts[uid]);
        delete this.uploadTimeouts[uid];
      });
      
      // 取消所有正在进行的请求
      Object.values(this.uploadControllers).forEach(controller => {
        controller.abort();
      });
      this.uploadControllers = {};
      
      this.uploading = false;
      this.$message.warning('上传已取消');
    },
    
    // 重试上传
    retryUpload(index) {
      this.files[index].status = 'waiting';
      if (!this.uploading) {
        this.startUpload();
      }
    },
    
    // 移除文件
    removeFile(index) {
      const file = this.files[index];
      // 如果文件正在上传,先取消上传
      if (file.status === 'uploading') {
        if (this.uploadControllers[file.uid]) {
          this.uploadControllers[file.uid].abort();
          delete this.uploadControllers[file.uid];
        }
        clearTimeout(this.uploadTimeouts[file.uid]);
        delete this.uploadTimeouts[file.uid];
      }
      this.files.splice(index, 1);
      this.clickFiles.splice(index, 1);
    },
    
    // 清空已完成文件
    clearCompleted() {
      this.files = this.files.filter(file => file.status !== 'success');
    },
    
    // 获取上传成功的文件
    getSuccessFiles() {
      return this.files.filter(file => file.status === 'success');
    },
    // 获取上传失败的文件
    getFailFiles() {
      return this.files.filter(file => file.status === 'error');
    },
    // 获取上传的文件状态
    getProgressStatus(fileStatus) {
      switch(fileStatus) {
        case 'success':
          return 'success';
        case 'error':
          return 'exception';
        case 'canceled':
          return 'warning';
        case 'again':
          return 'exception';
        default:
          return undefined;
      }
    },
    
    // 格式化文件大小
    formatFileSize(bytes) {
      if (bytes === 0) return '0 Bytes';
      const k = 1024;
      const sizes = ['Bytes', 'KB', 'MB', 'GB'];
      const i = Math.floor(Math.log(bytes) / Math.log(k));
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    },
  }
}
</script>

<style lang="less" scoped>
.drag-uploader {
  width: 100%;
  display: flex;
  height: 180px;
  justify-content: space-between;
}

.drag-area {
  border: 2px dashed #dcdfe6;
  border-radius: 6px;
  padding: 40px 20px;
  text-align: center;
  transition: border-color 0.3s;
  width: 50%;
}

.drag-area.drag-over {
  border-color: #409eff;
  background-color: rgba(64, 158, 255, 0.05);
}

.upload-content {
  cursor: pointer;
}

.el-icon-upload {
  font-size: 40px;
  color: #c0c4cc;
  margin-bottom: 10px;
}

.upload-text {
  font-size: 14px;
  color: #606266;
  margin-bottom: 5px;
}

.upload-text em {
  color: #409eff;
  font-style: normal;
}

.upload-hint {
  font-size: 12px;
  color: #909399;
}

.upload-controls {
  margin-bottom: 20px;
  display: flex;
  gap: 10px;
  align-items: center;
}

.file-list {
  border: 1px solid #ebeef5;
  border-radius: 4px;
  margin-left: 10px;
  width: 50%;
  .fileList-item {
    height: 130px;
    overflow: auto;
  }
}

.file-item {
  padding: 10px 15px 0 15px;
  border-bottom: 1px solid #ebeef5;
}

.file-item:last-child {
  border-bottom: none;
}

.file-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 5px;
}

.file-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.file-size {
  color: #909399;
  font-size: 12px;
  margin-left: 10px;
}

.file-status {
  font-size: 12px;
  margin-left: 10px;
}

.file-status .success {
  color: #67c23a;
}

.file-status .error {
  color: #f56c6c;
}

.file-progress {
  margin: 5px 0;
}

.file-actions {
  text-align: right;
}

.file-title {
  margin: 10px 15px 0 15px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
</style>