vue分片上传文件功能
参考https://blog.csdn.net/qq_41579104/article/details/124456211?spm=1001.2101.3001.6650.6&utm_medium=distribute.pc_relevant.none-task-blog-2defaultBlogCommendFromBaiduRate-6-124456211-blog-127953473.235%5Ev33%5Epc_relevant_increate_t0_download_v2_base&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2defaultBlogCommendFromBaiduRate-6-124456211-blog-127953473.235%5Ev33%5Epc_relevant_increate_t0_download_v2_base&utm_relevant_index=11 然后自己做了大量修改
支持大文件上传,文件批量上传、因为是对文件分片请求上传,请求数量巨大,对请求并发数进行了控制。并进行了错误处理(分片上传请求失败,会监听然后重新进行失败分片的上传请求) 文件上传增加了解析md5的进度展示以及上传文件的进度条和上传速度的展示 ,(解析文件MD5用了‘spark-md5’,可以先npm i spark-md5 --save
)直接上代码:
效果图
<template>
<div class="test-info" style="line-height: 25px; margin-top: 10px">
<el-form
:inline="true"
:model="dataForm"
@keyup.enter.native="getFileUploadList(true)"
>
<el-form-item>
<el-input
size="mini"
class="selects"
v-model="dataForm.fileName"
placeholder="文件名称"
clearable
></el-input>
</el-form-item>
<el-form-item>
<el-select
size="mini"
class="selects"
v-model="dataForm.creator"
placeholder="上传者"
filterable
clearable
>
<el-option
v-for="item in dataForm.testPrincipalList"
:label="item.userName"
:value="item.userAccount"
:key="item.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button
size="mini"
class="btns"
type="primary"
plain
style="height: 35px"
@click="getFileUploadList(true)"
>查询</el-button
>
</el-form-item>
<el-form-item>
<el-button
size="mini"
class="btns"
style="height: 35px"
@click="reset()"
>重置</el-button
>
</el-form-item>
</el-form>
<div style="margin: 0 0 10px 0">
<el-button size="mini" type="primary" @click="fileDialogVisible = true"
>上传文件</el-button
>
</div>
<el-table
:data="fileDataList"
border
v-loading="dataListLoading"
style="width: 99.6%"
:header-cell-style="{ background: '#87CEEB', color: '#606266' }"
ref="table"
>
<!-- :height="tableMaxHeight" -->
<af-table-column
type="index"
:index="1"
prop="sequence"
header-align="center"
align="center"
width="60"
label="序号"
>
</af-table-column>
<!-- <af-table-column
type="index"
:index="1"
prop="uploadId"
header-align="center"
align="center"
width="100"
label="uploadId"
>
</af-table-column> -->
<af-table-column
prop="submitTitle"
header-align="center"
align="center"
width="250"
label="文件名称"
>
<template slot-scope="scope">
<el-popover
width="280"
placement="top-start"
trigger="hover"
:content="scope.row.fileName"
>
<span slot="reference" class="pop-hover">
{{ scope.row.fileName }}
</span>
</el-popover>
</template>
</af-table-column>
<af-table-column
prop="fileIdentifier"
header-align="center"
align="center"
width="280"
label="文件md5"
>
<template slot-scope="scope">
<el-popover
width="290"
placement="top-start"
trigger="hover"
:content="scope.row.fileIdentifier"
>
<span slot="reference" class="pop-hover">
{{ scope.row.fileIdentifier }}
</span>
</el-popover>
</template>
</af-table-column>
<af-table-column
type="index"
prop="uploadId"
header-align="center"
align="center"
width="120"
label="文件大小"
>
<template slot-scope="scope">
<span v-if="scope.row.totalSize > 1024 * 1024 * 1024"
>{{
(scope.row.totalSize / 1024 / 1024 / 1024).toFixed(1)
}}
Gb</span
>
<span v-else-if="scope.row.totalSize > 1024 * 1024"
>{{ (scope.row.totalSize / 1024 / 1024).toFixed(1) }} Mb</span
>
<span v-else-if="scope.row.totalSize > 1024"
>{{ (scope.row.totalSize / 1024).toFixed(1) }} Kb</span
>
<span v-else>{{ scope.row.totalSize.toFixed(1) }} B</span>
</template>
</af-table-column>
<!-- <af-table-column
prop="qps"
header-align="center"
align="center"
label="文件是否上传完成"
>
<template slot-scope="scope">
<span v-if="scope.row.isFinished === 0">未完成</span>
<span v-if="scope.row.isFinished === 1">已完成</span>
</template>
</af-table-column> -->
<af-table-column
prop="creatorName"
header-align="center"
align="center"
label="上传者"
width="120"
>
</af-table-column>
<af-table-column
prop="createTime"
header-align="center"
align="center"
label="上传时间"
:formatter="formatTimeStampCreate"
>
</af-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="currentPage"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
>
</el-pagination>
<el-dialog
title="文件上传"
:visible.sync="fileDialogVisible"
width="40%"
:close-on-click-modal="false"
@close="dialogClose"
>
<div v-for="(item, index) in errorList" :key="index">
<span style="color: red"
>{{ item }}部分分片上传失败,请重新打开窗口上传</span
>
</div>
<div
class="myDiv"
style="display: flex; justify-content: center; margin-bottom: 5px"
>
<!-- :file-list="fileList" -->
<el-upload
class="upload-demo"
action="#"
drag
:on-change="uploadFile"
:on-remove="removeFile"
:show-file-list="true"
:auto-upload="false"
ref="uploadfiles"
:multiple="true"
:limit="10"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
<!-- <div class="el-upload__tip" slot="tip">
只能上传jpg/png文件,且不超过500kb
</div> -->
</el-upload>
</div>
<div
style="width: 72%; margin: 4px auto"
v-for="(item, index) in successList"
:key="index"
>
<div v-if="!item.md5Name" class="cont">
<div class="ball">
<p>正在为 {{ item.fileName }} 计算md5值... {{ item.md5Speed }}%</p>
<div class="ball1"></div>
<div class="ball2"></div>
<div class="ball3"></div>
</div>
<!-- <span>正在为传输 {{ item.fileName }} 做准备中...</span> -->
</div>
<div v-if="item.md5Name" style="position: relative">
<el-progress
:text-inside="true"
:stroke-width="18"
:percentage="item.progress"
></el-progress>
<span
v-if="item.progress != 100"
style="font-size: 14px; float: right"
>上传速度: {{ item.speed }} M / s
</span>
<span
v-if="item.progress == 100"
style="font-size: 14px; float: right"
>上传速度: 0 M / s
</span>
<span style="font-size: 14px">{{ item.fileName }}</span>
<!-- style="position: absolute; right: -120px; top: -2px" -->
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import SparkMD5 from 'spark-md5'
import moment from 'moment'
const chunkSize = 10 * 1024 * 1024 //定义分片的大小 暂定为10M,方便测试
// const controller = new AbortController()
// const signal = controller.signal
import {
storageUploadExist,
storageUploadinitTask,
storageUploadfier,
storageUploadmerge,
storageUploadGet,
chunkNumUpload
} from '@/api/fileUpload.js'
import { deepCopy, RequestQueue } from '@/utils/utils'
// let token = localStorage.getItem('tokenStr')
// let account = localStorage.getItem('userAccount')
export default {
name: 'fileUpload',
components: {},
props: {},
data() {
return {
dataForm: {
fileName: '',
creator: '',
testPrincipalList: [],
testPrincipal: ''
},
fileDataList: [],
dataList: [],
testReportView: '',
currentPage: 1,
pageSize: 10,
totalCount: 0,
visible: false,
dataListLoading: false,
fileDialogVisible: false,
fileList: [],
loadingFile: false,
progress: 0,
showProgress: false,
successList: [],
totalList: [],
fileMD5: '',
progressList: [],
resetList: [],
listres: [],
resultList: [],
errorList: [],
cancelList: [],
repeatList: [],
requestList: [],
time: '',
lastTime: ''
}
},
computed: {
tableMaxHeight() {
return window.innerHeight - 200 + 'px'
}
},
watch: {
successList: {
handler(newVal) {
if (newVal.length) {
this.showProgress = true
newVal.forEach((item) => {
this.totalList.forEach((ele) => {
if (item.md5Name === ele.fileName) {
if (!ele.total.length) {
item.progress = 100
// item.speed = 0
} else {
item.progress = Math.floor(
(item.sucList.length / ele.total.length) * 100
)
}
if (item.sucList.length === ele.total.length) {
// console.log('监听合并上传')
this.resetList.push(item.md5Name)
this.listres = [...new Set(this.resetList)]
this.mergeFile(
this.listres[this.listres.length - 1],
item.fileName
)
}
}
})
})
}
},
deep: true
},
// 监听分片失败上传,进行对失败分片的重新请求
repeatList: {
async handler(newVal) {
let count = 0
newVal.forEach((item, index) => {
if (
item.fileMD5 === newVal[newVal.length - 1].fileMD5 &&
item.num === newVal[newVal.length - 1].num
) {
count += 1
}
})
if (count < 10 && newVal.length) {
let fileMd5 = newVal[newVal.length - 1].fileMd5
let num = newVal[newVal.length - 1].num
let _chunkFile = newVal[newVal.length - 1]._chunkFile
// let tokenStr = newVal[newVal.length - 1].tokenStr
// let userAccount = newVal[newVal.length - 1].userAccount
let fileName = newVal[newVal.length - 1].fileName
let totalLength = newVal[newVal.length - 1].totalLength
// const startTime = performance.now() // 记录开始时间
this.uploadChunkFile(
fileMd5,
num,
_chunkFile,
// tokenStr,
// userAccount,
fileName,
totalLength
// startTime
)
} else {
let flag = false
let fileName = newVal[newVal.length - 1].fileName
flag = this.errorList.some((item) => item === fileName)
if (!flag) {
this.errorList.push(fileName)
}
}
},
deep: true
}
},
created() {
// this.getFileUploadList(true)
this.getAllUser()
// console.log(this.$route.query.fileName, 111)
},
activated() {
if (this.$route.query.fileName) {
this.dataForm.fileName = this.$route.query.fileName
this.getFileUploadList(true)
} else {
this.reset()
}
},
methods: {
reset() {
this.dataForm.fileName = ''
this.dataForm.creator = ''
this.getFileUploadList(true)
},
// 获取文件列表
getFileUploadList(flag) {
this.dataListLoading = true
if (flag) {
this.currentPage = 1
this.pageSize = 10
}
const form = {
fileName: this.dataForm.fileName,
creator: this.dataForm.creator,
currentPage: this.currentPage,
pageSize: this.pageSize
}
storageUploadGet(form)
.then(({ data }) => {
if (data && data.message.code === 200) {
this.fileDataList = data.result.uploadTaskVos
this.totalCount = data.result.totalCount
} else if (data && data.message.code === 401) {
this.$router.push({ name: 'login' })
} else {
this.dataList = []
this.totalCount = 0
}
this.dataListLoading = false
})
.catch((err) => {
console.log(err)
this.$message.error('获取列表失败!')
})
},
getAllUser() {
this.$http({
url: this.$http.adornUrl('/user/get'),
method: 'get'
}).then(({ data }) => {
if (data && data.message.code === 200) {
this.dataForm.testPrincipalList = data.result
} else if (data && data.message.code === 401) {
this.$router.push({ name: 'login' })
} else {
this.dataForm.testPrincipalList = []
}
})
},
formatTimeStampCreate(row) {
return moment(row.createTime).format('YYYY-MM-DD HH:mm:ss')
},
sizeChangeHandle(val) {
this.pageSize = val
this.currentPage = 1
this.getFileUploadList(false)
},
currentChangeHandle(val) {
this.currentPage = val
this.getFileUploadList(false)
},
/**
* 上传文件
*/
async uploadFile(File) {
// console.log(File, 909)
this.loadingFile = true
let self = this
//获取用户选择的文件
const file = File.raw
this.currentFile = file
//文件大小(大于100m再分片哦,否则直接走普通文件上传的逻辑就可以了,这里只实现分片上传逻辑)
const fileSize = File.size
// 放入文件列表
if (this.fileList.length) {
const flag = this.fileList.some((item) => item.name === File.name)
if (!flag) {
this.fileList.push({ name: File.name })
}
} else {
this.fileList.push({ name: File.name })
}
// return
// 可以设置大于多少兆可以分片上传,否则走普通上传
// if (fileSize <= chunkSize) {
// console.log('上传的文件大于20m才能分片上传')
// }
//计算当前选择文件需要的分片数量
const chunkCount = Math.ceil(fileSize / chunkSize)
console.log(
'文件大小:',
File.size / 1024 / 1024 + 'Mb',
'分片数:',
chunkCount
)
self.successList.push({
fileName: File.name,
md5Name: '',
sucList: [],
progress: 0,
speed: 0,
md5Speed: 0
// time: '',
// totalNum: chunkCount
})
const startTime = performance.now() // 记录开始时间
//获取文件md5
const fileMd5 = await this.getFileMd5(file, chunkCount, self.successList)
const endTime = performance.now() // 记录结束时间
const duration = (endTime - startTime) / 1000 // 计算时间差,单位为秒
// console.log(duration, '获取md5的时间')
self.fileMD5 = fileMd5
// console.log('文件md5:', fileMd5)
// console.log('向后端请求本次分片上传初始化')
const initUploadParams = {
identifier: fileMd5, //文件的md5
fileName: File.name, //文件名
totalSize: fileSize, //文件大小
// totalChunks: chunkCount //分片的总数量
chunkSize: chunkSize, //分片大小
creator: localStorage.userAccount
}
// 调用后端检查文件上传情况接口
this.time = performance.now()
// return
// localStorage.setItem('tokenStr', token)
// localStorage.setItem('userAccount', account)
storageUploadExist(fileMd5)
.then(async ({ data }) => {
self.showProgress = true
if (data && data.message.code === 200) {
//分片上传成功直接秒传
if (data.result && data.result.finished === true) {
// 所有分片上传成功且合并成功,直接秒传
// console.log('当前文件上传情况:秒传')
self.loadingFile = false
this.$message.success(`${File.name}已经上传成功,无需再上传`)
self.totalList.push({ fileName: fileMd5, total: [] })
self.successList.forEach((item) => {
if (item.fileName === File.name) {
item.md5Name = fileMd5
item.sucList = [1]
}
})
// self.fileList = []
// self.$refs.uploadFile.clearFiles()
return false
}
if (!data.result) {
const { data } = await storageUploadinitTask(initUploadParams)
if (data && data.message.code === 200) {
// self.$message.success('初始化上传任务成功!')
} else if (data && data.message.code === 401) {
self.$router.push({ name: 'login' })
}
}
let list = []
let totalList = []
let sucList = []
for (let i = 1; i <= chunkCount; i++) {
list.push(i)
}
for (let i = 1; i <= chunkCount; i++) {
totalList.push(i)
}
if (data.result && !data.result.finished) {
// 获取后端返回的已上传分片数字的数组
let uploaded = data.result.taskRecord.exitPartList.map((item) => {
return item.partNumber
})
uploaded.forEach((item) => {
sucList.push(1)
list.forEach((ele, index) => {
if (item == ele) {
list.splice(index, 1)
}
})
})
}
// self.noUpload.push({ fileName: fileMd5, noUpload: list })
self.totalList.push({ fileName: fileMd5, total: totalList })
// let tokenStr = localStorage.getItem('tokenStr')
// let userAccount = localStorage.getItem('userAccount')
self.successList.forEach((item) => {
if (item.fileName === File.name) {
item.md5Name = fileMd5
item.sucList = sucList
}
})
// self.successList.push({
// fileName: File.name,
// md5Name: fileMd5,
// sucList: [],
// progress: 0
// })
const requestQueue = new RequestQueue(4) // 最多同时进行 4 个请求
for (let i = 0; i < list.length; i++) {
//分片开始位置
let start = i * chunkSize
//分片结束位置
let end = Math.min(fileSize, start + chunkSize)
//取文件指定范围内的byte,从而得到分片数据
// console.log(File, '0000')
let _chunkFile = File.raw.slice(start, end)
// console.log(_chunkFile)
// console.log('开始上传第' + i + '个分片')
// 通过await实现顺序上传
// await this.getMethods(formdata)
// 并行上传
// const startTime = performance.now() // 记录开始时间
requestQueue.push(() =>
self.uploadChunkFile(
fileMd5,
list[i],
_chunkFile,
// tokenStr,
// userAccount,
File.name,
// startTime,
chunkCount
)
)
}
// await self.uploadLoading(File, noUpload, fileSize, fileMd5)
// 文件上传完毕,请求后端合并文件并传入参数
// 定义分片开始上传的序号
// 由于是顺序上传,可以判断后端返回的分片数组的长度,为0则说明文件是第一次上传,分片开始序号从0开始
// 如果分片数组的长度不为0,我们取最后一个序号作为开始序号
// let num = uploaded.length == 0 ? 0 : uploaded[uploaded.length - 1]
// console.log(num, '分片开始序号')
// 当前为顺序上传方式,若要测试并发上传,请将103 行 await 修饰符删除即可
// 循环调用上传
} else if (data && data.message.code === 401) {
this.$router.push({ name: 'login' })
}
})
.catch((err) => {
this.$message.error('上传失败!')
})
},
uploadChunkFile(
fileMd5,
num,
_chunkFile,
// tokenStr,
// userAccount,
fileName,
// startTime,
totalLength
) {
const _that = this
const startTime = performance.now() // 记录开始时间
return new Promise((resolve, reject) => {
_that
.getMethods(fileMd5, num)
.then(async (url) => {
// localStorage.removeItem('tokenStr')
// localStorage.removeItem('userAccount')
chunkNumUpload(url, _chunkFile)
.then((data) => {
// console.log(data, '分片成功')
if (data.status == 200) {
const endTime = performance.now() // 记录结束时间
const duration = (endTime - startTime) / 1000 // 计算时间差,单位为秒
// let leftTime = ''
// if ((totalLength - num) * duration > 60) {
// leftTime =
// (((totalLength - num) * duration) / 60).toFixed(1) + '分'
// } else {
// leftTime =
// ((totalLength - num) * duration).toFixed(1) + '秒'
// }
const fileSpeed = (10 / duration).toFixed(1) * 4
_that.successList.forEach((ele) => {
if (ele.md5Name === fileMd5) {
ele.sucList.push(1)
ele.speed = fileSpeed
}
})
}
resolve()
})
.catch((err) => {
_that.repeatList.push({
fileMd5,
num,
_chunkFile,
// tokenStr,
// userAccount,
fileName,
totalLength
})
reject()
})
})
.catch((err) => {
console.log(err, '预签名错误')
})
})
},
/**
* 上传文件方法
* @param formdata 上传文件的参数
*/
getMethods(identifier, partNumber) {
return new Promise((resolve, reject) => {
storageUploadfier(identifier, partNumber).then(({ data }) => {
if (data && data.message.code === 200) {
resolve(data.result)
}
})
})
},
/**
* 获取文件MD5
* @param file
* @returns {Promise<unknown>}
*/
getFileMd5(file, chunkCount, list) {
return new Promise((resolve, reject) => {
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice
let chunks = chunkCount
let currentChunk = 0
let spark = new SparkMD5.ArrayBuffer()
let fileReader = new FileReader()
let time = new Date().getTime()
fileReader.onload = function (e) {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
// console.log(
// `第${currentChunk}分片解析完成, 开始第${
// currentChunk + 1
// } / ${chunks}分片解析`
// )
list.forEach((item) => {
if (item.fileName === file.name) {
item.md5Speed = Math.ceil((currentChunk / chunks) * 100)
}
})
loadNext()
} else {
let md5 = spark.end()
console.log(
`MD5计算完成:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${
file.size
} 用时:${new Date().getTime() - time} ms`
)
spark.destroy() //释放缓存
resolve(md5)
}
}
fileReader.onerror = function (e) {
reject(e)
}
function loadNext() {
let start = currentChunk * chunkSize
let end = start + chunkSize
if (end > file.size) {
end = file.size
}
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
})
},
/**
* 请求后端合并文件
* @param fileMd5 文件md5
* @param fileName 文件名称
* @param count 文件分片总数
*/
async mergeFile(fileMd5, fileName) {
// console.log('开始请求后端合并文件')
const flag = this.resultList.some((item) => item === fileMd5)
if (flag) {
return
}
this.resultList.push(fileMd5)
try {
const { data } = await storageUploadmerge(fileMd5)
if (data && data.message.code === 200) {
this.$message.success(`${fileName}文件上传成功`)
this.loadingFile = false
this.getFileUploadList(true)
this.lastTime = performance.now()
console.log((this.lastTime - this.time) / 1000, 'timeLast')
// this.$refs.uploadfile.clearFiles()
} else {
this.loadingFile = false
this.$message.error(data.message)
}
} catch (err) {
this.$message.error(`${fileName}文件上传失败`)
this.$message.error(err)
}
},
removeFile(file) {
this.cancelList.push(file.name)
const index = this.fileList.findIndex((item) => item.name === file.name)
this.fileList.splice(index, 1)
this.successList.forEach((item, index) => {
if (item.fileName === file.name) {
this.successList.splice(index, 1)
}
})
},
// 上传失败的文件自动重新请求上传
dialogClose() {
this.fileList = []
this.$refs.uploadfiles.clearFiles()
this.totalList = []
this.successList = []
this.cancelList = []
this.errorList = []
}
}
}
</script>
<style lang="scss" scoped>
@import '@/views/tap.scss';
.ball {
/*设置动画盒子的整体样式*/
width: 90%; /*设置整体大小*/
height: 90px;
text-align: center; /*设置对齐方式*/
// color: #fff; /*设置文字颜色*/
// background: rgba(0, 0, 0, 0.5); /*设置背景颜色*/
margin: 20px auto;
}
.ball > p {
/*设置加载的提示文字的样式*/
padding: 20px 0;
}
.ball > div {
/*设置动画中三个小球的样式*/
width: 18px; /*设置大小*/
height: 18px;
background: #1abc9c; /*设置背景颜色*/
border-radius: 100%; /*设置圆角边框*/
display: inline-block; /*设置其显示方式*/
animation: move 1.4s infinite ease-in-out both; /*添加动画*/
}
.ball .ball1 {
/*设置第一个小球的动画延迟*/
animation-delay: 0.16s;
}
.ball .ball2 {
/*设置第二个小球的动画延迟*/
animation-delay: 0.32s;
}
.ball .ball3 {
/*设置第二个小球的动画延迟*/
animation-delay: 0.48s;
}
@keyframes move {
/*创建动画*/
0% {
transform: scale(0);
}
40% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
</style>
这个是对请求并发数进行控制的js代码(导入到vue文件中使用即可)
在这个的js代码中,我们创建了一个 RequestQueue 类,它接受一个参数 maxConcurrentRequests,
表示最多同时进行的请求数量。我们使用一个数组 pendingRequests 来存储等待处理的请求,
使用一个计数器 activeRequests 来记录当前正在进行的请求数量。
当我们调用 push 方法添加一个请求时,它会将请求添加到 pendingRequests 数组中,
并立即调用 processQueue 方法来处理请求队列。processQueue 方法会不断地从
pendingRequests 数组中取出请求,直到达到最大并发数或者队列为空为止。
对于每个请求,我们使用 Promise 来处理异步操作,并在请求完成后递减 activeRequests
计数器,然后再次调用 processQueue 方法来处理下一个请求。
// 并发请求数量控制
export class RequestQueue {
constructor(maxConcurrentRequests) {
this.maxConcurrentRequests = maxConcurrentRequests
this.pendingRequests = []
this.activeRequests = 0
}
push(request) {
this.pendingRequests.push(request)
this.processQueue()
}
processQueue() {
while (
this.pendingRequests.length > 0 &&
this.activeRequests < this.maxConcurrentRequests
) {
const request = this.pendingRequests.shift()
this.activeRequests++
// setTimeout(() => {
request().then(() => {
this.activeRequests--
this.processQueue()
})
// }, 1000)
}
}
}