vue+Minio实现多文件进度上传
背景
最近突然接到了一个产品的需求,有点特别,在这里给大家分享一下,需求如下
- 提交表单,同时要上传模型资源
- 模型文件是大文件,要显示上传进度,同时可以删除
- 模型文件要上传到服务器,表单数据同步到数据库
- 同时要同步上传后的模型地址到数据库
- 后端使用Minio做文件管理
设计图如下
一开始以为是一个简单的表单上传,发现并不是,这是大文件上传啊,但很快又发现,不单单是上传大文件,还有将文件信息关联到表单。
基于这个奇葩的情况,我和后端兄弟商量了一下,决定使用如下方案
实现方案
分2步走
- 点击上传时,先提交表单信息到数据库,接着后端返回一个表单的id给我
- 当所有文件上传完成后,再调用另外一个服务,将上传完成后的地址和表单id发送给后端
如此,便完成了上面的需求
了解一下Mino
这里大家先了解一下Minio的js SDK文档
里面有2个很重要的接口,今天要用到
一个是给文件生成用于put方法上传的地址 |
---|
一个是获取已经上传完成后的文件的get下载地址 |
---|
实现步骤
这里是使用原生的 ajax请求进行上传的,至于为什么,后面会有说到
1.创建存储桶
创建一个Minio上传实例
var Minio = require('minio')
this.minioClient = new Minio.Client({
endPoint: '192.168.172.162', //后端提供
port: 9000, //端口号默认9000
useSSL: true,
accessKey: 'Q3AM3UQ867SPQQA43P2F', //后端提供
secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
});
this.userBucket = 'yourBucketName' //这里后端需要提供给你,一个存储桶名字
复制代码
2.选择文件
这里使用input标签选择文件,点击选择文件的时候,调用一下input的click方法,就可以打开本地文件夹
<el-form-item label="资源文件">
<el-button
style="marginRight:10px;"
@click="selectFile()"
size="mini"
>选择文件</el-button>
<input
:accept="acceptFileType"
multiple="multiple"
type="file"
id="uploadInput"
ref="uploadInput"
v-show="false"
@change="getAndFormatFile()"
>
<i class="tip">仅支持.gbl、.gltf、.fbx、.obj、.mtl、.hdr、.png、.jpg格式的文件</i>
<i class="tip">单个文件的大小限制为128MB</i>
</el-form-item>
复制代码
selectFile() {
let inputDOM = this.$refs.uploadInput
inputDOM.click();
},
复制代码
接着就是对文件进行格式化
//格式化文件并创建上传队列
getAndFormatFile(){
let files = this.$refs.uploadInput.files
const userBucket = this.userBucket
if(files.length > 6) {
this.$message({
message: `最大只能上传6个文件`,
type: 'warning'
})
return
}
files.forEach((file, index) => {
if ((file.size / 1024 / 1024).toFixed(2) > 128) { //单个文件限制大小为128MB
this.$message({
message: `文件大小不能超过128MB`,
type: 'warning'
})
return
}
//创建文件的put方法的url
this.minioClient.presignedPutObject(userBucket, file.name, 24 * 60 * 60, (err, presignedUrl) => {
if (err) {
this.$message({
message: `服务器连接超时`,
type: 'error'
})
return err
}
let fileIcon = this.getFileIcon(file)
let fileUploadProgress = '0%' //文件上传进度
this.fileInfoList.push({
file, //文件
fileIcon, //文件对应的图标 className
fileUploadProgress, //文件上传进度
filePutUrl: presignedUrl, //文件上传put方法的url
fileGetUrl: '', //文件下载的url
})
})
})
this.fileList = [...this.fileInfoList]
},
复制代码
3.创建上传队列
这里定义了一个创建文件上传请求的方法,使用原生的XMLHttpRequest
,它接受以下参数
file
:要上传的文件filePutUrl
:文件上传的put方法地址customHeader
: 自定义的头信息onUploadProgress
:文件上传的进度监听函数onUploaded
:文件上传完成的监听函数onError
:文件上传出错的监听函数
//创建上传文件的http
createUploadHttp(config){
const {file, filePutUrl, customHeader, onUploadProgress, onUploaded, onError} = config
let fileName = file.name
let http = new XMLHttpRequest();
http.upload.addEventListener("progress", (e) => { //监听http的进度。并执行进度监听函数
onUploadProgress({
progressEvent: e,
uploadingFile: file
})
}, false)
http.onload = () => {
if (http.status === 200 && http.status < 300 || http.status === 304) {
try {
//监听http的完成事件,并执行上传完成的监听函数
const result = http.responseURL
onUploaded({ result, uploadedFile: file})
} catch(error) {
//监听错误
onError({ error, errorFile: file})
}
}
}
http.open("PUT", filePutUrl, true);
//加入头信息
Object.keys(customHeader).forEach((key, index) =>{
http.setRequestHeader(key, customHeader[key])
})
http.send(file);
return http //返回该http实例
}
复制代码
4.开始上传
//上传文件到存储桶
async handleUplaod(){
let _this = this
if(this.fileInfoList.length < 1) {
this.$message({
message: `请先选择文件`,
type: 'warning'
})
return
}
//先上传文件的基本表单信息,获取表单信息的id
try{
const {remark, alias} = _this.uploadFormData
let res = await uploadModelSourceInfo({remark, serviceName: alias})
_this.modelSourceInfoId = res.message
}catch(error){
if(error) {
_this.$message({
message: `上传失败,请检查服务`,
type: 'error'
})
return
}
}
//开始将模型资源上传到远程的存储桶
this.fileList.forEach((item, index) => {
const {file, filePutUrl} = item
let config = {
file,
filePutUrl,
customHeader:{
"X-FILENAME": encodeURIComponent(file.name),
"X-Access-Token": getToken()
},
onUploadProgress: ({progressEvent, uploadingFile}) => {
let progress = (progressEvent.loaded / progressEvent.total).toFixed(2)
this.updateFileUploadProgress(uploadingFile, progress)
},
onUploaded: ({result, uploadedFile}) => {
this.updateFileDownloadUrl(uploadedFile)
},
onError: ({error, errorFile}) => {
}
}
let httpInstance = this.createUploadHttp(config) //创建http请求实例
this.httpQueue.push(httpInstance) //将http请求保存到队列中
})
},
//更新对应文件的上传进度
updateFileUploadProgress(uploadingFile, progress) {
this.fileInfoList.forEach((item, index) => {
if(item.file.name === uploadingFile.name){
item.fileUploadProgress = (Number(progress)*100).toFixed(2) + '%'
}
})
},
//更新上传完成文件的下载地址
updateFileDownloadUrl(uploadedFile){
const userBucket = this.userBucket
this.fileInfoList.forEach((item, index) => {
if(item.file.name === uploadedFile.name){
this.minioClient.presignedGetObject(userBucket, uploadedFile.name, 24*60*60, (err, presignedUrl) => {
if (err) return console.log(err)
item.fileGetUrl = presignedUrl
})
}
})
},
复制代码
5 上传完成后,同步文件地址给后端
在watch里监听文件列表,当所有的文件进度都是100%时,表示上传完成,接着就可以同步文件信息
watch:{
fileInfoList: {
handler(val){
//1.3所有文件都上传到存储桶后,将上传完成后的文件地址、文件名字同步后端
if(val.length < 1) return
let allFileHasUpload = val.every((item, index) => {
return item.fileGetUrl.length > 1
})
if(allFileHasUpload) {
this.allFileHasUpload = allFileHasUpload
const {modelSourceInfoId} = this
if(modelSourceInfoId.length < 1) {
return
}
const url = process.env.VUE_APP_BASE_API + "/vector-map/threeDimensionalModelService/invokeMapService"
const files = val.map((ite, idx) => {
return {
fileName: ite.file.name,
fileUrl: ite.fileGetUrl
}
})
this.syncAllUploadedFile(url, files, modelSourceInfoId)
}
},
deep: true
}
},
//同步已上传的文件到后端
syncAllUploadedFile(url, files, modelSourceInfoId){
let xhr = new XMLHttpRequest()
xhr.onload = () => {
if (xhr.status === 200 && xhr.status < 300 || xhr.status === 304) {
try {
const res = JSON.parse(xhr.responseText)
if(res && res.code === 200){
this.$message({
message: '上传完成',
type: 'success'
})
this.$emit('close')
this.fileInfoList = []
this.fileList = []
this.httpQueue = []
}
} catch(error) {
this.$message({
message: '上传失败,请检查服务',
type: 'error'
})
}
}
}
xhr.open("post", url, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('X-Access-Token', getToken())
//将前面1.1获取文件信息的id作为头信息传递到后端
xhr.setRequestHeader('ThreeDimensionalModel-ServiceID', modelSourceInfoId)
xhr.send(JSON.stringify(files))
},
复制代码
6.删除文件
删除文件时要注意
- 删除本地的文件缓存
- 删除存储桶里面的文件
- 停止当前文件对应的http请求
//删除文件,并取消正在文件的上传
deleteFile(fileInfo, index){
this.httpQueue[index] && this.httpQueue[index].abort()
this.httpQueue[index] && this.httpQueue.splice(index, 1)
this.fileInfoList.splice(index, 1)
this.fileList.splice(index, 1)
this.removeRemoteFile(fileInfo)
},
//清空文件并取消上传队列
clearFile() {
this.fileInfoList.forEach((item, index) => {
this.httpQueue[index] && this.httpQueue[index].abort()
this.httpQueue[index] && this.httpQueue.splice(index, 1)
this.removeRemoteFile(item)
})
this.fileInfoList = []
this.httpQueue = []
this.fileList = []
},
//删除远程文件
removeRemoteFile(fileInfo){
const userBucket = this.userBucket
const { fileUploadProgress, file} = fileInfo
const fileName = file.name
const complete = fileUploadProgress === '100.00%' ? true : false
if(complete){
this. minioClient.removeObject(userBucket, fileName, function(err) {
if (err) {
return console.log('Unable to remove object', err)
}
console.log('Removed the object')
})
}else{
this.minioClient.removeIncompleteUpload(userBucket, fileName, function(err) {
if (err) {
return console.log('Unable to remove incomplete object', err)
}
console.log('Incomplete object removed successfully.')
})
}
},
复制代码
完整代码
这里的完整代码是我直接从工程里拷贝出来的,里面用到了一些自己封装的服务和方法 比如 后端的接口、AES解密、获取Token、表单验证等
import{uploadModelSourceInfo, uploadModelSource, getMinioConfig} from '@/api/map'
import AES from '@/utils/AES.js'
import { getToken } from '@/utils/auth'
import * as myValiDate from "@/utils/formValidate";
复制代码
/**
* 文件说明
* @Author: zhuds
* @Description: 模型资源上传弹窗
分为3个步骤
1.先将文件的基本表单信息上传给后端,获取文件信息的ID
2.然后将文件上传存储桶
3.等所有文件都上传完成后,再将上传完成后的文件信息传递给后端,注意,此时的请求头要戴上第1步获取的文件信息id
* @Date: 2/28/2022, 1:13:20 PM
* @LastEditDate: 2/28/2022, 1:13:20 PM
* @LastEditor:
*/
<template>
<div class="upload-model">
<el-dialog
:visible.sync="isVisible"
@close="close()"
:show-close ="false"
:close-on-click-modal="false"
top="10vh"
v-if="isVisible"
:destroy-on-close="true"
>
<div slot="title" class="header-title">
<div class="icon"></div>
<span>上传模型资源</span>
<i class="el-icon-close" @click="close()"></i>
</div>
<el-form
:label-position="labelPosition"
label-width="80px"
:model="uploadFormData"
ref="form"
:rules="rules"
>
<el-form-item label="别名">
<el-input size="small" v-model="uploadFormData.alias"></el-input>
</el-form-item>
<el-form-item label="备注">
<el-input type="textarea" v-model="uploadFormData.remark" size="small"></el-input>
</el-form-item>
<el-form-item label="资源文件">
<el-button
style="marginRight:10px;"
@click="selectFile()"
size="mini"
>选择文件</el-button>
<input
:accept="acceptFileType"
multiple="multiple"
type="file"
id="uploadInput"
ref="uploadInput"
v-show="false"
@change="getAndFormatFile()"
>
<i class="tip">仅支持.gbl、.gltf、.fbx、.obj、.mtl、.hdr、.png、.jpg格式的文件</i>
<i class="tip">单个文件的大小限制为128MB</i>
</el-form-item>
</el-form>
<div class="file-list" v-show="fileInfoList.length > 0">
<div class="file-item" v-for="(item, index) in fileInfoList" :key="index">
<div class="icon"></div>
<div class="name">{{item.file.name}}</div>
<div class="size">{{(item.file.size/1024/1024).toFixed(2)}}MB </div>
<div class="progress">
<div class="bar" :style="{width: item.fileUploadProgress}"></div>
</div>
<div class="rate">{{item.fileUploadProgress}}</div>
<div class="delete-btn" @click="deleteFile(item, index)">x</div>
</div>
</div>
<div class="custom-footer">
<button class="info" @click="close()">取 消</button>
<button class="success" @click="handleUplaod()">上传</button>
</div>
</el-dialog>
</div>
</template>
<script>
import{uploadModelSourceInfo, uploadModelSource, getMinioConfig} from '@/api/map'
import AES from '@/utils/AES.js'
import { getToken } from '@/utils/auth'
import * as myValiDate from "@/utils/formValidate";
let Minio = require('minio')
export default {
name: 'UploadModelDialog',
props: {
isVisible: {
type: Boolean,
default: false
},
},
data(){
return {
labelPosition: 'right',
uploadFormData: {
alias: '', //服务名称
remark: '', //备注
},
rules: {
serviceName: [{
validator: myValiDate.validateServiceName,
trigger: "blur",
required: true,
}],
},
acceptFileType:".glb,.gltf,.fbx,.obj,.mtl,.hdr,.png,.jpg, .mp4",
fileList:[], //待上传的文件列表
fileInfoList: [], //格式化后的文件信息列表
userBucket: null,
httpQueue: [], //上传文件的http队列
allFileHasUpload: false, //是否完成上传
modelSourceInfoId: '', //模型资源基本信息的id
}
},
watch:{
fileInfoList: {
handler(val){
//1.3所有文件都上传到存储桶后,将上传完成后的文件地址、文件名字同步后端
if(val.length < 1) return
let allFileHasUpload = val.every((item, index) => {
return item.fileGetUrl.length > 1
})
if(allFileHasUpload) {
this.allFileHasUpload = allFileHasUpload
const {modelSourceInfoId} = this
if(modelSourceInfoId.length < 1) {
return
}
const url = process.env.VUE_APP_BASE_API + "/vector-map/threeDimensionalModelService/invokeMapService"
const files = val.map((ite, idx) => {
return {
fileName: ite.file.name,
fileUrl: ite.fileGetUrl
}
})
this.syncAllUploadedFile(url, files, modelSourceInfoId)
}
},
deep: true
}
},
created() {
this.initMinioClient()
},
beforeDestroy() {
if(!this.allFileHasUpload) {
this.clearFile()
}
},
methods:{
//创建存储桶
async initMinioClient(){
const { code, result, message } = AES.decryptToJSON(await getMinioConfig({}))
if(!result || code !== 200) {
this.$customMessage.error({message: '获取存储桶配置信息出错'})
return false
}
let {accessKey, bucketName, endPoint, secretKey} = result
//console.log({accessKey, bucketName, endPoint, secretKey})
let endPointStr = endPoint.split(":")[1]
let formatPort = Number(endPoint.split(":")[2])
let formatEndPoint = endPointStr.split('//')[1]
this.userBucket = bucketName
this.minioClient = new Minio.Client({
useSSL: false,
partSize: '20M',
port: formatPort,
endPoint: formatEndPoint,
accessKey,
secretKey
});
let userBucket = this.userBucket
//userBucket只能作为字符串变量传入,不能作为其他变量的属性或者函数返回值,属于Minio的一个规定
this.minioClient.bucketExists(userBucket, (err)=> {
if (err && err.code == 'NoSuchBucket') {
this.minioClient.makeBucket(userBucket, 'us-east-1', (err)=> {
if (err) {
return console.log('创建存储桶失败', err)
}
// console.log('Bucket created successfully in "us-east-1".')
})
}else{
//console.log('存储桶存在')
}
})
},
close(flag = false) {
this.$emit('close', flag)
//关闭弹窗时,如果文件没有上传完成,则清空文件
if(!this.allFileHasUpload) {
this.clearFile()
}
},
selectFile() {
let inputDOM = this.$refs.uploadInput
inputDOM.click();
},
getFileSize(file){
let fileSize = ''
if(file.size / 1024 < 1){
fileSize = file.size + 'B'
}else if(file.size / 1024 /1024 < 1){
fileSize = file.size + 'KB'
}else if(file.size / 1024 /1024 >=1){
fileSize = file.size + 'MB'
}else{
}
return fileSize
},
//删除文件,并取消正在文件的上传
deleteFile(fileInfo, index){
this.httpQueue[index] && this.httpQueue[index].abort()
this.httpQueue[index] && this.httpQueue.splice(index, 1)
this.fileInfoList.splice(index, 1)
this.fileList.splice(index, 1)
this.removeRemoteFile(fileInfo)
},
//清空文件并取消上传队列
clearFile() {
this.fileInfoList.forEach((item, index) => {
this.httpQueue[index] && this.httpQueue[index].abort()
this.httpQueue[index] && this.httpQueue.splice(index, 1)
this.removeRemoteFile(item)
})
this.fileInfoList = []
this.httpQueue = []
this.fileList = []
},
//删除远程文件
removeRemoteFile(fileInfo){
const userBucket = this.userBucket
const { fileUploadProgress, file} = fileInfo
const fileName = file.name
const complete = fileUploadProgress === '100.00%' ? true : false
if(complete){
this. minioClient.removeObject(userBucket, fileName, function(err) {
if (err) {
return console.log('Unable to remove object', err)
}
console.log('Removed the object')
})
}else{
this.minioClient.removeIncompleteUpload(userBucket, fileName, function(err) {
if (err) {
return console.log('Unable to remove incomplete object', err)
}
console.log('Incomplete object removed successfully.')
})
}
},
//格式化文件并创建上传队列
getAndFormatFile(){
let files = this.$refs.uploadInput.files
const userBucket =
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· AI与.NET技术实操系列(六):基于图像分类模型对图像进行分类
2019-03-18 sublime text 3 package Install 安装失败解决方法