前端多文件上传核心功能实现:格式支持、批量上传与状态可视化

在文件上传类应用中,多格式兼容批量处理能力清晰的状态反馈是提升用户体验的关键。本文将聚焦这三大核心需求,基于 Vue + Element UI 实现一套可复用的文件上传解决方案,包含完整代码示例和关键逻辑解析。

一、核心功能概览

功能点 技术参数 核心价值
多格式支持 覆盖 DOC、DOCX、PDF、TXT、XLS、XLSX、CSV、PPTX、HTML、JSON、MD 共 11 种格式 满足办公、开发、文档管理等多场景需求
批量上传 单次最多 100 个文件,单个文件 ≤ 100MB 高效处理多文件上传场景,避免重复操作
状态可视化 实时展示上传进度、成功 / 失败 / 等待状态,支持暂停 / 重试 / 批量删除 让用户清晰掌握上传状态,降低操作成本

二、完整实现代码(可直接复用)

2.1 主组件(DocumentUpload.vue)

负责文件选择、上传逻辑、状态管理和交互控制:

js 复制代码
<template>
  <div class="document-upload">
    <!-- 上传区域 -->
    <div class="upload-container card">
      <el-upload
        ref="uploadRef"
        drag
        :action="uploadApi"
        :show-file-list="false"
        multiple
        :auto-upload="false"
        :accept="acceptFormats"
        :on-change="handleFileSelect"
      >
        <div class="upload-dragger">
          <i class="el-icon-upload"></i>
          <div class="upload-text">点击上传或拖拽文档到这里</div>
          <div class="upload-tip">
            支持 {{ formatNames }},最多一次上传100个文件,每个文件不超过100MB
          </div>
        </div>
      </el-upload>

      <!-- 文件状态列表 -->
      <FileUploadStatus
        :file-list="fileList"
        @batch-delete="handleBatchDelete"
        @pause-upload="handlePauseUpload"
        @retry-upload="handleRetryUpload"
      />
    </div>
  </div>
</template>

<script>
import FileUploadStatus from './FileUploadStatus.vue'
import axios from 'axios'

