vue+Minio实现多文件进度上传

vue+Minio实现多文件进度上传

背景

最近突然接到了一个产品的需求,有点特别,在这里给大家分享一下,需求如下

  1. 提交表单,同时要上传模型资源
  2. 模型文件是大文件,要显示上传进度,同时可以删除
  3. 模型文件要上传到服务器,表单数据同步到数据库
  4. 同时要同步上传后的模型地址到数据库
  5. 后端使用Minio做文件管理

设计图如下

pic1.png

一开始以为是一个简单的表单上传,发现并不是,这是大文件上传啊,但很快又发现,不单单是上传大文件,还有将文件信息关联到表单。

基于这个奇葩的情况,我和后端兄弟商量了一下,决定使用如下方案

实现方案

分2步走

  1. 点击上传时,先提交表单信息到数据库,接着后端返回一个表单的id给我
  2. 当所有文件上传完成后,再调用另外一个服务,将上传完成后的地址和表单id发送给后端

如此,便完成了上面的需求

了解一下Mino

这里大家先了解一下Minio的js SDK文档

里面有2个很重要的接口,今天要用到

一个是给文件生成用于put方法上传的地址
image.png
一个是获取已经上传完成后的文件的get下载地址

image.png

实现步骤

这里是使用原生的 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 = 
posted @   风吹麦浪打  阅读(3101)  评论(1编辑  收藏  举报
编辑推荐:
· .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 安装失败解决方法
点击右上角即可分享
微信分享提示