当前位置:   article > 正文

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

vue3 大文件上传

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

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

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

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

 安装后版本是:

9b0afe7ef4fa48bcab3ab729daf6f721.png

 安装好后在main.js中引入

  1. // 文件分片上传
  2. import uploader from 'vue-simple-uploader';
  3. import 'vue-simple-uploader/dist/style.css';
  4. const app = createApp(App)
  5. app.use(uploader)

 

  1. <template>
  2. <div>
  3. <uploader
  4. ref="uploaderRef"
  5. :options="options"
  6. :autoStart="false"
  7. :file-status-text="fileStatusText"
  8. class="uploader-ui"
  9. @file-added="onFileAdded"
  10. @file-success="onFileSuccess"
  11. @file-progress="onFileProgress"
  12. @file-error="onFileError"
  13. >
  14. <uploader-unsupport></uploader-unsupport>
  15. <uploader-drop>
  16. <p>拖拽文件到此处或</p>
  17. <uploader-btn id="global-uploader-btn" ref="uploadBtn" :attrs="attrs"
  18. >选择文件<i class="el-icon-upload el-icon--right"></i
  19. ></uploader-btn>
  20. </uploader-drop>
  21. <uploader-list></uploader-list>
  22. </uploader>
  23. </div>
  24. </template>
  25. <script setup>
  26. import { reactive, ref, onMounted } from "vue";
  27. import SparkMD5 from "spark-md5";
  28. import { mergeFile } from "@/apis/upload";
  29. const options = reactive({
  30. //目标上传 URL,默认POST, import.meta.env.VITE_API_URL = api
  31. // target ==》http://localhost:8000/api/uploader/chunk
  32. target: "http://localhost:8000/chunk",
  33. query: {},
  34. headers: {
  35. // 需要携带token信息,当然看各项目情况具体定义
  36. Authorization: "Bearer " + localStorage.getItem("access_token"),
  37. },
  38. //分块大小(单位:字节)
  39. chunkSize: 1024 * 1024 * 5,
  40. //上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
  41. fileParameterName: "file",
  42. //失败后最多自动重试上传次数
  43. maxChunkRetries: 3,
  44. //是否开启服务器分片校验,对应GET类型同名的target URL
  45. testChunks: true,
  46. // 剩余时间
  47. parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {
  48. return parsedTimeRemaining
  49. .replace(/\syears?/, "年")
  50. .replace(/\days?/, "天")
  51. .replace(/\shours?/, "小时")
  52. .replace(/\sminutes?/, "分钟")
  53. .replace(/\sseconds?/, "秒");
  54. },
  55. // 服务器分片校验函数
  56. checkChunkUploadedByResponse: function (chunk, response_msg) {
  57. let objMessage = JSON.parse(response_msg);
  58. console.log(response_msg, "response_msg");
  59. if (objMessage.data.isExist) {
  60. return true;
  61. }
  62. return (objMessage.data.uploaded || []).indexOf(chunk.offset + 1) >= 0;
  63. },
  64. });
  65. const attrs = reactive({
  66. // 设置上传文件类型
  67. accept: ['.rar','.zip']
  68. })
  69. const fileStatusText = reactive({
  70. success: "上传成功",
  71. error: "上传失败",
  72. uploading: "上传中",
  73. paused: "暂停",
  74. waiting: "等待上传",
  75. });
  76. onMounted(() => {
  77. console.log(uploaderRef.value, "uploaderRef.value");
  78. });
  79. function onFileAdded(file) {
  80. computeMD5(file);
  81. }
  82. async function onFileSuccess(rootFile, file, response, chunk) {
  83. //refProjectId为预留字段,可关联附件所属目标,例如所属档案,所属工程等
  84. // 判断是否秒传成功,成功取消合并
  85. console.log("秒传成功", response);
  86. let objMessage = JSON.parse(response);
  87. if (objMessage.data.isExist != null && objMessage.data.isExist == true) {
  88. console.log("秒传成功", response);
  89. window.$message.success("秒传成功");
  90. return;
  91. }
  92. const res = await mergeFile({
  93. filename: file.name, //文件名称
  94. identifier: file.uniqueIdentifier, //文件唯一标识
  95. totalChunks: chunk.offset + 1, //文件总分片数
  96. });
  97. if (res.code === 200) {
  98. console.log("上传成功", res);
  99. window.$message.success("上传成功");
  100. } else {
  101. console.log("上传失败", res);
  102. window.$message.error("上传失败");
  103. }
  104. }
  105. function onFileError(rootFile, file, response, chunk) {
  106. console.log("上传完成后异常信息:" + response);
  107. }
  108. /**
  109. * 计算md5,实现断点续传及秒传
  110. * @param file
  111. */
  112. function computeMD5(file) {
  113. file.pause();
  114. //单个文件的大小限制1G
  115. let fileSizeLimit = 1 * 1024 * 1024 * 1024;
  116. console.log("文件大小:" + file.size);
  117. console.log("限制大小:" + fileSizeLimit);
  118. if (file.size > fileSizeLimit) {
  119. file.cancel();
  120. window.$message.error("文件大小不能超过1G");
  121. }
  122. let fileReader = new FileReader();
  123. let time = new Date().getTime();
  124. let blobSlice =
  125. File.prototype.slice ||
  126. File.prototype.mozSlice ||
  127. File.prototype.webkitSlice;
  128. let currentChunk = 0;
  129. const chunkSize = 10 * 1024 * 1000;
  130. let chunks = Math.ceil(file.size / chunkSize);
  131. let spark = new SparkMD5.ArrayBuffer();
  132. //如果文件过大计算非常慢,因此采用只计算第1块文件的md5的方式
  133. let chunkNumberMD5 = 1;
  134. loadNext();
  135. fileReader.onload = (e) => {
  136. spark.append(e.target.result);
  137. if (currentChunk < chunkNumberMD5) {
  138. loadNext();
  139. } else {
  140. let md5 = spark.end();
  141. file.uniqueIdentifier = md5;
  142. // 计算完毕开始上传
  143. file.resume();
  144. console.log(
  145. `MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${
  146. file.size
  147. } 用时:${new Date().getTime() - time} ms`
  148. );
  149. }
  150. };
  151. fileReader.onerror = function () {
  152. file.cancel();
  153. };
  154. function loadNext() {
  155. let start = currentChunk * chunkSize;
  156. let end = start + chunkSize >= file.size ? file.size : start + chunkSize;
  157. fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
  158. currentChunk++;
  159. console.log("计算第" + currentChunk + "块");
  160. }
  161. }
  162. const uploaderRef = ref();
  163. function close() {
  164. uploaderRef.value.cancel();
  165. }
  166. function error(msg) {
  167. console.log(msg, "msg");
  168. }
  169. </script>
  170. <style scoped>
  171. .uploader-ui {
  172. padding: 15px;
  173. margin: 40px auto 0;
  174. font-size: 12px;
  175. font-family: Microsoft YaHei;
  176. box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
  177. }
  178. .uploader-ui .uploader-btn {
  179. margin-right: 4px;
  180. font-size: 12px;
  181. border-radius: 3px;
  182. color: #fff;
  183. background-color: #58bfc1;
  184. border-color: #58bfc1;
  185. display: inline-block;
  186. line-height: 1;
  187. white-space: nowrap;
  188. }
  189. .uploader-ui .uploader-list {
  190. max-height: 440px;
  191. overflow: auto;
  192. overflow-x: hidden;
  193. overflow-y: auto;
  194. }
  195. </style>