export default {
  name: 'DocumentUpload',
  components: { FileUploadStatus },
  data() {
    return {
      // 支持的文件格式(核心配置)
      acceptFormats: '.doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx,.html,.json,.md',
      formatNames: 'DOC、DOCX、PDF、TXT、XLS、XLSX、CSV、PPTX、HTML、JSON、MD',
      // 上传配置
      uploadApi: '/api/v1/upload/document', // 替换为实际接口地址
      fileList: [], // 所有文件列表(含状态)
      uploadQueue: [], // 上传队列
      concurrentLimit: 5, // 并发上传限制(避免服务器压力)
      currentConcurrent: 0, // 当前正在上传的文件数
      isProcessing: false // 防止重复处理选择事件
    }
  },
  methods: {
    // 1. 文件选择处理(格式+数量+大小校验)
    handleFileSelect(file, fileList) {
      // 仅处理刚选择的文件(status=ready)
      if (file.status !== 'ready' || this.isProcessing) return
      this.isProcessing = true

      setTimeout(() => {
        try {
          this.$refs.uploadRef.clearFiles() // 清除组件内置列表,统一用自定义列表管理

          // 校验1:单次选择不超过100个
          if (fileList.length > 100) {
            this.$message.error('最多一次上传100个文件')
            return
          }

          // 校验2:总文件数不超过100个
          const newFiles = fileList.filter(f => 
            !this.fileList.some(item => item.uid === f.uid) // 去重
          )
          const totalCount = this.fileList.length + newFiles.length
          if (totalCount > 100) {
            this.$message.error(`当前已选择${this.fileList.length}个,本次选择${newFiles.length}个,总数超过100个限制`)
            return
          }

          // 校验3:单个文件不超过100MB
          const invalidFiles = newFiles.filter(f => f.size > 100 * 1024 * 1024)
          if (invalidFiles.length) {
            const fileNames = invalidFiles.map(f => f.name).join('、')
            this.$message.error(`文件「${fileNames}」超过100MB限制,无法上传`)
            return
          }

          // 校验通过:添加到文件列表和上传队列
          newFiles.forEach(newFile => {
            this.fileList.unshift({
              ...newFile,
              status: 'ready', // 初始状态:等待上传
              percentage: 0,
              reason: '' // 失败原因
            })
            this.uploadQueue.push(newFile)
          })

          // 开始处理上传队列
          this.processUploadQueue()
        } finally {
          this.isProcessing = false
        }
      }, 0)
    },

    // 2. 并发上传队列处理
    processUploadQueue() {
      const availableSlots = this.concurrentLimit - this.currentConcurrent
      if (availableSlots <= 0 || this.uploadQueue.length === 0) return

      // 取出可上传的文件
      const filesToUpload = this.uploadQueue.splice(0, availableSlots)
      this.currentConcurrent += filesToUpload.length

      // 逐个上传
      filesToUpload.forEach(file => {
        this.uploadFile(file)
          .then(() => {
            this.currentConcurrent--
            this.processUploadQueue() // 递归处理下一批
          })
          .catch(() => {
            this.currentConcurrent--
            this.processUploadQueue()
          })
      })
    },

    // 3. 单个文件上传核心逻辑
    uploadFile(file) {
      return new Promise((resolve, reject) => {
        const fileIndex = this.fileList.findIndex(item => item.uid === file.uid)
        if (fileIndex === -1) return reject(new Error('文件不存在'))

        // 取消上传令牌(用于暂停功能)
        const CancelToken = axios.CancelToken
        const source = CancelToken.source()

        // 更新文件状态
        const updateFileStatus = (updates) => {
          this.fileList[fileIndex] = { ...this.fileList[fileIndex], ...updates }
        }

        // 初始化上传状态
        updateFileStatus({
          status: 'uploading',
          source // 保存取消令牌,用于暂停
        })

        // 构建表单数据
        const formData = new FormData()
        formData.append('file', file.raw)
        formData.append('fileName', file.name)
        formData.append('fileSize', file.size)

        // 发送上传请求
        axios({
          url: this.uploadApi,
          method: 'POST',
          headers: {
            'Content-Type': 'multipart/form-data',
            'token': localStorage.getItem('token') // 替换为实际权限令牌
          },
          data: formData,
          timeout: 300000, // 5分钟超时(适配大文件)
          cancelToken: source.token,
          // 实时更新上传进度
          onUploadProgress: (progressEvent) => {
            if (progressEvent.lengthComputable) {
              const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100)
              updateFileStatus({ percentage })
            }
          }
        })
          .then(res => {
            if (res.code === 200) {
              updateFileStatus({
                status: 'finished',
                percentage: 100,
                source: null
              })
              this.$message.success(`文件「${file.name}」上传成功`)
              resolve()
            } else {
              const errMsg = res.msg || '上传失败'
              updateFileStatus({
                status: 'failed',
                reason: errMsg,
                source: null
              })
              this.$message.error(`文件「${file.name}」上传失败:${errMsg}`)
              reject(new Error(errMsg))
            }
          })
          .catch(err => {
            if (axios.isCancel(err)) {
              // 手动取消(暂停)
              updateFileStatus({
                status: 'ready',
                reason: '已暂停上传',
                source: null
              })
            } else {
              // 异常失败
              const errMsg = err.message || '网络异常'
              updateFileStatus({
                status: 'failed',
                reason: errMsg,
                source: null
              })
              this.$message.error(`文件「${file.name}」上传异常:${errMsg}`)
            }
            reject(err)
          })
      })
    },

    // 4. 暂停上传
    handlePauseUpload({ file }) {
      const targetFile = this.fileList.find(item => item.uid === file.uid)
      if (targetFile && targetFile.source) {
        targetFile.source.cancel('用户暂停上传')
      }
    },

    // 5. 重试上传
    handleRetryUpload({ file }) {
      const fileIndex = this.fileList.findIndex(item => item.uid === file.uid)
      if (fileIndex !== -1) {
        // 重置状态并加入队列
        this.fileList[fileIndex] = {
          ...this.fileList[fileIndex],
          status: 'ready',
          percentage: 0,
          reason: ''
        }
        this.uploadQueue.push(file)
        this.processUploadQueue()
      }
    },

    // 6. 批量删除文件
    handleBatchDelete(selectedUids) {
      // 移除文件列表中的文件
      this.fileList = this.fileList.filter(item => !selectedUids.includes(item.uid))
      // 移除上传队列中的文件
      this.uploadQueue = this.uploadQueue.filter(file => !selectedUids.includes(file.uid))
      // 更新并发数(如果删除的是正在上传的文件)
      const uploadingCount = selectedUids.filter(uid => 
        this.fileList.some(item => item.uid === uid && item.status === 'uploading')
      ).length
      this.currentConcurrent = Math.max(0, this.currentConcurrent - uploadingCount)
      // 继续处理队列
      this.processUploadQueue()
      this.$message.success(`成功删除${selectedUids.length}个文件`)
    }
  }
}
</script>

