本文介绍了一个基于Vue.js和Element UI的拖拽上传组件实现。该组件支持文件拖拽上传和点击上传两种方式,具有以下核心功能:
- 支持多文件上传,可设置文件大小限制(默认5000MB)和文件类型过滤
- 提供上传进度显示、失败重试(最多3次)和取消上传功能
- 自动检测重复文件并提示
- 可配置并发上传数(默认3个)和超时时间(默认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>