前后端分块上传功能实现
-
前端:vue3、vite5、element-plus
-
后端:nestjs@10
-
前端部分代码展示
<template>
<el-upload
ref="uploadRef"
class="upload-demo"
action=""
:http-request="uploadRequest"
:auto-upload="true"
:before-upload="beforeUpload"
:file-list="fileList"
drag
multiple
:disabled="uploading"
>
<div slot="trigger" class="el-upload-trigger">
<!-- 上传中的时候禁止点击上传区域 -->
<span v-if="!uploading">点击或拖拽文件到这里</span>
<span v-else>文件上传中...</span>
</div>
<div slot="tip" class="el-upload__tip">
请拖拽文件到这里或点击框选择文件
</div>
<el-progress :percentage="progress"></el-progress
</el-upload>
<!-- 文件信息展示区域 -->
<div v-if="selectedFile" class="file-info">
<p><strong>文件名:</strong> {{ selectedFile.name || '--' }}</p>
<p>
<strong>文件大小:</strong>
{{ (selectedFile.size / 1024 / 1024).toFixed(2) || '--'}} MB
</p>
<p><strong>文件类型:</strong> {{ selectedFile.type || '--'}}</p>
</div>
</template>
<script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";
import axios from "axios";
// 引用 el-upload 组件
const uploadRef = ref(null);
const fileList = ref([]);
const progress = ref(0);
const selectedFile = ref(null); // 新增,用于保存当前选中文件的信息
let hasNewFile = ref(false);
const uploading = ref(false); // 上传中标记
// 上传分块大小,单位为字节,您可以自由设置
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
// 文件上传前钩子,处理新文件的逻辑
const beforeUpload = (file) => {
if (uploading.value) {
ElMessage.warning("文件正在上传,请等待完成后再操作");
return false; // 禁止文件再次上传
}
// 如果有新文件上传,重置进度条
if (!hasNewFile.value) {
hasNewFile.value = true;
progress.value = 0;
}
fileList.value = [file]; // 将文件推入列表并覆盖旧文件
selectedFile.value = file; // 保存当前选中的文件信息
console.log(selectedFile.value)
uploadRequest({ file }); // 自动上传
return false; // 阻止默认上传逻辑
};
// 实现分块上传逻辑
const uploadRequest = async (options) => {
const { file } = options;
uploading.value = true; // 开始上传,禁用操作
const chunkSize = CHUNK_SIZE;
const chunks = Math.ceil(file.size / chunkSize); // 计算分块数量
const chunkList = [];
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size); // 计算每块的起止
const chunk = file.slice(start, end);
chunkList.push({
chunk,
index: i,
});
}
// 上传每个分块
try {
for (let i = 0; i < chunkList.length; i++) {
const formData = new FormData();
formData.append("file", chunkList[i].chunk);
formData.append("index", chunkList[i].index);
formData.append("total", chunkList.length);
formData.append("filename", file.name);
await axios.post("http://localhost:3000/upload", formData, {
onUploadProgress: (progressEvent) => {
// 动态更新进度
progress.value = Math.round(
((i + progressEvent.loaded / progressEvent.total) /
chunkList.length) *
100
);
},
});
}
ElMessage.success("文件上传成功");
} catch (error) {
ElMessage.error("文件上传失败,请重试");
progress.value = 0; // 重置进度条
uploading.value = false; // 上传失败,允许再次操作
return;
}
uploading.value = false; // 上传成功,允许再次操作
clearFileList(); // 上传成功后清除文件列表
};
// 清除文件列表缓存,但保持进度条为100%
const clearFileList = () => {
fileList.value = []; // 清空文件列表
hasNewFile.value = false; // 标记不再是新文件
progress.value = 0; // 重置进度条
};
</script>
<style scoped>
.upload-demo {
width: 300px;
margin: 0 auto;
}
.el-upload-trigger {
width: 100%;
height: 100px;
border: 1px dashed #d9d9d9;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.file-info {
width: 300px;
margin: 0 auto;
margin-top: 20px;
font-size: 14px;
color: #333;
}
.file-info p {
margin: 0;
}
</style>
- 后端部分代码展示
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
Body,
BadRequestException,
HttpCode,
HttpStatus,
InternalServerErrorException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as fs from 'fs-extra';
import * as path from 'path';
@Controller('upload')
export class UploadController {
// 定义上传路径和最终保存文件的路径
private readonly uploadPath = path.join(__dirname, '../../', 'uploads'); // 上传文件保存的路径
private readonly uploadFilesPath = path.join(
__dirname,
'../../',
'uploadFiles',
); // 最终保存文件的路径
constructor() {
// 确保上传目录和最终保存目录存在
try {
fs.ensureDirSync(this.uploadPath);
fs.ensureDirSync(this.uploadFilesPath);
} catch (error) {
throw new InternalServerErrorException(
'Failed to create necessary directories',
);
}
}
// 单文件上传处理
@Post()
@HttpCode(HttpStatus.OK)
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (req, file, cb) => {
try {
const dest = path.join(__dirname, '../../', 'uploads');
// 检查目录是否存在,不存在则创建
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
cb(null, dest);
} catch (error) {
cb(
new InternalServerErrorException(
'Failed to create upload directory',
),
null,
);
}
},
filename: (req, file, cb) => {
// 使用原始文件名保存文件
cb(null, file.originalname);
},
}),
}),
)
async uploadFile(@UploadedFile() file: Express.Multer.File, @Body() body) {
try {
if (!file) {
// 如果没有上传文件,抛出异常
throw new BadRequestException('No file uploaded');
}
// 获取请求体中的索引和总块数信息
const index = body.index; // 当前块索引
const total = body.total; // 总块数
const filename = body.filename; // 原始文件名
// 检查请求体参数是否存在
if (!index || !total || !filename) {
throw new BadRequestException('Missing required parameters');
}
// 分块文件的存储目录
const chunkDir = path.join(this.uploadPath, filename + '_chunks');
fs.ensureDirSync(chunkDir); // 确保分块目录存在
const chunkPath = path.join(chunkDir, `${index}`);
// 检查分块是否已经存在,避免重复保存
if (!fs.existsSync(chunkPath)) {
// 将文件从临时路径移动到目标路径
await fs.move(file.path, chunkPath);
}
// 判断是否是最后一个块,如果是则进行合并
if (parseInt(index) + 1 === parseInt(total)) {
await this.mergeChunks(filename, total);
}
return { message: 'File uploaded successfully' }; // 返回成功信息
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new InternalServerErrorException('File upload failed');
}
}
// 合并分块文件的方法
private async mergeChunks(filename: string, total: number) {
const chunkDir = path.join(this.uploadPath, filename + '_chunks'); // 分块目录
const finalPath = path.join(this.uploadFilesPath, filename); // 合并后文件保存路径
try {
const writeStream = fs.createWriteStream(finalPath); // 创建写入流
// 顺序读取每个块并写入最终文件
for (let i = 0; i < total; i++) {
const chunkPath = path.join(chunkDir, `${i}`);
if (!fs.existsSync(chunkPath)) {
throw new InternalServerErrorException(`Chunk ${i} is missing`);
}
const data = await fs.readFile(chunkPath);
writeStream.write(data); // 写入块数据
}
// 合并完成后关闭写入流
writeStream.end();
// 删除分块文件夹
await fs.remove(chunkDir);
} catch (error) {
throw new InternalServerErrorException('Failed to merge chunks');
}
}
}