<style scoped lang="scss">
.document-upload {
  .upload-container {
    width: 100%;
    padding: 20px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0,0,0,0.08);

    .el-upload-dragger {
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 40px 0;
      border: 2px dashed #dcdcdc;
      border-radius: 8px;
      transition: border-color 0.3s;

      &:hover {
        border-color: #409eff;
      }

      .el-icon-upload {
        font-size: 48px;
        color: #409eff;
        margin-bottom: 16px;
      }

      .upload-text {
        font-size: 16px;
        color: #333;
        margin-bottom: 8px;
      }

      .upload-tip {
        font-size: 12px;
        color: #999;
        text-align: center;
        max-width: 80%;
      }
    }
  }
}
</style>

2.2 状态展示组件(FileUploadStatus.vue)

负责文件状态可视化、筛选和操作按钮渲染:

js 复制代码
<template>
  <div class="file-upload-status">
    <!-- 顶部筛选栏 -->
    <div class="status-header">
      <div class="file-count">已选择 {{ fileList.length }} 个文件</div>
      <div class="filter-controls">
        <el-checkbox v-model="isAllSelected" @change="handleAllSelect">全选</el-checkbox>
        <el-button 
          type="text" 
          icon="el-icon-delete" 
          class="delete-btn"
          :disabled="selectedUids.length === 0"
          @click="handleBatchDeleteConfirm"
        >
          批量删除
        </el-button>
        <!-- 状态筛选 -->
        <el-select v-model="statusFilter" placeholder="全部状态" size="mini" @change="filterFiles">
          <el-option label="全部" value=""></el-option>
          <el-option label="等待上传" value="ready"></el-option>
          <el-option label="上传中" value="uploading"></el-option>
          <el-option label="上传成功" value="finished"></el-option>
          <el-option label="上传失败" value="failed"></el-option>
        </el-select>
      </div>
    </div>

    <!-- 文件列表 -->
    <div class="file-list">
      <el-empty 
        v-if="filteredFileList.length === 0" 
        description="暂无文件" 
        :image-size="60"
      />
      <div 
        v-for="file in filteredFileList" 
        :key="file.uid"
        class="file-item"
        :class="`status-${file.status}`"
      >
        <!-- 复选框 -->
        <el-checkbox 
          v-model="selectedUids" 
          :label="file.uid"
          :disabled="file.status === 'uploading'"
        ></el-checkbox>
        <!-- 文件图标 -->
        <div class="file-icon">
          <img :src="getFileIcon(file.name)" alt="文件图标" />
        </div>
        <!-- 文件信息 -->
        <div class="file-info">
          <div class="file-name">{{ file.name }}</div>
          <div class="file-meta">
            <span class="file-size">{{ formatFileSize(file.size) }}</span>
            <span class="file-status" :title="file.reason || getStatusText(file.status)">
              {{ file.reason || getStatusText(file.status) }}
            </span>
          </div>
        </div>
        <!-- 进度条/操作按钮 -->
        <div class="file-actions">
          <!-- 上传中:进度条 + 暂停按钮 -->
          <div v-if="file.status === 'uploading'" class="uploading-controls">
            <el-progress :percentage="file.percentage" size="small" :text-inside="true"></el-progress>
            <el-button icon="el-icon-pause" size="mini" @click="emitPauseUpload(file)"></el-button>
          </div>
          <!-- 等待上传:开始按钮 -->
          <el-button 
            v-else-if="file.status === 'ready'" 
            icon="el-icon-play" 
            size="mini" 
            @click="emitRetryUpload(file)"
          ></el-button>
          <!-- 上传失败:重试按钮 -->
          <el-button 
            v-else-if="file.status === 'failed'" 
            icon="el-icon-refresh" 
            size="mini" 
            type="text"
            @click="emitRetryUpload(file)"
          ></el-button>
          <!-- 上传成功:无操作 -->
          <div v-else></div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 工具函数:格式化文件大小
