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)