后端代码:

使用需要安装FastAPI和aiofiles

安装命令

  1. pip install fastapi
  2. pip install aiofiles

 

  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. # @Time : 2023/2/10 16:44
  4. # @Author : YueJian
  5. # @File : test2.py
  6. # @Description :
  7. import os
  8. import os.path
  9. import re
  10. import shutil
  11. import stat
  12. from email.utils import formatdate
  13. from mimetypes import guess_type
  14. from pathlib import Path
  15. from urllib.parse import quote
  16. import aiofiles
  17. import uvicorn
  18. from fastapi import Body, File, Path as F_Path, UploadFile, Request
  19. from fastapi import Form, FastAPI
  20. from starlette.responses import StreamingResponse
  21. app = FastAPI()
  22. # 上传位置
  23. UPLOAD_FILE_PATH = "./static/file"
  24. @app.get("/file-slice")
  25. async def check_file(identifier: str, totalChunks: int):
  26. """
  27. 判断上传文件是否存在
  28. :param identifier:
  29. :param totalChunks:
  30. :return:
  31. """
  32. path = os.path.join(UPLOAD_FILE_PATH, identifier)
  33. if not os.path.exists(path):
  34. # 不存在
  35. return {"code": 200, "data": {"isExist": False}}
  36. else:
  37. # 存在时判断分片数是否
  38. chunks = [int(str(i).split("_")[1]) for i in os.listdir(path)]
  39. return {"code": 200, "data": {"isExist": False, "uploaded": chunks}}
  40. @app.post("/file-slice")
  41. async def upload_file(
  42. request: Request,
  43. identifier: str = Form(..., description="文件唯一标识符"),
  44. chunkNumber: str = Form(..., description="文件分片序号(初值为1)"),
  45. file: UploadFile = File(..., description="文件")
  46. ):
  47. """文件分片上传"""
  48. path = Path(UPLOAD_FILE_PATH, identifier)
  49. if not os.path.exists(path):
  50. os.makedirs(path)
  51. file_name = Path(path, f'{identifier}_{chunkNumber}')
  52. if not os.path.exists(file_name):
  53. async with aiofiles.open(file_name, 'wb') as f:
  54. await f.write(await file.read())
  55. return {"code": 200, "data": {
  56. 'chunk': f'{identifier}_{chunkNumber}'
  57. }}
  58. @app.put("/file-merge")
  59. async def merge_file(
  60. request: Request,
  61. filename: str = Body(..., description="文件名称含后缀"),
  62. identifier: str = Body(..., description="文件唯一标识符"),
  63. totalChunks: int = Body(..., description="总分片数")
  64. ):
  65. """合并分片文件"""
  66. target_file_name = Path(UPLOAD_FILE_PATH, filename)
  67. path = Path(UPLOAD_FILE_PATH, identifier)
  68. try:
  69. async with aiofiles.open(target_file_name, 'wb+') as target_file: # 打开目标文件
  70. for i in range(len(os.listdir(path))):
  71. temp_file_name = Path(path, f'{identifier}_{i + 1}')
  72. async with aiofiles.open(temp_file_name, 'rb') as temp_file: # 按序打开每个分片
  73. data = await temp_file.read()
  74. await target_file.write(data) # 分片内容写入目标文件
  75. except Exception as e:
  76. print(e)
  77. return {"code": 400, "msg": "合并失败"}
  78. shutil.rmtree(path) # 删除临时目录
  79. return {"code": 200, "data": {"filename": filename, "url": f"/api/file/file-slice/{filename}"}}
  80. @app.get("/file-slice/{file_name}")
  81. async def download_file(request: Request, file_name: str = F_Path(..., description="文件名称(含后缀)")):
  82. """分片下载文件,支持断点续传"""
  83. # 检查文件是否存在
  84. file_path = Path(UPLOAD_FILE_PATH, file_name)
  85. if not os.path.exists(file_path):
  86. return {"code": 400, "msg": "文件不存在"}
  87. # 获取文件的信息
  88. stat_result = os.stat(file_path)
  89. content_type, encoding = guess_type(file_path)
  90. content_type = content_type or 'application/octet-stream'
  91. # 读取文件的起始位置和终止位置
  92. range_str = request.headers.get('range', '')
  93. range_match = re.search(r'bytes=(\d+)-(\d+)', range_str, re.S) or re.search(r'bytes=(\d+)-', range_str, re.S)
  94. if range_match:
  95. start_bytes = int(range_match.group(1))
  96. end_bytes = int(range_match.group(2)) if range_match.lastindex == 2 else stat_result.st_size - 1
  97. else:
  98. start_bytes = 0
  99. end_bytes = stat_result.st_size - 1
  100. # 这里 content_length 表示剩余待传输的文件字节长度
  101. content_length = stat_result.st_size - start_bytes if stat.S_ISREG(stat_result.st_mode) else stat_result.st_size
  102. # 构建文件名称
  103. name, *suffix = file_name.rsplit('.', 1)
  104. suffix = f'.{suffix[0]}' if suffix else ''
  105. filename = quote(f'{name}{suffix}') # 文件名编码,防止中文名报错
  106. # 打开文件从起始位置开始分片读取文件
  107. return StreamingResponse(
  108. file_iterator(file_path, start_bytes, 1024 * 1024 * 5), # 每次读取 5M
  109. media_type=content_type,
  110. headers={
  111. 'content-disposition': f'attachment; filename="{filename}"',
  112. 'accept-ranges': 'bytes',
  113. 'connection': 'keep-alive',
  114. 'content-length': str(content_length),
  115. 'content-range': f'bytes {start_bytes}-{end_bytes}/{stat_result.st_size}',
  116. 'last-modified': formatdate(stat_result.st_mtime, usegmt=True),
  117. },
  118. status_code=206 if start_bytes > 0 else 200
  119. )
  120. def file_iterator(file_path, offset, chunk_size):
  121. """
  122. 文件生成器
  123. :param file_path: 文件绝对路径
  124. :param offset: 文件读取的起始位置
  125. :param chunk_size: 文件读取的块大小
  126. :return: yield
  127. """
  128. with open(file_path, 'rb') as f:
  129. f.seek(offset, os.SEEK_SET)
  130. while True:
  131. data = f.read(chunk_size)
  132. if data:
  133. yield data
  134. else:
  135. break
  136. if __name__ == '__main__':
  137. uvicorn.run("main:app", host="0.0.0.0", port=8000)

个人博客网站灵动空间,欢迎访问

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小惠珠哦/article/detail/995808
推荐阅读
相关标签
  

闽ICP备14008679号