const formatFileSize = (size) => {
  if (size < 1024) return size + 'B'
  if (size < 1024 * 1024) return (size / 1024).toFixed(1) + 'KB'
  return (size / (1024 * 1024)).toFixed(1) + 'MB'
}

export default {
  name: 'FileUploadStatus',
  props: {
    fileList: {
      type: Array,
      required: true,
      default: () => []
    }
  },
  data() {
    return {
      selectedUids: [], // 选中的文件UID
      isAllSelected: false, // 全选状态
      statusFilter: '', // 状态筛选
      filteredFileList: [] // 筛选后的文件列表
    }
  },
  watch: {
    fileList: {
      handler() {
        this.filterFiles()
        this.updateAllSelectedStatus()
      },
      deep: true
    },
    statusFilter() {
      this.filterFiles()
    },
    selectedUids() {
      this.updateAllSelectedStatus()
    }
  },
  mounted() {
    this.filterFiles()
  },
  methods: {
    formatFileSize,
    // 筛选文件
    filterFiles() {
      if (!this.statusFilter) {
        this.filteredFileList = [...this.fileList]
        return
      }
      this.filteredFileList = this.fileList.filter(file => file.status === this.statusFilter)
    },
    // 更新全选状态
    updateAllSelectedStatus() {
      const selectableFiles = this.filteredFileList.filter(file => file.status !== 'uploading')
      this.isAllSelected = selectableFiles.length > 0 && 
        this.selectedUids.length === selectableFiles.length
    },
    // 全选/取消全选
    handleAllSelect(checked) {
      if (checked) {
        this.selectedUids = this.filteredFileList
          .filter(file => file.status !== 'uploading')
          .map(file => file.uid)
      } else {
        this.selectedUids = []
      }
    },
    // 批量删除确认
    handleBatchDeleteConfirm() {
      this.$confirm('确定删除选中的文件吗?', '提示', {
        type: 'warning',
        confirmButtonText: '确定',
        cancelButtonText: '取消'
      }).then(() => {
        this.$emit('batch-delete', this.selectedUids)
        this.selectedUids = []
      }).catch(() => {})
    },
    // 获取文件图标(根据后缀名)
    getFileIcon(fileName) {
      const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
      const iconMap = {
        '.doc': require('@/assets/icons/file-doc.png'),
        '.docx': require('@/assets/icons/file-docx.png'),
        '.pdf': require('@/assets/icons/file-pdf.png'),
        '.txt': require('@/assets/icons/file-txt.png'),
        '.xls': require('@/assets/icons/file-xls.png'),
        '.xlsx': require('@/assets/icons/file-xlsx.png'),
        '.csv': require('@/assets/icons/file-csv.png'),
        '.pptx': require('@/assets/icons/file-pptx.png'),
        '.html': require('@/assets/icons/file-html.png'),
        '.json': require('@/assets/icons/file-json.png'),
        '.md': require('@/assets/icons/file-md.png')
      }
      return iconMap[ext] || require('@/assets/icons/file-default.png')
    },
    // 获取状态文本
    getStatusText(status) {
      const statusMap = {
        ready: '等待上传',
        uploading: '上传中',
        finished: '上传成功',
        failed: '上传失败'
      }
      return statusMap[status] || '未知状态'
    },
    // 触发暂停上传
    emitPauseUpload(file) {
      this.$emit('pause-upload', { file })
    },
    // 触发重试上传
    emitRetryUpload(file) {
      this.$emit('retry-upload', { file })
    }
  }
}
</script>

