大文件上传前端vue3+后端FastApi

vue3中大文件上传我使用的是vue-simple-uploader,文件加密使用MD5这个三方插件,比较方便,这个插件很不错,有以下功能亮点:

1、支持文件、多文件、文件夹上传
2、支持拖拽文件、文件夹上传
3、统一对待文件和文件夹,方便操作管理
4、可暂停、继续上传
5、错误处理
6、上传队列管理,支持最大并发上传
7、分块上传
8、支持进度、预估剩余时间、出错自动重试、重传等操作

在vue3中使用用需要注意安装的版本,否则使用会报错

npm i --save vue-simple-uploader@next spark-md5

 安装后版本是:

 安装好后在main.js中引入

// 文件分片上传
import uploader from 'vue-simple-uploader';
import 'vue-simple-uploader/dist/style.css';

const app = createApp(App)

app.use(uploader)

 

<template>
  <div>
    <uploader
      ref="uploaderRef"
      :options="options"
      :autoStart="false"
      :file-status-text="fileStatusText"
      class="uploader-ui"
      @file-added="onFileAdded"
      @file-success="onFileSuccess"
      @file-progress="onFileProgress"
      @file-error="onFileError"
    >
      <uploader-unsupport></uploader-unsupport>
      <uploader-drop>
        <p>拖拽文件到此处或</p>
        <uploader-btn id="global-uploader-btn" ref="uploadBtn" :attrs="attrs"
          >选择文件<i class="el-icon-upload el-icon--right"></i
        ></uploader-btn>
      </uploader-drop>
      <uploader-list></uploader-list>
    </uploader>
  </div>
</template>
   
  <script setup>
import { reactive, ref, onMounted } from "vue";
import SparkMD5 from "spark-md5";
import { mergeFile } from "@/apis/upload";

const options = reactive({
  //目标上传 URL,默认POST, import.meta.env.VITE_API_URL = api
  // target ==》http://localhost:8000/api/uploader/chunk
  target: "http://localhost:8000/chunk",
  query: {},
  headers: {
    // 需要携带token信息,当然看各项目情况具体定义
    Authorization: "Bearer " + localStorage.getItem("access_token"),
  },
  //分块大小(单位:字节)
  chunkSize: 1024 * 1024 * 5,
  //上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
  fileParameterName: "file",
  //失败后最多自动重试上传次数
  maxChunkRetries: 3,
  //是否开启服务器分片校验,对应GET类型同名的target URL
  testChunks: true,
  // 剩余时间
  parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {
    return parsedTimeRemaining
      .replace(/\syears?/, "年")
      .replace(/\days?/, "天")
      .replace(/\shours?/, "小时")
      .replace(/\sminutes?/, "分钟")
      .replace(/\sseconds?/, "秒");
  },
  // 服务器分片校验函数
  checkChunkUploadedByResponse: function (chunk, response_msg) {
    let objMessage = JSON.parse(response_msg);
    console.log(response_msg, "response_msg");
    if (objMessage.data.isExist) {
      return true;
    }
    return (objMessage.data.uploaded || []).indexOf(chunk.offset + 1) >= 0;
  },
});
const attrs =  reactive({
        // 设置上传文件类型
	   accept: ['.rar','.zip']
	 })
const fileStatusText = reactive({
  success: "上传成功",
  error: "上传失败",
  uploading: "上传中",
  paused: "暂停",
  waiting: "等待上传",
});
onMounted(() => {
  console.log(uploaderRef.value, "uploaderRef.value");
});
function onFileAdded(file) {
  computeMD5(file);
}

async function onFileSuccess(rootFile, file, response, chunk) {
  //refProjectId为预留字段,可关联附件所属目标,例如所属档案,所属工程等
  //   判断是否秒传成功,成功取消合并
  console.log("秒传成功", response);
  let objMessage = JSON.parse(response);
  if (objMessage.data.isExist != null && objMessage.data.isExist == true) {
    console.log("秒传成功", response);
    window.$message.success("秒传成功");
    return;
  }
  const res = await mergeFile({
    filename: file.name, //文件名称
    identifier: file.uniqueIdentifier, //文件唯一标识
    totalChunks: chunk.offset + 1, //文件总分片数
  });
  if (res.code === 200) {
    console.log("上传成功", res);
    window.$message.success("上传成功");
  } else {
    console.log("上传失败", res);
    window.$message.error("上传失败");
  }
}
function onFileError(rootFile, file, response, chunk) {
  console.log("上传完成后异常信息:" + response);
}

