在文件上传类应用中,多格式兼容 、批量处理能力 和清晰的状态反馈是提升用户体验的关键。本文将聚焦这三大核心需求,基于 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 批量上传与并发控制
- 批量选择限制 :通过
fileList.length校验单次选择和总文件数,避免超过 100 个限制; - 并发控制 :通过
concurrentLimit限制同时上传的文件数(默认 5 个),避免服务器压力过大; - 上传队列 :未上传的文件存入
uploadQueue,上传完成后递归处理下一批,确保有序上传。
3.3 上传状态可视化
- 状态机设计 :每个文件包含
ready(等待上传)、uploading(上传中)、finished(成功)、failed(失败)四种状态,清晰区分文件所处阶段; - 进度反馈 :通过
onUploadProgress回调实时计算上传进度,配合进度条展示; - 操作按钮适配:不同状态显示不同操作(上传中显示暂停、失败显示重试、等待显示开始);
- 筛选功能:支持按状态筛选文件,快速定位目标文件。
四、用户体验优化
- 拖拽上传支持拖拽文件到上传区域直接上传,配合视觉反馈(图标、提示文字)提升操作便捷性。
- 进度可视化上传中文件显示渐变背景进度条,直观展示上传进度:
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}%);
}
- 操作便捷性
- 批量删除:支持全选删除和按状态筛选后删除
- 状态快速切换:上传失败文件可直接重试,无需重新选择