前后端实现文件分块上传

前后端分块上传功能实现

  • 博客地址

  • 前端: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');
    }
  }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值