/**
 * 计算md5,实现断点续传及秒传
 * @param file
 */
function computeMD5(file) {
  file.pause();

  //单个文件的大小限制1G
  let fileSizeLimit = 1 * 1024 * 1024 * 1024;
  console.log("文件大小:" + file.size);
  console.log("限制大小:" + fileSizeLimit);
  if (file.size > fileSizeLimit) {
    file.cancel();
    window.$message.error("文件大小不能超过1G");
  }

  let fileReader = new FileReader();
  let time = new Date().getTime();
  let blobSlice =
    File.prototype.slice ||
    File.prototype.mozSlice ||
    File.prototype.webkitSlice;
  let currentChunk = 0;
  const chunkSize = 10 * 1024 * 1000;
  let chunks = Math.ceil(file.size / chunkSize);
  let spark = new SparkMD5.ArrayBuffer();
  //如果文件过大计算非常慢,因此采用只计算第1块文件的md5的方式
  let chunkNumberMD5 = 1;

  loadNext();

  fileReader.onload = (e) => {
    spark.append(e.target.result);

    if (currentChunk < chunkNumberMD5) {
      loadNext();
    } else {
      let md5 = spark.end();
      file.uniqueIdentifier = md5;
      // 计算完毕开始上传
      file.resume();
      console.log(
        `MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${
          file.size
        } 用时:${new Date().getTime() - time} ms`
      );
    }
  };

  fileReader.onerror = function () {
    file.cancel();
  };

  function loadNext() {
    let start = currentChunk * chunkSize;
    let end = start + chunkSize >= file.size ? file.size : start + chunkSize;

    fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
    currentChunk++;
    console.log("计算第" + currentChunk + "块");
  }
}
const uploaderRef = ref();
function close() {
  uploaderRef.value.cancel();
}
function error(msg) {
  console.log(msg, "msg");
}
</script>
   
  <style scoped>
.uploader-ui {
  padding: 15px;
  margin: 40px auto 0;
  font-size: 12px;
  font-family: Microsoft YaHei;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
}
.uploader-ui .uploader-btn {
  margin-right: 4px;
  font-size: 12px;
  border-radius: 3px;
  color: #fff;
  background-color: #58bfc1;
  border-color: #58bfc1;
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
}
.uploader-ui .uploader-list {
  max-height: 440px;
  overflow: auto;
  overflow-x: hidden;
  overflow-y: auto;
}
</style>

后端代码:

使用需要安装FastAPI和aiofiles

安装命令

pip install fastapi
pip install aiofiles

 

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time    : 2023/2/10 16:44
# @Author  : YueJian
# @File    : test2.py
# @Description :

import os
import os.path
import re
import shutil
import stat
from email.utils import formatdate
from mimetypes import guess_type
from pathlib import Path
from urllib.parse import quote

import aiofiles
import uvicorn
from fastapi import Body, File, Path as F_Path, UploadFile, Request
from fastapi import Form, FastAPI
from starlette.responses import StreamingResponse

app = FastAPI()
# 上传位置
UPLOAD_FILE_PATH = "./static/file"


@app.get("/file-slice")
async def check_file(identifier: str, totalChunks: int):
    """
    判断上传文件是否存在
    :param identifier:
    :param totalChunks:
    :return:
    """
    path = os.path.join(UPLOAD_FILE_PATH, identifier)
    if not os.path.exists(path):
        # 不存在
        return {"code": 200, "data": {"isExist": False}}
    else:
        # 存在时判断分片数是否
        chunks = [int(str(i).split("_")[1]) for i in os.listdir(path)]
        return {"code": 200, "data": {"isExist": False, "uploaded": chunks}}