<style scoped lang="scss">
.file-upload-status {
  margin-top: 20px;

  .status-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;

    .file-count {
      font-size: 14px;
      color: #333;
    }

    .filter-controls {
      display: flex;
      align-items: center;
      gap: 12px;

      .delete-btn {
        color: #f56c6c;
      }
    }
  }

  .file-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    max-height: 500px;
    overflow-y: auto;
    padding-right: 8px;
  }

  .file-item {
    display: flex;
    align-items: center;
    padding: 12px;
    background: #fafafa;
    border-radius: 4px;
    gap: 12px;

    &.status-uploading {
      background: #f0f7ff;
    }

    &.status-finished {
      background: #f0fff4;
    }

    &.status-failed {
      background: #fff0f0;
    }
  }

  .file-icon {
    width: 32px;
    height: 32px;

    img {
      width: 100%;
      height: 100%;
      object-fit: contain;
    }
  }

  .file-info {
    flex: 1;
    min-width: 0;

    .file-name {
      font-size: 14px;
      color: #333;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      margin-bottom: 4px;
    }

    .file-meta {
      display: flex;
      gap: 16px;
      font-size: 12px;
      color: #666;

      .file-status {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
    }
  }

  .file-actions {
    display: flex;
    align-items: center;
    gap: 12px;

    .uploading-controls {
      display: flex;
      align-items: center;
      gap: 12px;
      width: 200px;
    }
  }
}
</style>

三、核心逻辑解析

3.1 多格式支持实现

通过 accept 属性限制文件选择格式,同时在代码中二次校验(避免绕过前端限制的情况):

javascript 复制代码
// 格式配置(集中管理,便于维护)
acceptFormats: '.doc,.docx,.pdf,.txt,.xls,.xlsx,.csv,.pptx,.html,.json,.md',
// 选择文件时自动过滤不符合格式的文件,同时代码中无需额外处理格式校验(Element UI已处理)

3.2 批量上传与并发控制

  1. 批量选择限制 :通过 fileList.length 校验单次选择和总文件数,避免超过 100 个限制;
  2. 并发控制 :通过 concurrentLimit 限制同时上传的文件数(默认 5 个),避免服务器压力过大;
  3. 上传队列 :未上传的文件存入 uploadQueue,上传完成后递归处理下一批,确保有序上传。

3.3 上传状态可视化

  1. 状态机设计 :每个文件包含 ready(等待上传)、uploading(上传中)、finished(成功)、failed(失败)四种状态,清晰区分文件所处阶段;
  2. 进度反馈 :通过 onUploadProgress 回调实时计算上传进度,配合进度条展示;
  3. 操作按钮适配:不同状态显示不同操作(上传中显示暂停、失败显示重试、等待显示开始);
  4. 筛选功能:支持按状态筛选文件,快速定位目标文件。

四、用户体验优化

  1. 拖拽上传支持拖拽文件到上传区域直接上传,配合视觉反馈(图标、提示文字)提升操作便捷性。
  2. 进度可视化上传中文件显示渐变背景进度条,直观展示上传进度:
css 复制代码
.file-item[status="uploading"] {
  background: linear-gradient(90deg,
    rgba(64, 158, 255, 0.2) #{file.percentage}%,
    rgba(64, 158, 255, 0.1) #{file.percentage}%);
}
  1. 操作便捷性
  • 批量删除:支持全选删除和按状态筛选后删除
  • 状态快速切换:上传失败文件可直接重试,无需重新选择
相关推荐
胖虎2654 小时前
Vue2 项目常用配置合集:多语言、SVG 图标、代码格式化、权限指令 + 主题切换
前端
一键定乾坤4 小时前
npm 源修改
前端
parade岁月4 小时前
Vue 3 响应式陷阱:对象引用丢失导致的数据更新失效
前端
掘金安东尼4 小时前
GPT-6 会带来科学革命?奥特曼最新设想:AI CEO、便宜医疗与全新计算机
前端·vue.js·github
申阳4 小时前
Day 5:03. 基于Nuxt开发博客项目-页面结构组织
前端·后端·程序员
全马必破三4 小时前
React的设计理念与核心特性
前端·react.js·前端框架
ttod_qzstudio4 小时前
替代 TDesign Dialog:用 div 实现可拖拽、遮罩屏蔽的对话框
前端·tdesign
洞窝技术4 小时前
前端人必看的 node_modules 瘦身秘籍:从臃肿到轻盈,Umi 项目依赖优化实战
前端·vue.js·react.js
Asort4 小时前
React函数组件深度解析:从基础到最佳实践
前端·javascript·react.js