@app.post("/file-slice")
async def upload_file(
        request: Request,
        identifier: str = Form(..., description="文件唯一标识符"),
        chunkNumber: str = Form(..., description="文件分片序号(初值为1)"),
        file: UploadFile = File(..., description="文件")
):
    """文件分片上传"""
    path = Path(UPLOAD_FILE_PATH, identifier)
    if not os.path.exists(path):
        os.makedirs(path)
    file_name = Path(path, f'{identifier}_{chunkNumber}')
    if not os.path.exists(file_name):
        async with aiofiles.open(file_name, 'wb') as f:
            await f.write(await file.read())
    return {"code": 200, "data": {
        'chunk': f'{identifier}_{chunkNumber}'
    }}


@app.put("/file-merge")
async def merge_file(
        request: Request,
        filename: str = Body(..., description="文件名称含后缀"),
        identifier: str = Body(..., description="文件唯一标识符"),
        totalChunks: int = Body(..., description="总分片数")
):
    """合并分片文件"""
    target_file_name = Path(UPLOAD_FILE_PATH, filename)
    path = Path(UPLOAD_FILE_PATH, identifier)
    try:
        async with aiofiles.open(target_file_name, 'wb+') as target_file:  # 打开目标文件
            for i in range(len(os.listdir(path))):
                temp_file_name = Path(path, f'{identifier}_{i + 1}')
                async with aiofiles.open(temp_file_name, 'rb') as temp_file:  # 按序打开每个分片
                    data = await temp_file.read()
                    await target_file.write(data)  # 分片内容写入目标文件
    except Exception as e:
        print(e)
        return {"code": 400, "msg": "合并失败"}
    shutil.rmtree(path)  # 删除临时目录
    return {"code": 200, "data": {"filename": filename, "url": f"/api/file/file-slice/{filename}"}}


@app.get("/file-slice/{file_name}")
async def download_file(request: Request, file_name: str = F_Path(..., description="文件名称(含后缀)")):
    """分片下载文件,支持断点续传"""
    # 检查文件是否存在
    file_path = Path(UPLOAD_FILE_PATH, file_name)
    if not os.path.exists(file_path):
        return {"code": 400, "msg": "文件不存在"}
    # 获取文件的信息
    stat_result = os.stat(file_path)
    content_type, encoding = guess_type(file_path)
    content_type = content_type or 'application/octet-stream'
    # 读取文件的起始位置和终止位置
    range_str = request.headers.get('range', '')
    range_match = re.search(r'bytes=(\d+)-(\d+)', range_str, re.S) or re.search(r'bytes=(\d+)-', range_str, re.S)
    if range_match:
        start_bytes = int(range_match.group(1))
        end_bytes = int(range_match.group(2)) if range_match.lastindex == 2 else stat_result.st_size - 1
    else:
        start_bytes = 0
        end_bytes = stat_result.st_size - 1
    # 这里 content_length 表示剩余待传输的文件字节长度
    content_length = stat_result.st_size - start_bytes if stat.S_ISREG(stat_result.st_mode) else stat_result.st_size
    # 构建文件名称
    name, *suffix = file_name.rsplit('.', 1)
    suffix = f'.{suffix[0]}' if suffix else ''
    filename = quote(f'{name}{suffix}')  # 文件名编码,防止中文名报错
    # 打开文件从起始位置开始分片读取文件
    return StreamingResponse(
        file_iterator(file_path, start_bytes, 1024 * 1024 * 5),  # 每次读取 5M
        media_type=content_type,
        headers={
            'content-disposition': f'attachment; filename="{filename}"',
            'accept-ranges': 'bytes',
            'connection': 'keep-alive',
            'content-length': str(content_length),
            'content-range': f'bytes {start_bytes}-{end_bytes}/{stat_result.st_size}',
            'last-modified': formatdate(stat_result.st_mtime, usegmt=True),
        },
        status_code=206 if start_bytes > 0 else 200
    )


def file_iterator(file_path, offset, chunk_size):
    """
    文件生成器
    :param file_path: 文件绝对路径
    :param offset: 文件读取的起始位置
    :param chunk_size: 文件读取的块大小
    :return: yield
    """
    with open(file_path, 'rb') as f:
        f.seek(offset, os.SEEK_SET)
        while True:
            data = f.read(chunk_size)
            if data:
                yield data
            else:
                break


if __name__ == '__main__':
    uvicorn.run("main:app", host="0.0.0.0", port=8000)
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值