# 文件上传(文件拖拽、文件夹上传)
<template>
<div>
<el-row>
<el-col :span="24" class="manage-area-title">
<h2>备份</h2>
</el-col>
</el-row>
<!-- <BreadCrumbs /> -->
<div v-loading="getHostLoading" class="page_content_wrap">
<el-form ref="form" class="form" :model="form" style="width: 40%;" label-width="150px" :rules="rules">
<el-form-item label="hostName">
<el-input v-model="form.hostName" placeholder="请输入hostName" readonly />
</el-form-item>
<el-form-item label="endpoint" prop="endpoint">
<el-input v-model="form.endpoint" clearable placeholder="请输入endpoint" />
</el-form-item>
<el-form-item label="Access Key" prop="accessKeyId">
<el-input v-model="form.accessKeyId" clearable placeholder="请输入Access Key" />
</el-form-item>
<el-form-item label="Secret Key" prop="secretAccessKey">
<el-input v-model="form.secretAccessKey" type="password" show-password clearable placeholder="请输入Secret Key" style="width: 80%;" />
<el-button style="position: absolute; right: 0;top:8px" @click="getBucketList">连接</el-button>
</el-form-item>
<el-form-item v-if="bucketList&&bucketList.length" label="bucket" prop="Bucket">
<el-select v-model="form.Bucket" clearable placeholder="请选择一个bucket" filterable>
<el-option v-for="x in bucketList" :key="x" :value="x" :label="x" />
</el-select>
</el-form-item>
<el-form-item>
<el-button class="golden" @click="validateBucket()">备份</el-button>
<el-button class="blue" @click="resetForm('form')">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-dialog title="备份" :visible.sync="dirFlag" width="65%" destroy-on-close>
<el-form ref="createForm" :model="createForm" size="mini" label-width="150px" style="padding:0 5%">
<!-- :before-upload="validateFileRule" -->
<!-- :accept=",,拼接可接受文件类型 image/* 任意图片文件" -->
<!-- :http-request="uploadFile" 覆盖原生action上传方法-->
<!-- var formData = new FormData(); // 用FormData存放上传文件 -->
<!-- formData.append('paramsName','file') -->
<el-row>
<el-col :span="3">
<el-upload
ref="uploadFile"
action="#"
multiple
:show-file-list="false"
:http-request="handleRequest"
:before-upload="handleSizeValidate"
:on-change="changeFile"
>
<!-- <el-button size="small" class="golden" @click="postFolder('file')">上传文件</el-button> -->
<el-button size="small" class="golden" @click="postFolder('folder')">上传文件夹</el-button>
</el-upload>
</el-col>
<el-col :span="3">
<el-button class="blue" @click="cleafFile">清空</el-button>
</el-col>
</el-row>
<!-- <input type="file" id="upload" ref="inputer" name="file" multiple /> -->
<div
draggable="true"
class="drag tableBox"
@dragover="(e)=>e.preventDefault()"
@drop="onDrop"
>
<div v-show="!fileListArr.length" class="el-upload__text">
<i class="el-icon-upload" style="margin-right: 6px" />拖拽文件夹到此处
<!-- <el-button type="text" @click="addFiles">添加文件</el-button> -->
</div>
<div v-show="!fileListArr.length" class="el-upload__text">
<!-- 文件上传数量不能超过100个,总大小不超过5GB -->
单个文件大小不超过50GB
</div>
<el-table v-show="fileListArr.length" :data="fileListArr.slice((currentPage - 1) * pageSize, currentPage * pageSize)">
<el-table-column label="对象key" prop="name" min-width="120px" />
<el-table-column label="目录" min-width="120px">
<template slot-scope="scope">
{{ (scope.row.webkitRelativePath ? form.hostName +'/'+ scope.row.webkitRelativePath : form.hostName +'/'+ scope.row.relativePath) | renderPath }}
</template>
</el-table-column>
<el-table-column label="类型" width="180px">
<template slot-scope="scope">
{{ scope.row.type }}
</template>
</el-table-column>
<el-table-column label="大小" width="120px">
<template slot-scope="scope">
{{ byteConvert(scope.row.size) }}
</template>
</el-table-column>
<el-table-column label="移除" width="100px">
<template slot-scope="scope">
<svg class="icon" aria-hidden="true" @click="removeItem(scope)">
<use xlink:href="#icon-trash" />
</svg>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="fileListArr.length"
:current-page="currentPage"
:page-sizes="[5, 10, 50, 100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="fileListArr.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button class="golden" :disabled="fileListArr.length==0" @click="confirmPut()">{{ $trans('button.confirm') }}</el-button>
<el-button @click="dirFlag = false;">{{ $trans('button.cancel') }}</el-button>
</div>
</el-dialog></div>
</template>
<script>
import {
upload,
hostname
} from '@/api/agent'
const AWS = require('aws-sdk')
// const FileSaver = require('file-saver')
import moment from 'moment'
export default {
filters: {
renderPath (path) {
path = String(path) || ''
const lastIndex = path.lastIndexOf('/')
return path.substr(0, lastIndex)
}
},
data () {
return {
createForm: {
folderName: ''
},
dirFlag: false,
form: {
hostName: '',
accessKeyId: '',
secretAccessKey: '',
// endpoint: 'http://10.0.2.154:8300',
endpoint: '',
path: '',
Bucket: ''
},
bucketList: [],
pageSize: 10,
currentPage: 1,
fileList: [],
fileListArr: [],
executeTime: '',
rules: {
// hostName: { required: true, message: '请输入hostName' },
accessKeyId: { required: true, message: '请输入accessKeyId' },
secretAccessKey: { required: true, message: '请输入secretAccessKey' },
endpoint: { required: true, message: '请输入endpoint' },
Bucket: { required: true, message: '请选择bucket' }
},
S3: null,
noBucket: false,
getHostLoading: false,
uploadSizeLimt: 1024 ** 4, // 上传文件大小限制 1T
uploadPartSize: 1024 * 1024 * 5, // 分段大小&&文件启用分段大小
sizeError: [],
enableReUpload: true,
readFileList: []
}
},
watch: {
dirFlag (val) {
if (val) {
this.$nextTick(() => {
this.$refs['uploadFile'].clearFiles()
})
this.fileListArr = []
} else {
this.releaseDisable()
this.doClearFileLog()
}
}
},
mounted () {
// get HostName、默认传递
this.init()
// setTimeout(() => {
// this.getBucketList()
// })
},
methods: {
handlePutPath (file) {
const {
webkitRelativePath,
relativePath
} = file
return webkitRelativePath || relativePath
},
cleafFile () {
this.fileListArr = [] // 清除表格展示
this.$refs['uploadFile'].clearFiles() // 清除组件FileList
},
handleRequest (val) {
// 无功能、为自定义请求触发 beforeUpload校验文件
// console.log(val, '123')
},
handleSizeValidate (file) {
const size = file.size
if (size > this.uploadSizeLimt) {
this.sizeError.push(file.name)
return false
} else {
// put到上传列表
// 此处去重、判断Key和目录同时一致、就移除之前的旧文件、替代新文件(暂无提示)
const isExist = this.fileListArr.findIndex(x => x.name === file.name && x.webkitRelativePath === file.webkitRelativePath)
if (isExist > -1) {
this.fileListArr.splice(isExist, 1)
}
this.fileListArr.push(file)
}
},
init () {
// const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
// this.form.accessKeyId = accessKeyId
// this.form.endpoint = endpoint
// this.form.hostName = 'Dc'
this.getHostLoading = true
hostname().then(res => {
this.form.hostName = res.data || ''
}).finally(() => {
this.getHostLoading = false
const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
this.form.accessKeyId = accessKeyId
this.form.endpoint = endpoint
})
},
removeItem (row) {
const index = this.fileListArr.findIndex(x => x.uid === row.row.uid)
this.fileListArr.splice(index, 1)
// 最后一页删除后、切到1
if (this.fileListArr.length / this.pageSize <= 1) {
this.currentPage = 1
} else if (Math.ceil(this.fileListArr.length / this.pageSize) < this.currentPage) {
this.currentPage = this.currentPage - 1
}
},
handleSizeChange (val) {
this.pageSize = val
},
handleCurrentChange (val) {
this.currentPage = val
},
changeFile (file, fileList) {
// console.log(file, fileList, 12333)
// this.fileListArr = fileList
// this.total = this.fileListArr.length
// 本地记录分段上传文件 abortMultiple
return
var blob = file.raw
// 测试大文件分片
const fileSize = file.raw.size
const chunkSize = this.uploadPartSize
const chunks = Math.ceil(fileSize / chunkSize)
const {
Bucket,
hostName,
endpoint,
accessKeyId
} = this.form
const Key = hostName + '/' + file.raw.webkitRelativePath
this.S3.createMultipartUpload({
Bucket,
Key
}, (err, data) => {
if (err) {
console.error('Error creating multipart upload:', err)
return
} else {
const keyList = JSON.parse(localStorage.getItem('keyList')) || []
const UploadId = data.UploadId
keyList.push({
Bucket,
Key,
UploadId: data.UploadId,
accessKeyId,
endpoint
})
localStorage.setItem('keyList', JSON.stringify(keyList))
const multiplePart = []
// uploadPart
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const body = blob.slice(start, end)
const reqParams = {
PartNumber: chunkCount + 1,
Body: body,
Bucket,
Key,
UploadId: data.UploadId
}
const p = new Promise((res, rej) => {
this.S3.uploadPart(reqParams
, (err, data) => {
if (err) rej(err)
else res(data)
})
})
multiplePart.push(p)
}
// uploadPart End
Promise.allSettled(multiplePart).then(listPartFin => {
console.log(listPartFin, 'finish')
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
var params = {
Bucket,
Key: hostName + '/' + file.raw.webkitRelativePath,
UploadId: data.UploadId
}
// this.S3.listMultipartUploads({ Bucket }, (err, data) => {
// console.log(err, data, '123')
// })
this.S3.listParts(params, (err, res) => {
if (err) return
else {
const Parts = res.Parts.map(x => {
return {
PartNumber: x.PartNumber,
ETag: x.ETag
}
}).sort((a, b) => a.PartNumber - b.PartNumber)
// finish
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: data.UploadId,
MultipartUpload: { Parts }
}, (err, data) => {
// 测试取消分段上传
console.log(err, data)
})
}
})
} else {
// handle reUploadPart
}
})
}
})
},
handleMultUpload (fileArr) {
const chunkSize = this.uploadPartSize
const {
Bucket,
hostName,
accessKeyId,
endpoint
} = this.form
const arr = localStorage.getItem('fileList')
if (!arr) localStorage.setItem('fileList', '[]')
this.readFileList = JSON.parse(arr || '[]')
// return PromiseMultiple
return fileArr.map(file => {
const fileSize = file.size
const chunks = Math.ceil(fileSize / chunkSize)
const Key = hostName + '/' + this.handlePutPath(file)
file['Key'] = Key
return new Promise((resolve, rejected) => {
// 检测文件检测失败重传
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
const isExistReUploadPart = this.readFileList.find(x => {
return x.Key === Key && x.Bucket === Bucket && x.accessKeyId === accessKeyId && x.endpoint === endpoint && x.reUpload
})
if (isExistReUploadPart) {
// 存在切片、在有效期且开启续传
const {
parts
} = isExistReUploadPart
const multiplePart = []
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const body = file.slice(start, end)
const reqParams = {
PartNumber: chunkCount + 1,
Body: body,
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId
}
const jumpPass = parts.some(x => x.PartNumber === chunkCount + 1)
if (jumpPass) continue
const p = new Promise((res, rej) => {
this.S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej(uploadPartErr)
else res(uploadPartData)
})
})
multiplePart.push(p)
}
// afterUploadPart
Promise.allSettled(multiplePart).then(listPartFin => {
// console.log(listPartFin, 'finish')
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
var params = {
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId
}
this.S3.listParts(params, (partErr, partRes) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (partErr) {
rejected({
err: partErr,
file,
startTime,
endTime
})
} else {
const Parts = partRes.Parts.map(x => {
return {
PartNumber: x.PartNumber,
ETag: x.ETag
}
}).sort((a, b) => a.PartNumber - b.PartNumber)
// finish
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
resolve(compErrData)
}
// console.log(compErr, compErrData)
})
}
})
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const err = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: err,
file,
startTime,
endTime
})
// handle reUploadPart
}
})
} else {
this.S3.createMultipartUpload({
Bucket,
Key
}, (createErr, createData) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (createErr) {
// console.error('Error creating multipart upload:', createErr)
console.log(createErr, 'listPartFin')
rejected({ err: createErr, file, startTime, endTime })
} else {
const multiplePart = []
// writeAbortLog
this.readFileList.push({
accessKeyId,
endpoint,
Bucket,
Key,
UploadId: createData.UploadId
})
// 此处同步的所以有问题了vuex先缓存一下
// endWrite 此处记录及最终Promise处处理完成判断、清楚记录或执行abortMultiple
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const body = file.slice(start, end)
const reqParams = {
PartNumber: chunkCount + 1,
Body: body,
Bucket,
Key,
UploadId: createData.UploadId
}
const p = new Promise((res, rej) => {
// if (chunkCount > chunks - 2) {
// reqParams.Bucket = '666'
// }
this.S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej(uploadPartErr)
else res(uploadPartData)
})
})
multiplePart.push(p)
}
// uploadPart End
Promise.allSettled(multiplePart).then(listPartFin => {
// console.log(listPartFin, 'finish')
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
var params = {
Bucket,
Key,
UploadId: createData.UploadId
}
this.S3.listParts(params, (partErr, partRes) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (partErr) {
rejected({
err: partErr,
file,
startTime,
endTime
})
} else {
const Parts = partRes.Parts.map(x => {
return {
PartNumber: x.PartNumber,
ETag: x.ETag
}
}).sort((a, b) => a.PartNumber - b.PartNumber)
// finish
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: createData.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
resolve(compErrData)
}
// console.log(compErr, compErrData)
})
}
})
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const err = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: err,
file,
startTime,
endTime
})
// handle reUploadPart
}
})
}
})
}
})
})
},
postFolder (type) {
if (type === 'file') {
$('.el-upload__input')[0].webkitdirectory = false
} else {
$('.el-upload__input')[0].webkitdirectory = true
}
},
releaseDisable () {
document.oncontextmenu = function () { }
document.onkeydown = function (event) {}
window.onbeforeunload = function () {}
},
resetForm (formName) {
if (this.$refs[formName] != undefined) {
this.$refs[formName].resetFields()
}
},
onDrop (e) {
e.preventDefault()
const dataTransfer = e.dataTransfer
if (
dataTransfer.items &&
dataTransfer.items[0] &&
dataTransfer.items[0].webkitGetAsEntry
) {
this.webkitReadDataTransfer(dataTransfer)
}
},
webkitReadDataTransfer (dataTransfer) {
let fileNum = dataTransfer.items.length
const files = []
this.loading = true
// 递减计数,当fileNum为0,说明读取文件完毕
const decrement = () => {
if (--fileNum === 0) {
this.handleFiles(files)
this.loading = false
}
}
// 递归读取文件方法
const readDirectory = (reader) => {
// readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。
reader.readEntries((entries) => {
if (entries.length) {
fileNum += entries.length
entries.forEach((entry) => {
if (entry.isFile) {
entry.file((file) => {
readFiles(file, entry.fullPath)
}, readError)
} else if (entry.isDirectory) {
readDirectory(entry.createReader())
}
})
readDirectory(reader)
} else {
decrement()
}
}, readError)
}
// 文件对象
const items = dataTransfer.items
// 拖拽文件遍历读取
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry()
if (!entry) {
decrement()
return
}
if (entry.isFile) {
// 读取单个文件
return
// readFiles(items[i].getAsFile(), entry.fullPath, 'file')
} else {
// entry.createReader() 读取目录。
readDirectory(entry.createReader())
}
}
function readFiles (file, fullPath) {
file.relativePath = fullPath.substring(1)
files.push(file)
decrement()
}
function readError (fileError) {
throw fileError
}
},
handleFiles (files) {
// 按文件名称去存储列表,考虑到批量拖拽不会有同名文件出现
const dirObj = {}
// console.log(files, '1233')
// return
files.forEach((item) => {
// relativePath 和 name 一致表示上传的为文件,不一致为文件夹
// 文件直接放入table表格中
// 仍需考虑去重问题
const isExist = this.fileListArr.findIndex(x => x.name === item.name && x.relativePath === item.relativePath)
if (isExist > -1) {
this.fileListArr.splice(isExist, 1)
}
this.fileListArr.push(item)
// if (item.relativePath === item.name) {
// this.tableData.push({
// name: item.name,
// filesList: [item.file],
// isFolder: false,
// size: item.size
// })
// }
// // 文件夹,需要处理后放在表格中
// if (item.relativePath !== item.name) {
// const filderName = item.relativePath.split('/')[0]
// if (dirObj[filderName]) {
// // 放入文件夹下的列表内
// const dirList = dirObj[filderName].filesList || []
// dirList.push(item)
// dirObj[filderName].filesList = dirList
// // 统计文件大小
// const dirSize = dirObj[filderName].size
// dirObj[filderName].size = dirSize ? dirSize + item.size : item.size
// } else {
// dirObj[filderName] = {
// filesList: [item],
// size: item.size
// }
// }
// }
})
// 放入tableData
Object.keys(dirObj).forEach((key) => {
this.tableData.push({
name: key,
filesList: dirObj[key].filesList,
isFolder: true,
size: dirObj[key].size
})
})
},
validateBucket () {
if (!this.form.Bucket) {
if (this.bucketList.length) {
this.$notify({
type: 'error',
title: '请选择一个bucket'
})
} else {
if (this.noBucket) {
this.$notify({
type: '无bucket可用,请先创建bucket'
})
} else {
this.$notify({
type: 'error',
title: '请点击“连接”按钮,设置bucket'
})
}
}
} else {
this.$refs['form'].validate((valid) => {
if (valid) {
document.onkeydown = function (event) {
var e = event || window.event || arguments.callee.caller.arguments[0]
if (e && e.keyCode == 116) {
return false
}
}
window.onbeforeunload = function (e) {
// 兼容ie
// 触发条件 产生交互、当前不支持自定义文字
e = e || window.event
if (e) e.returnValue = 'none'
return 'none'
}
document.oncontextmenu = function () { return false }
this.dirFlag = true
const { endpoint, accessKeyId } = this.form
localStorage.setItem('form', JSON.stringify({
endpoint,
accessKeyId
}))
}
})
}
},
getBucketList () {
const {
accessKeyId,
secretAccessKey,
endpoint
} = this.form
if (!endpoint) {
this.$notify({
type: 'error',
title: '请输入endpoint'
})
return
}
if (!accessKeyId) {
this.$notify({
type: 'error',
title: '请输入Access Key'
})
return
}
if (!secretAccessKey) {
this.$notify({
type: 'error',
title: '请输入Secret Key'
})
return
}
this.S3 = new AWS.S3({
accessKeyId,
secretAccessKey,
endpoint,
region: 'EastChain-1',
s3ForcePathStyle: true
})
this.S3.listBuckets((err, data) => {
if (err) {
// console.dir(err)
// console.log('%c 123', 'color:red;font-size:20px')
// "NetworkingError"
let title = ''
let message = ''
if (err.code === 'AccessDenied') {
title = '连接S3失败'
message = '请检查ak/sk是否输入正确'
} else if (Number(err.code) === 12) {
title = '网络异常'
message = '请检查endpoint是否正确'
} else if (err.code === 'NetworkingError') {
title = '网络异常'
message = '请检查endpoint是否正确,或稍后再试'
} else {
title = '连接S3失败'
message = this.$trans(err.message || '')
}
// console.dir(err, 'err')
this.$notify({
type: 'error',
title,
message,
showClose: false,
customClass: 'errorTip'
})
this.noBucket = false
this.bucketList = []
this.form.Bucket = ''
} else {
this.bucketList = (data.Buckets || []).map(x => x.Name)
if (!this.bucketList.length) {
this.$notify({
type: 'error',
title: '无bucket可用,请先创建bucket'
})
this.noBucket = true
} else {
this.form.Bucket = this.bucketList[0]
this.$notify({
type: 'success',
title: '连接S3成功'
})
// 确保断网或刷新页面导致未完成的上传记录清除
this.doClearFileLog()
}
}
})
},
doClearFileLog () {
const {
accessKeyId,
endpoint
} = this.form
const keyList = JSON.parse(JSON.stringify(this.readFileList))
const doAbortTasks = keyList.map((x, i) => {
return new Promise((resolve, rejected) => {
if (accessKeyId === x.accessKeyId && endpoint === x.endpoint) {
// 一致性确保listPart正常
const params = {
Bucket: x.Bucket,
Key: x.Key,
UploadId: x.UploadId
}
this.S3.listParts(params, (err, data) => {
// 不存在err、complete完还有part、上传大文件失败
// console.log(data, 'err', err)
if (!err) {
// doAbort
const reUpload = data.Parts && data.Parts.length > 0
// console.log(data, '1233', reUpload)
if (reUpload && this.enableReUpload) {
keyList[i].reUpload = true
keyList[i].parts = data.Parts
const expireTime = keyList[i].expireTime
if (expireTime) {
if (
expireTime < moment().valueOf()) {
this.S3.abortMultipartUpload(params, (err, data) => {
if (!err) {
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
} else {
resolve('keepReUpload')
}
} else {
keyList[i].expireTime = moment().add(15, 'day').valueOf()
resolve('reUpload')
}
// 有切片需要支持后续上传
} else {
this.S3.abortMultipartUpload(params, (err, data) => {
if (!err) {
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
}
} else {
// 此处问题、
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
} else {
rejected('notMatch')
// noThingTodo
}
})
})
Promise.allSettled(doAbortTasks).then(res => {
// localStorage.setItem('fileList', JSON.stringify(iterateArr))
// 结束清理status为删除的
const fileList = keyList.filter(x => x.delete !== true)
this.readFileList = []
localStorage.setItem('fileList', JSON.stringify(fileList))
// console.log('checkOver', keyList, localStorage.getItem('fileList'))
})
},
confirmPut () {
const {
Bucket,
hostName
} = this.form
const putObjectArr = []
const multUploadArr = []
const judgeUploadType = async () => {
this.fileListArr.forEach(x => {
if (x.size <= this.uploadPartSize) {
putObjectArr.push({
Bucket,
Key: hostName + '/' + this.handlePutPath(x),
Body: x
})
} else {
multUploadArr.push(x)
}
})
}
// 区分大文件
(async () => {
await judgeUploadType()
// startPutObject
// console.log(putObjectArr, multUploadArr)
// return
const loading = this.$loading({
lock: true,
text: '文件上传中,请勿关闭当前页面',
spinner: 'el-icon-loading',
background: 'rgba(1,1,1,.3)',
customClass: 'putLoading'
})
try {
const putObejcts = putObjectArr.map(file => {
return new Promise((res, rej) => {
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
this.S3.putObject(file, (err, data) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (err) rej({ err, file, startTime, endTime })
else res({ success: 'success', file, startTime, endTime })
})
})
})
const multipleObjects = this.handleMultUpload(multUploadArr)
Promise.allSettled(
[Promise.allSettled(putObejcts),
Promise.allSettled(multipleObjects)
]
).then(result => {
this.releaseDisable()
loading.close()
// console.log(result, 'result')
this.writeErrorLog(result)
// 上传及分段上传全部结束
})
} catch (error) {
console.log('errorOperate')
}
})()
},
writeErrorLog (result) {
const file = [...result[0].value, ...result[1].value]
const failList = file.filter(x => x.status === 'rejected')
const log = failList.reduce((pre, cur, i) => {
return pre + '结束时间:' + cur.reason.endTime + ' ' + '对象Key: ' + cur.reason.file.Key + ' ' + ' ' + '错误原因: ' + cur.reason.err.message + '\n'
}, '')
const total = file.length
const failCount = failList.length
const successCount = total - failCount
// console.log('===============', failList)
if (failCount && failCount > 0) {
upload({
log
}).then(res => {
this.$notify({
title: '上传完成',
dangerouslyUseHTMLString: true,
type: 'success',
message: `<p>
<strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
<br/> <span style="color:#d3d6d8;font-size:15px">请到备份历史查看详情</span>
</p>`
})
}).finally(() => {
this.dirFlag = false
})
} else {
this.$notify({
title: '上传完成',
dangerouslyUseHTMLString: true,
type: 'success',
message: `<p>
<strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
</p>`
})
this.dirFlag = false
}
}
}
}
</script>
<style lang="scss" scoped>
:deep(.form){
label.el-form-item__label{
margin-left: 0!important;
width: 150px!important;
}
.el-select{
width: 100%;
}
}
:deep(.el-dialog){
.icon {
cursor: pointer;
font-size: 17px;
margin: 0 18px 0 3px;
vertical-align: middle !important;
}
}
:deep(.errorTip){
background-color: aqua!important;
width: fit-content!important;
.el-notification__group{
.el-notification__content{
p{
color: #d3d6d8;
}
}
}
}
.el-icon-upload {
font-size: 19px;
margin: 0;
}
.el-upload__text {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 20px;
text-align: center;
color: #d3d6d8;
}
.addFiles {
color: #337dff;
}
.drag {
width: 100%;
margin-top: 10px;
padding: 50px 10px 50px;
border: 1px dashed #ccc;
}
.el-table{
max-height:600px;
overflow-y: auto;
}
</style>
<style>
.putLoading {
.el-loading-spinner{
margin-top: -100px!important;
}
.el-loading-spinner i{
font-size: 25px;
}
.el-loading-text{
font-size: 25px;
}
}
</style>
# 分片上传、
# 上传对象并发不同步
<template>
<div>
<el-row>
<el-col
:span="24"
class="manage-area-title"
>
<h2>备份</h2>
</el-col>
</el-row>
<!-- <BreadCrumbs /> -->
<div
v-loading="getHostLoading"
class="page_content_wrap"
>
<el-form
ref="form"
class="form"
:model="form"
style="width: 40%;"
label-width="150px"
:rules="rules"
>
<el-form-item label="hostName">
<el-input
v-model="form.hostName"
placeholder="请输入hostName"
readonly
/>
</el-form-item>
<el-form-item
label="endpoint"
prop="endpoint"
>
<el-input
v-model="form.endpoint"
clearable
placeholder="请输入endpoint"
/>
</el-form-item>
<el-form-item
label="Access Key"
prop="accessKeyId"
>
<el-input
v-model="form.accessKeyId"
clearable
placeholder="请输入Access Key"
/>
</el-form-item>
<el-form-item
label="Secret Key"
prop="secretAccessKey"
>
<el-input
v-model="form.secretAccessKey"
type="password"
show-password
clearable
placeholder="请输入Secret Key"
style="width: 80%;"
/>
<el-button
style="position: absolute; right: 0;top:8px"
@click="getBucketList"
>连接</el-button>
</el-form-item>
<el-form-item
v-if="bucketList&&bucketList.length"
label="bucket"
prop="Bucket"
>
<el-select
v-model="form.Bucket"
clearable
placeholder="请选择一个bucket"
filterable
>
<el-option
v-for="x in bucketList"
:key="x"
:value="x"
:label="x"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
class="golden"
@click="validateBucket()"
>备份</el-button>
<el-button
class="blue"
@click="resetForm('form')"
>重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- <div
id="loadChart"
style="width:400px;height:300px"
/> -->
<el-dialog
title="备份"
:visible.sync="dirFlag"
width="65%"
destroy-on-close
:close-on-press-escape="false"
:close-on-click-modal="false"
>
<el-form
ref="createForm"
:model="createForm"
size="mini"
label-width="150px"
style="padding:0 5%;position:relative"
>
<!-- :before-upload="validateFileRule" -->
<!-- :accept=",,拼接可接受文件类型 image/* 任意图片文件" -->
<!-- :http-request="uploadFile" 覆盖原生action上传方法-->
<!-- var formData = new FormData(); // 用FormData存放上传文件 -->
<!-- formData.append('paramsName','file') -->
<el-row class="uploadMenu">
<el-upload
ref="uploadFile"
action="#"
multiple
:show-file-list="false"
:http-request="handleRequest"
:before-upload="handleSizeValidate"
>
<!-- <el-button
size="small"
class="golden"
@click="postFolder('file')"
>上传文件</el-button> -->
<el-button
size="small"
class="golden"
@click="postFolder('folder')"
>上传</el-button>
</el-upload>
<el-button
class="blue"
:disabled="!fileListArr.length"
@click="cleafFile"
>清空</el-button>
</el-row>
<!-- <input type="file" id="upload" ref="inputer" name="file" multiple /> -->
<div
draggable="true"
class="drag tableBox"
:style="renderPadding"
>
<div
v-show="!fileListArr.length"
class="el-upload__text"
>
<i
class="el-icon-upload"
style="margin-right: 6px"
/>点击上传或拖拽文件夹到此处
<!-- <el-button type="text" @click="addFiles">添加文件</el-button> -->
</div>
<div
v-show="!fileListArr.length"
class="el-upload__text"
>
<!-- 文件上传数量不能超过100个,总大小不超过5GB -->
单个文件大小不超过50GB
</div>
<el-table
v-show="fileListArr.length"
:data="fileListArr.slice((currentPage - 1) * pageSize, currentPage * pageSize)"
>
<el-table-column
label="对象key"
prop="name"
min-width="120px"
/>
<el-table-column
label="目录"
min-width="120px"
>
<template slot-scope="scope">
{{ (scope.row.webkitRelativePath ? form.hostName +'/'+ scope.row.webkitRelativePath : form.hostName +'/'+ scope.row.relativePath) | renderPath }}
</template>
</el-table-column>
<el-table-column
label="类型"
width="180px"
>
<template slot-scope="scope">
{{ scope.row.type }}
</template>
</el-table-column>
<el-table-column
label="大小"
width="120px"
>
<template slot-scope="scope">
{{ byteConvert(scope.row.size) }}
</template>
</el-table-column>
<el-table-column
label="移除"
width="100px"
>
<template slot-scope="scope">
<svg
class="icon"
aria-hidden="true"
@click="removeItem(scope)"
>
<use xlink:href="#icon-trash" />
</svg>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="fileListArr.length"
:current-page="currentPage"
:page-sizes="[5, 10, 50, 100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="fileListArr.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-form>
<div
slot="footer"
class="dialog-footer"
>
<el-button
class="golden"
:disabled="fileListArr.length==0"
@click="confirmPut()"
>{{ $trans('button.confirm') }}</el-button>
<el-button @click="dirFlag = false;">{{ $trans('button.cancel') }}</el-button>
</div>
<div
id="loadChart"
style="width:400px;height:300px;display:none"
/>
</el-dialog>
<div
v-if="showDrop"
class="picker__drop-zone"
@dragover="(e)=>e.preventDefault()"
@drop="onDrop"
>
<div class="drop-arrow">
<div class="arrow anim-floating" />
<div class="base" />
</div>
<div
data-v-6a50ffaa=""
class="picker__drop-zone-label"
>拖拽文件夹到此处</div>
</div>
</div>
</template>
<script>
import {
upload,
getHostname
} from '@/api/agent'
const AWS = require('aws-sdk')
// const FileSaver = require('file-saver')
// AWS 设置超时时间 默认2min、当前60min
const initialTime = 5000
let catchNetFail = false
AWS.config.update({
httpOptions: {
timeout: 1000 * 60 * 5
},
maxRetries: 0 // 默认
// maxRedirects: 10,
// retryDelayOptions: {
// // base 默认100ms
// customBackoff: (count, err) => {
// // catchNetFail = true
// initialTime = count * 1000 + 5000
// // console.log(count, initialTime)
// // 重试时间间隔、默认5000,线性增长、最大增长5min、总重试时间12h
// // y= kx+b k、b为常数、by轴的偏移量
// // an = a1+(n-1) sn = n(a1+an)/2
// // 420=>24h
// return initialTime
// }
// }
})
import moment from 'moment'
export default {
filters: {
renderPath (path) {
path = String(path) || ''
const lastIndex = path.lastIndexOf('/')
return path.substr(0, lastIndex)
}
},
data () {
return {
continueArr: [],
finalList: [],
loadingBg: null,
putObjectNameArr: [],
timer: null,
myecharts: null,
datas_outer: [],
mockPutSize: 0,
needMock: false,
putSize: 0,
totalSize: 0,
createForm: {
folderName: ''
},
dirFlag: false,
form: {
hostName: '',
accessKeyId: '',
secretAccessKey: '',
// endpoint: 'http://10.0.2.154:8300',
endpoint: '',
path: '',
Bucket: ''
},
bucketList: [],
pageSize: 10,
currentPage: 1,
fileList: [],
fileListArr: [],
executeTime: '',
rules: {
// hostName: { required: true, message: '请输入hostName' },
accessKeyId: { required: true, message: '请输入accessKeyId' },
secretAccessKey: { required: true, message: '请输入secretAccessKey' },
endpoint: {
required: true,
validator: (_, val, cb) => {
const reg = /^(http:\/\/)?(.)*/
if (!val) {
return cb('请输入endpoint')
} else if (reg.test(val)) {
if (val.indexOf('http://') === -1) {
// 匹配http://替换
const regPrefix = /(h)?(t)?(t)?(p)?(:)?(\/)?(\/)?/
const matchStr = val.match(regPrefix)?.[0]
this.form.endpoint = 'http://' + val.substring(matchStr.length)
} else {
return cb()
}
}
}
},
Bucket: { required: true, message: '请选择bucket' }
},
S3: null,
noBucket: false,
getHostLoading: false,
uploadSizeLimt: 5 * 1024 ** 3, // 上传文件大小限制 1T
uploadPartSize: 1024 * 1024 * 5, // 分段大小&&文件启用分段大小
sizeError: [],
enableReUpload: true,
readFileList: [],
showDrop: false
}
},
computed: {
renderPadding () {
return this.fileListArr.length ? {
padding: '50px 10px'
} : {
padding: '150px 20px'
}
},
options () {
return {
tooltip: {
show: false
},
title: {
// text超出最大数字16位
text: this.renderLoadingText(),
x: 'center',
y: 'center',
textStyle: {
color: '#fff',
fontSize: '30px' // 中间标题文字大小设置
}
},
series: [
{
name: '完成情况外层',
type: 'pie',
padAngle: 5,
// radius: ['40%', '60%'],
radius: ['52%', '75%'],
center: ['50%', '50%'],
clockwise: false,
data: this.datas_outer,
// startAngle: 100,
hoverAnimation: false,
legendHoverLink: false,
label: {
show: false
},
labelLine: {
show: false
}
}
]
}
}
},
watch: {
dirFlag (val) {
if (val) {
this.$nextTick(() => {
this.$refs['uploadFile'].clearFiles()
})
this.enableDrop()
this.fileListArr = []
this.datas_outer = []
for (let i = 30; i > 0; i--) {
this.datas_outer.push({
value: 1, // 占位用
name: '未完成',
itemStyle: { color: '#19272e' }
})
}
} else {
this.mockPutSize = 0
this.needMock = false
this.myecharts = null
this.continueArr = []
this.putSize = 0 // 记录进度
this.totalSize = 0
this.readFileList = []
catchNetFail = false
clearTimeout(this.timer)
this.releaseDisable()
this.disableDrop()
// this.doClearFileLog()
}
}
},
mounted () {
// AWS.events.on('send', (req) => {
// console.log('req', req)
// if (req.retryCount > 5) {
// this.S3.uploadPart(
// { ...req.request.params }
// , (error, success) => {
// console.log(error, success, req)
// })
// }
// })
// this.needMock = true
// this.myecharts = this.$echarts.init(document.getElementById('loadChart'))
// this.renderChartPart()
// setTimeout(async () => {
// await this.renderLoadingChart(5)
// }, 1000)
document.addEventListener('keydown', function (event) {
if (event.code === 'Escape') {
event.preventDefault() // 取消默认行为
}
})
const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
this.form.accessKeyId = accessKeyId || 'http://10.0.2.153:9000'
this.form.endpoint = endpoint
// || 'http://10.0.2.153:9000'
this.form.secretAccessKey = 'minioadmin'
setTimeout(() => {
this.getBucketList()
})
// this.init()
// get HostName、默认传递
// setTimeout(() => {
// this.getBucketList()
// // this.init()
// })
},
destroyed () {
clearTimeout(this.timer)
},
methods: {
dragEnterHandler (e) {
e.preventDefault()
if (!this.showDrop) {
this.showDrop = true
}
},
dragLeaveHandler (e) {
e.preventDefault()
e.relatedTarget || (this.showDrop = false)
// e.relatedTarget有效值仍在界面内
},
dropHandler (e) {
e.preventDefault()
this.showDrop = false
},
renderLoadingText () {
return this.needMock ? String(this.mockPutSize).replace('.00', '') + '%' : Number((this.putSize / this.totalSize) * 100).toFixed(2).replace('.00', '') + '%'
},
async renderLoadingChart (timeSeconds, initialValue = 0) {
// console.log(timeSeconds, 123)
const totalValue = initialValue ? 100 - initialValue : 100
const res = this.getMockTime(timeSeconds, totalValue)
this.mockPutSize = initialValue == 100 ? 100 : initialValue + Number(res[0]).toFixed(2)
this.renderChartPart()
// 比如5s
for (let i = 1; i <= timeSeconds; i++) {
await new Promise(resolve => {
setTimeout(() => {
// 这里放置每隔一秒执行的代码
this.mockPutSize = Number(res.shift()).toFixed(2)
this.renderChartPart()
// 进度 xdata 最终是 100
resolve(i)
}, 1000) // i * 1000 表示每次延迟 i 秒
})
}
return Promise.resolve(true)
},
renderChartPart () {
//
var num = 30 // 定义小块个数
var rate = this.needMock ? this.mockPutSize / 100 : this.putSize / this.totalSize // 完成率
const count = rate * 30
//
// 填充
for (let i = 1; i <= num; i++) {
if (i <= count) {
this.datas_outer[num - i].itemStyle.color = '#ff8746'
} else {
this.datas_outer[num - i].itemStyle.color = '#19272e'
}
}
this.myecharts && this.myecharts.setOption(this.options)
if (this.needMock) {
clearTimeout(this.timer)
} else {
this.timer = setTimeout(() => {
this.renderChartPart()
}, 1000)
}
},
getMockTime (totalTime, count) {
const res = []
count = count || 100
function nonLinearIncrease (currentTime, totalTime) {
// 非线性增长函数,这里使用了sin函数作为示例
const progress = Math.sin((Math.PI / 2) * (currentTime / totalTime))
const result = progress * count
return result
}
// 测试函数,模拟从0到100的非线性增长过程
function testNonLinearIncrease (totalTime) {
for (let t = 1; t <= totalTime; t++) {
const value = nonLinearIncrease(t, totalTime)
res.push(value)
// console.log(`Time: ${t}, Value: ${value}`)
}
return res
}
return testNonLinearIncrease(totalTime)
},
handlePutPath (file) {
const {
webkitRelativePath,
relativePath
} = file
return webkitRelativePath || relativePath
},
cleafFile () {
this.fileListArr = [] // 清除表格展示
this.$refs['uploadFile'].clearFiles() // 清除组件FileList
},
handleRequest (val) {
// 无功能、为自定义请求触发 beforeUpload校验文件
// console.log(val, '123')
},
handleSizeValidate (file) {
const size = file.size
const isExist = this.fileListArr.findIndex(x => {
return x.name === file.name && (x.webkitRelativePath || x.relativePath) === (file.webkitRelativePath || file.relativePath)
})
if (isExist > -1 || size > this.uploadSizeLimt) {
return false
}
this.fileListArr.push(file)
},
init () {
// const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
// this.form.accessKeyId = accessKeyId
// this.form.endpoint = endpoint
// this.form.hostName = 'Dc'
this.getHostLoading = true
getHostname().then(res => {
this.form.hostName = res.data || ''
}).finally(() => {
this.getHostLoading = false
const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
this.form.accessKeyId = accessKeyId
this.form.endpoint = endpoint
})
},
removeItem (row) {
const index = this.fileListArr.findIndex(x => x.relativePath === row.row.relativePath && x.name === row.row.name)
this.fileListArr.splice(index, 1)
// 最后一页删除后、切到1
if (this.fileListArr.length / this.pageSize <= 1) {
this.currentPage = 1
} else if (Math.ceil(this.fileListArr.length / this.pageSize) < this.currentPage) {
this.currentPage = this.currentPage - 1
}
},
handleSizeChange (val) {
this.pageSize = val
},
handleCurrentChange (val) {
this.currentPage = val
},
async handleMultUpload (fileArr) {
const {
Bucket,
hostName,
accessKeyId,
endpoint
} = this.form
// const arr = localStorage.getItem('fileList')
// if (!arr) localStorage.setItem('fileList', '[]')
// this.readFileList = JSON.parse(arr || '[]')
// 此处做断点续传
// return PromiseMultiple
// 此处不能统一执行、依次加入任务队列
const asyncTask = (file) => {
const fileSize = file.size
// 大于5GB、分片10m、500、
let chunkSize = ''
if (fileSize > 1024 * 1024 * 1024 * 10) {
chunkSize = 1024 * 1024 * 10
} else if (fileSize > 1024 * 1024 * 1024 * 5) {
chunkSize = 1024 * 1024 * 8
} else {
chunkSize = 1024 * 1024 * 5
}
// > 1000 ? 1024 * 1024 * 10 : this.uploadPartSize //5MB
const chunks = Math.ceil(fileSize / chunkSize)
const Key = hostName + '/' + this.handlePutPath(file)
file['Key'] = Key
return new Promise((resolve, rejected) => {
// 检测文件检测失败重传
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
const isExistReUploadPart = this.readFileList.find(x => {
return x.Key === Key && x.Bucket === Bucket && x.accessKeyId === accessKeyId && x.endpoint === endpoint
})
// console.log(
// isExistReUploadPart, '分片续传'
// )
// 存在文件的分片、调用listPart获取已上传的分片、并在下面的上传分片中跳过已有的分片
// 触发前置条件、 分片处理完添加分片到fileList上传列表、处理成功移除、
// 故递归处理的函数在此只有分片失败会进入此、createMultipartUpload和complete不会在此处理?
if (isExistReUploadPart) {
const {
UploadId
} = isExistReUploadPart
// 存在切片、在有效期且开启续传
const params = {
Bucket,
Key,
UploadId
}
this.S3.listParts(params, (err, data) => {
// 不存在err、complete完还有part、上传大文件失败
// console.log(data, 'err', err)
if (!err) {
// console.log(data.Parts, '===已上传的分片===')
// 分片续传、data.Parts 参数为已上传的分片list、需重置非空
isExistReUploadPart.parts = data.Parts || [];
(async () => {
const {
parts
} = isExistReUploadPart
let multiplePart = []
const listPartFin = []
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const doneUploadSize = end - start
const body = file.slice(start, end)
const PartNumber = chunkCount + 1
const reqParams = {
PartNumber,
Body: body,
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId
}
const jumpPass = parts.some(x => x.PartNumber === chunkCount + 1)
if (jumpPass) {
this.putSize += doneUploadSize
if (chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.doneUploadSize
: cur.reason.doneUploadSize
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
continue
} else {
continue
// 此处continue跳过已有的循环、若为最后循环、需等待任务队列结束并拿到分片结果
}
}
const p = new Promise((res, rej) => {
this.S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej({ ...uploadPartErr, doneUploadSize })
else res({ ...uploadPartData, doneUploadSize, PartNumber })
})
})
multiplePart.push(p)
if (multiplePart.length == 5 || chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.doneUploadSize
: cur.reason.doneUploadSize
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
}
}
// console.log(listPartFin, '=====剩下的分片=====')
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
const Parts = [...listPartFin, ...parts]
.map(x => {
return {
PartNumber: x.PartNumber || x.value.PartNumber,
ETag: x.ETag || x.value.ETag
}
})
.sort((a, b) => a.PartNumber - b.PartNumber)
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === isExistReUploadPart.UploadId
})
this.readFileList.splice(delIndex, 1)
resolve(compErrData)
}
// console.log(compErr, compErrData)
})
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const uploadPartError = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: uploadPartError,
file,
startTime,
endTime
})
// handle reUploadPart
}
})()
} else {
this.putSize += fileSize
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({ err, file, startTime, endTime })
}
})
} else {
// 处理上传进度
this.S3.createMultipartUpload({
Bucket,
Key
}, async (createErr, createData) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (createErr) {
// console.error('Error creating multipart upload:', createErr)
this.putSize += fileSize
// console.log(createErr, 'listPartFin')
rejected({ err: createErr, file, startTime, endTime })
} else {
let multiplePart = []
// writeAbortLog
this.readFileList.push({
accessKeyId,
endpoint,
Bucket,
Key,
UploadId: createData.UploadId
})
const listPartFin = []
// 此处同步的所以有问题了vuex先缓存一下
// endWrite 此处记录及最终Promise处处理完成判断、清楚记录或执行abortMultiple
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const doneUploadSize = end - start
const body = file.slice(start, end)
const reqParams = {
PartNumber: chunkCount + 1,
Body: body,
Bucket,
Key,
UploadId: createData.UploadId
}
const p = new Promise((res, rej) => {
// if (chunkCount > chunks - 2) {
// reqParams.Bucket = '666'
// }
this.S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej({ ...uploadPartErr, doneUploadSize })
else res({ ...uploadPartData, doneUploadSize })
})
})
multiplePart.push(p)
if (multiplePart.length == 3 || chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
// console.log(partRes, '123')
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.doneUploadSize
: cur.reason.doneUploadSize
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
}
}
// uploadPart End
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
// var params = {
// Bucket,
// Key,
// UploadId: createData.UploadId
// }
const Parts = listPartFin.map((x, i) => {
return {
PartNumber: i + 1,
ETag: x.value.ETag
}
})
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: createData.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === createData.UploadId
})
this.readFileList.splice(delIndex, 1)
resolve(compErrData)
}
})
// this.S3.listParts(params, (partErr, partRes) => {
// const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
// if (partErr) {
// rejected({
// err: partErr,
// file,
// startTime,
// endTime
// })
// } else {
// const Parts = partRes.Parts.map(x => {
// return {
// PartNumber: x.PartNumber,
// ETag: x.ETag
// }
// }).sort((a, b) => a.PartNumber - b.PartNumber)
// // finish
// this.S3.completeMultipartUpload({
// Bucket,
// Key,
// UploadId: createData.UploadId,
// MultipartUpload: { Parts }
// }, (compErr, compErrData) => {
// if (compErr) {
// const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
// rejected({
// err: compErr,
// file,
// startTime,
// endTime
// })
// } else {
// resolve(compErrData)
// }
// // console.log(compErr, compErrData)
// })
// }
// })
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const err = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: err,
file,
startTime,
endTime
})
// handle reUploadPart
}
}
})
}
})
}
const finalList = []
while (fileArr.length) {
const file = fileArr.shift()
finalList.push(await asyncTask(file).catch(err => {
return {
hasError: true,
err
}
}))
}
//
return finalList[0].hasError ? Promise.reject(finalList) : Promise.resolve(finalList)
},
postFolder (type) {
if (type === 'file') {
document.querySelector('.el-upload__input').webkitdirectory = false
} else {
document.querySelector('.el-upload__input').webkitdirectory = true
}
},
releaseDisable () {
document.oncontextmenu = function () { }
document.onkeydown = function (event) { }
window.onbeforeunload = function () { }
},
enableDrop () {
window.addEventListener('dragenter', this.dragEnterHandler)
window.addEventListener('dragleave', this.dragLeaveHandler)
window.addEventListener('drop', this.dropHandler)
},
disableDrop () {
window.removeEventListener('dragenter', this.dragEnterHandler)
window.removeEventListener('dragleave', this.dragLeaveHandler)
window.removeEventListener('drop', this.dropHandler)
},
resetForm (formName) {
if (this.$refs[formName] != undefined) {
this.$refs[formName].resetFields()
}
},
onDrop (e) {
e.preventDefault()
const dataTransfer = e.dataTransfer
if (
dataTransfer.items &&
dataTransfer.items[0] &&
dataTransfer.items[0].webkitGetAsEntry
) {
this.webkitReadDataTransfer(dataTransfer)
}
},
webkitReadDataTransfer (dataTransfer) {
let fileNum = dataTransfer.items.length
const files = []
this.loading = true
// 递减计数,当fileNum为0,说明读取文件完毕
const decrement = () => {
if (--fileNum === 0) {
this.handleFiles(files)
this.loading = false
}
}
// 递归读取文件方法
const readDirectory = (reader) => {
// readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。
reader.readEntries((entries) => {
if (entries.length) {
fileNum += entries.length
entries.forEach((entry) => {
if (entry.isFile) {
entry.file((file) => {
readFiles(file, entry.fullPath)
}, readError)
} else if (entry.isDirectory) {
readDirectory(entry.createReader())
}
})
readDirectory(reader)
} else {
decrement()
}
}, readError)
}
// 文件对象
const items = dataTransfer.items
// 拖拽文件遍历读取
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry()
if (!entry) {
decrement()
return
}
if (entry.isFile) {
// 读取单个文件
return
// readFiles(items[i].getAsFile(), entry.fullPath, 'file')
} else {
// entry.createReader() 读取目录。
readDirectory(entry.createReader())
}
}
function readFiles (file, fullPath) {
file.relativePath = fullPath.substring(1)
files.push(file)
decrement()
}
function readError (fileError) {
throw fileError
}
},
handleFiles (files) {
// 按文件名称去存储列表,考虑到批量拖拽不会有同名文件出现
const dirObj = {}
// console.log(files, '1233')
// return
files.forEach((item) => {
// relativePath 和 name 一致表示上传的为文件,不一致为文件夹
// 文件直接放入table表格中
// 仍需考虑去重问题
const isExist = this.fileListArr.findIndex(x => x.name === item.name && (x.webkitRelativePath || x.relativePath) === (item.webkitRelativePath || item.relativePath))
if (isExist > -1 || item.size > this.uploadSizeLimt) {
return
// this.fileListArr.splice(isExist, 1)
}
this.fileListArr.push(item)
// if (item.relativePath === item.name) {
// this.tableData.push({
// name: item.name,
// filesList: [item.file],
// isFolder: false,
// size: item.size
// })
// }
// // 文件夹,需要处理后放在表格中
// if (item.relativePath !== item.name) {
// const filderName = item.relativePath.split('/')[0]
// if (dirObj[filderName]) {
// // 放入文件夹下的列表内
// const dirList = dirObj[filderName].filesList || []
// dirList.push(item)
// dirObj[filderName].filesList = dirList
// // 统计文件大小
// const dirSize = dirObj[filderName].size
// dirObj[filderName].size = dirSize ? dirSize + item.size : item.size
// } else {
// dirObj[filderName] = {
// filesList: [item],
// size: item.size
// }
// }
// }
})
// 放入tableData
Object.keys(dirObj).forEach((key) => {
this.tableData.push({
name: key,
filesList: dirObj[key].filesList,
isFolder: true,
size: dirObj[key].size
})
})
},
validateBucket () {
if (!this.form.Bucket) {
if (this.bucketList.length) {
this.$notify({
type: 'error',
title: '请选择一个bucket'
})
} else {
if (this.noBucket) {
this.$notify({
type: '无bucket可用,请先创建bucket'
})
} else {
this.$notify({
type: 'error',
title: '请点击“连接”按钮,设置bucket'
})
}
}
} else {
this.$refs['form'].validate((valid) => {
if (valid) {
document.onkeydown = function (event) {
var e = event || window.event || arguments.callee.caller.arguments[0]
if (e && e.keyCode == 116) {
return false
}
}
window.onbeforeunload = function (e) {
// 兼容ie
// 触发条件 产生交互、当前不支持自定义文字
e = e || window.event
if (e) e.returnValue = 'none'
return 'none'
}
document.oncontextmenu = function () { return false }
this.dirFlag = true
const { endpoint, accessKeyId } = this.form
localStorage.setItem('form', JSON.stringify({
endpoint,
accessKeyId
}))
}
})
}
},
getBucketList () {
const {
accessKeyId,
secretAccessKey,
endpoint
} = this.form
if (!endpoint) {
this.$notify({
type: 'error',
title: '请输入endpoint'
})
return
}
if (!accessKeyId) {
this.$notify({
type: 'error',
title: '请输入Access Key'
})
return
}
if (!secretAccessKey) {
this.$notify({
type: 'error',
title: '请输入Secret Key'
})
return
}
this.S3 = new AWS.S3({
accessKeyId,
secretAccessKey,
endpoint,
region: 'EastChain-1',
s3ForcePathStyle: true
})
this.S3.listBuckets((err, data) => {
if (err) {
// console.dir(err)
// console.log('%c 123', 'color:red;font-size:20px')
// "NetworkingError"
let title = ''
let message = ''
if (err.code === 'AccessDenied') {
title = '连接S3失败'
message = '请检查ak/sk是否输入正确'
} else if (Number(err.code) === 12) {
title = '网络异常'
message = '请检查endpoint是否正确'
} else if (err.code === 'NetworkingError') {
title = '网络异常'
message = '请检查endpoint是否正确,或稍后再试'
} else {
title = '连接S3失败'
message = this.$trans(err.message || '')
}
// console.dir(err, 'err')
this.$notify({
type: 'error',
title,
message,
showClose: false,
customClass: 'errorTip'
})
this.noBucket = false
this.bucketList = []
this.form.Bucket = ''
} else {
this.bucketList = (data.Buckets || []).map(x => x.Name)
if (!this.bucketList.length) {
this.$notify({
type: 'error',
title: '无bucket可用,请先创建bucket'
})
this.noBucket = true
} else {
this.form.Bucket = this.bucketList[0]
this.$notify({
type: 'success',
title: '连接S3成功'
})
this.form.Bucket = 'test'
// 确保断网或刷新页面导致未完成的上传记录清除
// this.doClearFileLog()
}
}
})
},
doClearFileLog () {
const {
accessKeyId,
endpoint
} = this.form
const keyList = JSON.parse(JSON.stringify(this.readFileList))
// console.log(keyList, 'null')
const doAbortTasks = keyList.map((x, i) => {
return new Promise((resolve, rejected) => {
if (accessKeyId === x.accessKeyId && endpoint === x.endpoint) {
// 一致性确保listPart正常
const params = {
Bucket: x.Bucket,
Key: x.Key,
UploadId: x.UploadId
}
this.S3.listParts(params, (err, data) => {
// 不存在err、complete完还有part、上传大文件失败
// console.log(data, 'err', err)
if (!err) {
// doAbort
const reUpload = data.Parts && data.Parts.length > 0
// console.log(data, '1233', reUpload)
if (reUpload && this.enableReUpload) {
keyList[i].reUpload = true
keyList[i].parts = data.Parts
const expireTime = keyList[i].expireTime
if (expireTime) {
if (
expireTime < moment().valueOf()) {
this.S3.abortMultipartUpload(params, (err, data) => {
if (!err) {
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
} else {
resolve('keepReUpload')
}
} else {
keyList[i].expireTime = moment().add(15, 'day').valueOf()
resolve('reUpload')
}
// 有切片需要支持后续上传
} else {
this.S3.abortMultipartUpload(params, (err, data) => {
if (!err) {
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
}
} else {
// 此处问题、
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
} else {
rejected('notMatch')
// noThingTodo
}
})
})
Promise.allSettled(doAbortTasks).then(res => {
// localStorage.setItem('fileList', JSON.stringify(iterateArr))
// 结束清理status为删除的
const fileList = keyList.filter(x => x.delete !== true)
this.readFileList = []
localStorage.setItem('fileList', JSON.stringify(fileList))
// console.log('checkOver', keyList, localStorage.getItem('fileList'))
})
},
confirmPut () {
const {
Bucket,
hostName
} = this.form
this.finalList = []
const putObjectArr = []
const multUploadArr = []
this.disableDrop()
document.querySelector('#loadChart').style.display = 'block'
const judgeUploadType = async () => {
// 文件分流
this.fileListArr.forEach(x => {
this.totalSize = this.totalSize + x.size
if (x.size <= this.uploadPartSize) {
putObjectArr.push({
Bucket,
Key: hostName + '/' + this.handlePutPath(x),
Body: x
})
} else {
multUploadArr.push(x)
}
})
}
// 区分大文件
(async () => {
await judgeUploadType()
// startPutObject
// console.log(putObjectArr, multUploadArr)
// return
this.loadingBg = this.$loading({
lock: true,
text: '文件上传中,请勿关闭当前页面',
spinner: 'el-icon-loading',
background: 'rgba(1,1,1,.3)',
customClass: 'putLoading'
})
try {
const putObjectFin = []
// const taskList = []
this.putObjectNameArr = []
const needMock = multUploadArr.length === 0
// const putObjectStart = +new Date()
// const putObjectCount = putObjectArr.length
this.needMock = needMock && putObjectArr.length <= 5
this.myecharts = this.$echarts.init(document.getElementById('loadChart'))
this.renderChartPart()
//
const _this = this
// 任务队列3
class AsyncQueue {
constructor (maxConcurrent = 2) {
this.maxConcurrent = maxConcurrent
this.running = 0
this.queue = []
}
// 优化单个上传大文件、分片任务并发改为非同步任务
async run () {
while (this.running < this.maxConcurrent && this.queue.length > 0) {
const file = this.queue.shift()
this.running++
let asyncTask = null
if (file.size <= _this.uploadPartSize) {
asyncTask = new Promise((res, rej) => {
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
_this.S3.putObject({
Bucket,
Key: hostName + '/' + _this.handlePutPath(file)
}, (err, data) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (err) rej({ err, file, startTime, endTime })
else res({ success: 'success', file, startTime, endTime })
})
})
} else {
const uploadResult = await Promise.allSettled([_this.handleMultUpload([file])])
console.log(uploadResult, '1233')
asyncTask = new Promise((resolve, reject) => {
uploadResult[0].status === 'rejected' ? reject({ ...uploadResult[0].reason[0], isMultUpload: true }) : resolve({ ...uploadResult[0].value, isMultUpload: true })
})
// 与putObject区分
}
await Promise.allSettled([asyncTask]).then(res => {
console.log(res, '=========')
const result = res[0]
// 这里分段处理得promise只返回最终处理成功得数据注意和putObject区分
if (result.status === 'fulfilled') {
if (!result.value.isMultUpload) {
_this.putSize += result.value.file.size
}
} else {
if (!result.reason.isMultUpload) {
_this.putSize += result.reason.file.size
}
}
putObjectFin.push(result)
this.running--
if (putObjectFin.length === _this.fileListArr.length) {
setTimeout(() => {
_this.releaseDisable()
_this.loadingBg.close()
console.log(putObjectFin)
// console.log(result, 'result')
_this.writeErrorLog(putObjectFin)
}, 1200)
} else {
this.run()
}
})
}
// console.log(putObjectFin, '12333')
}
add (task) {
this.queue.push(task)
this.run()
}
}
const asyncQueue = new AsyncQueue(2)
let totalCount = this.fileListArr.length
let index = 0
while (totalCount > 0) {
totalCount--
const file = this.fileListArr[index]
asyncQueue.add(file)
index++
}
// for (let i = 0; i < putObjectCount; i++) {
// const file = putObjectArr[i]
// // i > 0 ? file.Bucket = '123' : null
// const p = new Promise((res, rej) => {
// const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
// this.S3.putObject(file, (err, data) => {
// const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
// if (err) rej({ err, file, startTime, endTime })
// else res({ success: 'success', file, startTime, endTime })
// })
// })
// taskList.push(p)
// if (taskList.length == 5 || i === putObjectCount - 1) {
// const partRes = await Promise.allSettled(taskList)
// this.putSize += partRes.reduce((pre, cur) => {
// pre += cur.status === 'fulfilled' ? cur.value.file.Body.size
// : cur.reason.file.Body.size
// return pre
// }, 0)
// putObjectFin.push(...partRes)
// taskList = []
// }
// }
// 文件索引不对、记录上传功能的文件名、并移除、记录余下文件
// this.continueArr = putObjectArr.filter(x => {
// return this.putObjectNameArr.every(y => {
// return x.Key !== y
// })
// })
// if (this.continueArr.length) {
// this.loadingBg.close()
// setTimeout(() => {
// // 判断网络连接情况
// this.$confirm('恢复上传对象', '请确认', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// closeOnClickModal: false,
// closeOnPressEscape: false,
// showClose: false,
// type: 'warning',
// dangerouslyUseHTMLString: true
// }).then(() => {
// this.continueUpload(putObjectFin, multUploadArr)
// }).catch(() => {
// this.dirFlag = false
// })
// }, 1000)
// return
// }
// putObject小于5、
// if (needMock) {
// if (putObjectArr.length <= 5 && !catchNetFail) {
// const putObjectEnd = +new Date()
// const timeSeconds = Math.ceil((putObjectEnd - putObjectStart) / 1000)
// await this.renderLoadingChart(timeSeconds, Number(((this.putSize / this.totalSize) * 100).toFixed(2)))
// } else {
// catchNetFail = false
// }
// }
// const multipleObjects = await this.handleMultUpload(multUploadArr)
// console.log(multipleObjects, '====分片文件list====')
// console.log(this.totalSize, this.putSize, 'fin')
// setTimeout(() => {
// this.releaseDisable()
// this.loadingBg.close()
// console.log(putObjectFin)
// // console.log(result, 'result')
// this.writeErrorLog(putObjectFin)
// }, 1200)
// 上传及分段上传全部结束
} catch (error) {
console.log('errorOperate', error)
}
})()
},
writeErrorLog (result) {
console.log(result, '================')
const file = result
// console.log(result, '123')
const failList = file.filter(x => x.status === 'rejected' || (x.hasError))
// .map(x => {
// // while大文件异步promise格式化
// if (x.hasError) {
// x.reason = x.err
// }
// return x
// })
// const log = failList.reduce((pre, cur, i) => {
// return pre + '结束时间:' + cur.reason.endTime + ' ' + '对象Key: ' + cur.reason.file.Key + ' ' + ' ' + '错误原因: ' + cur.reason.err.message + '\n'
// }, '')
const total = file.length
const failCount = failList.length
const successCount = total - failCount
// console.log('===============', failList, log, result)
if (failCount && failCount > 0) {
this.$notify({
title: '上传完成',
dangerouslyUseHTMLString: true,
type: 'success',
message: `<p>
<strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
<br/>
</p>`
})
this.dirFlag = false
// upload({
// log
// }).then(res => {
// this.$notify({
// title: '上传完成',
// dangerouslyUseHTMLString: true,
// type: 'success',
// message: `<p>
// <strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
// <br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
// <br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
// <br/> <span style="color:#d3d6d8;font-size:15px">请到备份历史查看详情</span>
// </p>`
// })
// }).finally(() => {
// this.dirFlag = false
// })
} else {
this.$notify({
title: '上传完成',
dangerouslyUseHTMLString: true,
type: 'success',
message: `<p>
<strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
</p>`
})
this.dirFlag = false
}
},
async continueUpload (putObjectFin, multUploadArr) {
// case putObject
this.loadingBg = this.$loading({
lock: true,
text: '文件上传中,请勿关闭当前页面',
spinner: 'el-icon-loading',
background: 'rgba(1,1,1,.3)',
customClass: 'putLoading'
})
const {
hostName
} = this.form
let taskList = []
const needMock = multUploadArr.length === 0
const putObjectStart = +new Date()
this.needMock = needMock && this.continueArr.length <= 5
// putObject 待上传的文件、
const count = this.continueArr.length
for (let i = 0; i < count; i++) {
const file = this.continueArr[i]
// i > 0 ? file.Bucket = '123' : null
const p = new Promise((res, rej) => {
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
this.S3.putObject(file, (err, data) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (err) rej({ err, file, startTime, endTime })
else res({ success: 'success', file, startTime, endTime })
})
})
taskList.push(p)
if (taskList.length == 5 || i === count - 1) {
const partRes = await Promise.allSettled(taskList)
// 罗列list 记录上传后的状态
// 存在reject newworkFailure 停止当前for循环putObject
if (partRes.some(x => x.status === 'rejected' && x.reason.err.code === 'NetworkingError')) {
break
} else {
console.log(partRes, '1233')
// 记录上传成功及非网络异常导致的上传失败
this.putObjectNameArr.push(...partRes.map(x => {
return x.status === 'fulfilled'
? hostName + '/' + this.handlePutPath(x.value.file.Body)
: hostName + '/' + this.handlePutPath(x.reason.file.Body)
}))
}
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.file.Body.size
: cur.reason.file.Body.size
return pre
}, 0)
putObjectFin.push(...partRes)
taskList = []
}
}
this.continueArr = this.continueArr.filter(x => {
return this.putObjectNameArr.every(y => {
return x.Key !== y
})
})
if (this.continueArr.length) {
this.loadingBg.close()
setTimeout(() => {
// 判断网络连接情况
this.$confirm('恢复上传对象', '请确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
closeOnClickModal: false,
closeOnPressEscape: false,
type: 'warning',
dangerouslyUseHTMLString: true
}).then(() => {
this.continueUpload(putObjectFin, multUploadArr)
})
}, 1000)
return
}
if (this.needMock) {
const putObjectEnd = +new Date()
const timeSeconds = Math.ceil((putObjectEnd - putObjectStart) / 1000)
await this.renderLoadingChart(timeSeconds)
}
const multipleObjects = await this.handleMultUpload(multUploadArr)
// console.log(this.totalSize, this.putSize, 'fin')
setTimeout(() => {
this.releaseDisable()
this.loadingBg.close()
// console.log(result, 'result')
this.writeErrorLog([...multipleObjects, ...putObjectFin])
}, 1200)
},
handleRemoveErrorUpload (arr) {
arr = JSON.parse(JSON.stringify(arr))
const ErrorConnect = arr.filter(x => x.hasError && x.err.err.code === 'NetworkingError')
if (ErrorConnect.length) {
const index = arr.findIndex(x => {
return x.Key === ErrorConnect[0].err.file.Key
})
arr.splice(index, 1)
}
return arr
}
}
}
</script>
<style lang="scss" scoped>
:deep(.form) {
label.el-form-item__label {
margin-left: 0 !important;
width: 150px !important;
}
.el-select {
width: 100%;
}
}
:deep(.el-dialog) {
.icon {
cursor: pointer;
font-size: 17px;
margin: 0 18px 0 3px;
vertical-align: middle !important;
}
}
:deep(.errorTip) {
background-color: aqua !important;
width: fit-content !important;
.el-notification__group {
.el-notification__content {
p {
color: #d3d6d8;
}
}
}
}
.el-icon-upload {
font-size: 40px;
margin: 0;
}
.el-upload__text {
display: flex;
align-items: center;
justify-content: center;
font-size: 25px;
margin: 40px 10px;
line-height: 25px;
text-align: center;
color: #d3d6d8;
}
.addFiles {
color: #337dff;
}
.drag {
width: 100%;
margin-top: 10px;
}
.el-table {
max-height: 600px;
overflow-y: auto;
}
#loadChart {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<style lang="scss">
.putLoading {
.el-loading-spinner {
position: fixed;
top: 10%;
left: 50%;
width: fit-content;
transform: translate(-50%);
}
.el-loading-spinner i {
font-size: 25px;
}
.el-loading-text {
font-size: 25px;
}
}
.uploadMenu {
width: fit-content;
display: flex;
justify-content: flex-start;
.el-button {
font-size: 25px;
height: 60px;
width: 120px;
padding: 15px;
margin-right: 50px;
box-sizing: border-box;
}
}
.picker__drop-zone {
position: fixed;
box-sizing: border-box;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: hsla(0, 0%, 100%, 0.9);
border: 6px solid #ff8746;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.anim-floating {
animation-name: anim-floating-6a50ffaa;
animation-duration: 1s;
animation-iteration-count: infinite;
}
.picker__drop-zone-label {
margin-top: 30px;
font-size: 25px;
color: #333;
}
.drop-arrow {
display: inline-block;
div {
display: block;
background-repeat: no-repeat;
background-position: 50%;
}
.arrow {
width: 38.68px;
height: 63.76px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 38.68 63.76' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M34.2 42.63 21.68 55V3c0-1.42-.88-3-2.34-3a3 3 0 0 0-2.66 3v52L4.47 42.63a2.68 2.68 0 0 0-1.85-.76 2.57 2.57 0 0 0-1.85.76 2.51 2.51 0 0 0 0 3.63L17.49 63a2.7 2.7 0 0 0 1.85.76 2.58 2.58 0 0 0 1.85-.76l16.72-16.75a2.51 2.51 0 0 0 0-3.63 2.69 2.69 0 0 0-3.7 0Zm0 0' fill='%23333'/%3E%3C/svg%3E");
margin-left: auto;
margin-right: auto;
margin-bottom: 0;
}
.base {
width: 88.98px;
height: 28.61px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 88.98 28.61' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M86.42.38A2.26 2.26 0 0 0 84 2.73v9.07a12.34 12.34 0 0 1-12 11.93H15.78C9.44 23.73 5 18.05 5 11.73V2.28A2.22 2.22 0 0 0 2.56 0 2.55 2.55 0 0 0 0 2.56v9.45a16.48 16.48 0 0 0 16.44 16.6h56.22A16.38 16.38 0 0 0 89 12.02V2.95A2.35 2.35 0 0 0 86.72.38h-.28z' fill='%23333'/%3E%3C/svg%3E");
}
}
}
@keyframes anim-floating-6a50ffaa {
0% {
transform: translateY(0);
}
50% {
transform: translateY(25%);
}
to {
transform: translateY(0);
}
}
</style>
# 上传对象并发同步
<template>
<div>
<el-row>
<el-col
:span="24"
class="manage-area-title"
>
<h2>备份</h2>
</el-col>
</el-row>
<!-- <BreadCrumbs /> -->
<div
v-loading="getHostLoading"
class="page_content_wrap"
>
<el-form
ref="form"
class="form"
:model="form"
style="width: 40%;"
label-width="150px"
:rules="rules"
>
<el-form-item label="hostName">
<el-input
v-model="form.hostName"
placeholder="请输入hostName"
readonly
/>
</el-form-item>
<el-form-item
label="endpoint"
prop="endpoint"
>
<el-input
v-model="form.endpoint"
clearable
placeholder="请输入endpoint"
/>
</el-form-item>
<el-form-item
label="Access Key"
prop="accessKeyId"
>
<el-input
v-model="form.accessKeyId"
clearable
placeholder="请输入Access Key"
/>
</el-form-item>
<el-form-item
label="Secret Key"
prop="secretAccessKey"
>
<el-input
v-model="form.secretAccessKey"
type="password"
show-password
clearable
placeholder="请输入Secret Key"
style="width: 80%;"
/>
<el-button
style="position: absolute; right: 0;top:8px"
@click="getBucketList"
>连接</el-button>
</el-form-item>
<el-form-item
v-if="bucketList&&bucketList.length"
label="bucket"
prop="Bucket"
>
<el-select
v-model="form.Bucket"
clearable
placeholder="请选择一个bucket"
filterable
>
<el-option
v-for="x in bucketList"
:key="x"
:value="x"
:label="x"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
class="golden"
@click="validateBucket()"
>备份</el-button>
<el-button
class="blue"
@click="resetForm('form')"
>重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- <div
id="loadChart"
style="width:400px;height:300px"
/> -->
<el-dialog
title="备份"
:visible.sync="dirFlag"
width="65%"
destroy-on-close
:close-on-press-escape="false"
:close-on-click-modal="false"
>
<el-form
ref="createForm"
:model="createForm"
size="mini"
label-width="150px"
style="padding:0 5%;position:relative"
>
<!-- :before-upload="validateFileRule" -->
<!-- :accept=",,拼接可接受文件类型 image/* 任意图片文件" -->
<!-- :http-request="uploadFile" 覆盖原生action上传方法-->
<!-- var formData = new FormData(); // 用FormData存放上传文件 -->
<!-- formData.append('paramsName','file') -->
<el-row class="uploadMenu">
<el-upload
ref="uploadFile"
action="#"
multiple
:show-file-list="false"
:http-request="handleRequest"
:before-upload="handleSizeValidate"
>
<!-- <el-button
size="small"
class="golden"
@click="postFolder('file')"
>上传文件</el-button> -->
<el-button
size="small"
class="golden"
@click="postFolder('folder')"
>上传</el-button>
</el-upload>
<el-button
class="blue"
:disabled="!fileListArr.length"
@click="cleafFile"
>清空</el-button>
</el-row>
<!-- <input type="file" id="upload" ref="inputer" name="file" multiple /> -->
<div
draggable="true"
class="drag tableBox"
:style="renderPadding"
>
<div
v-show="!fileListArr.length"
class="el-upload__text"
>
<i
class="el-icon-upload"
style="margin-right: 6px"
/>点击上传或拖拽文件夹到此处
<!-- <el-button type="text" @click="addFiles">添加文件</el-button> -->
</div>
<div
v-show="!fileListArr.length"
class="el-upload__text"
>
<!-- 文件上传数量不能超过100个,总大小不超过5GB -->
单个文件大小不超过50GB
</div>
<el-table
v-show="fileListArr.length"
:data="fileListArr.slice((currentPage - 1) * pageSize, currentPage * pageSize)"
>
<el-table-column
label="对象key"
prop="name"
min-width="120px"
/>
<el-table-column
label="目录"
min-width="120px"
>
<template slot-scope="scope">
{{ (scope.row.webkitRelativePath ? form.hostName +'/'+ scope.row.webkitRelativePath : form.hostName +'/'+ scope.row.relativePath) | renderPath }}
</template>
</el-table-column>
<el-table-column
label="类型"
width="180px"
>
<template slot-scope="scope">
{{ scope.row.type }}
</template>
</el-table-column>
<el-table-column
label="大小"
width="120px"
>
<template slot-scope="scope">
{{ byteConvert(scope.row.size) }}
</template>
</el-table-column>
<el-table-column
label="移除"
width="100px"
>
<template slot-scope="scope">
<svg
class="icon"
aria-hidden="true"
@click="removeItem(scope)"
>
<use xlink:href="#icon-trash" />
</svg>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="fileListArr.length"
:current-page="currentPage"
:page-sizes="[5, 10, 50, 100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="fileListArr.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-form>
<div
slot="footer"
class="dialog-footer"
>
<el-button
class="golden"
:disabled="fileListArr.length==0"
@click="confirmPut()"
>{{ $trans('button.confirm') }}</el-button>
<el-button @click="dirFlag = false;">{{ $trans('button.cancel') }}</el-button>
</div>
<div
id="loadChart"
style="width:400px;height:300px;display:none"
/>
</el-dialog>
<div
v-if="showDrop"
class="picker__drop-zone"
@dragover="(e)=>e.preventDefault()"
@drop="onDrop"
>
<div class="drop-arrow">
<div class="arrow anim-floating" />
<div class="base" />
</div>
<div
data-v-6a50ffaa=""
class="picker__drop-zone-label"
>拖拽文件夹到此处</div>
</div>
</div>
</template>
<script>
import {
upload,
getHostname
} from '@/api/agent'
const AWS = require('aws-sdk')
// const FileSaver = require('file-saver')
// AWS 设置超时时间 默认2min、当前60min
let initialTime = 5000
let catchNetFail = false
AWS.config.update({
httpOptions: {
timeout: 1000 * 60 * 5
},
maxRetries: 420, // 默认
retryDelayOptions: {
// base 默认100ms
customBackoff: (count, err) => {
initialTime = count * 1000 + 5000
// console.log(count, initialTime)
// 重试时间间隔、默认5000,线性增长、最大增长5min、总重试时间12h
// y= kx+b k、b为常数、by轴的偏移量
// an = a1+(n-1) sn = n(a1+an)/2
// 420=>24h
console.log(err, '123')
return initialTime
}
}
})
import moment from 'moment'
export default {
filters: {
renderPath (path) {
path = String(path) || ''
const lastIndex = path.lastIndexOf('/')
return path.substr(0, lastIndex)
}
},
data () {
return {
continueArr: [],
finalList: [],
loadingBg: null,
putObjectNameArr: [],
timer: null,
myecharts: null,
datas_outer: [],
mockPutSize: 0,
needMock: false,
putSize: 0,
totalSize: 0,
createForm: {
folderName: ''
},
dirFlag: false,
form: {
hostName: '',
accessKeyId: '',
secretAccessKey: '',
// endpoint: 'http://10.0.2.154:8300',
endpoint: '',
path: '',
Bucket: ''
},
bucketList: [],
pageSize: 10,
currentPage: 1,
fileList: [],
fileListArr: [],
executeTime: '',
rules: {
// hostName: { required: true, message: '请输入hostName' },
accessKeyId: { required: true, message: '请输入accessKeyId' },
secretAccessKey: { required: true, message: '请输入secretAccessKey' },
endpoint: {
required: true,
validator: (_, val, cb) => {
const reg = /^(http:\/\/)?(.)*/
if (!val) {
return cb('请输入endpoint')
} else if (reg.test(val)) {
if (val.indexOf('http://') === -1) {
// 匹配http://替换
const regPrefix = /(h)?(t)?(t)?(p)?(:)?(\/)?(\/)?/
const matchStr = val.match(regPrefix)?.[0]
this.form.endpoint = 'http://' + val.substring(matchStr.length)
} else {
return cb()
}
}
}
},
Bucket: { required: true, message: '请选择bucket' }
},
S3: null,
noBucket: false,
getHostLoading: false,
uploadSizeLimt: 5 * 1024 ** 3, // 上传文件大小限制 1T
uploadPartSize: 1024 * 1024 * 5, // 分段大小&&文件启用分段大小
sizeError: [],
enableReUpload: true,
readFileList: [],
showDrop: false
}
},
computed: {
renderPadding () {
return this.fileListArr.length ? {
padding: '50px 10px'
} : {
padding: '150px 20px'
}
},
options () {
return {
tooltip: {
show: false
},
title: {
// text超出最大数字16位
text: this.renderLoadingText(),
x: 'center',
y: 'center',
textStyle: {
color: '#fff',
fontSize: '30px' // 中间标题文字大小设置
}
},
series: [
{
name: '完成情况外层',
type: 'pie',
padAngle: 5,
// radius: ['40%', '60%'],
radius: ['52%', '75%'],
center: ['50%', '50%'],
clockwise: false,
data: this.datas_outer,
// startAngle: 100,
hoverAnimation: false,
legendHoverLink: false,
label: {
show: false
},
labelLine: {
show: false
}
}
]
}
}
},
watch: {
dirFlag (val) {
if (val) {
this.$nextTick(() => {
this.$refs['uploadFile'].clearFiles()
})
this.enableDrop()
this.fileListArr = []
this.datas_outer = []
for (let i = 30; i > 0; i--) {
this.datas_outer.push({
value: 1, // 占位用
name: '未完成',
itemStyle: { color: '#19272e' }
})
}
} else {
this.mockPutSize = 0
this.needMock = false
this.myecharts = null
this.continueArr = []
this.putSize = 0 // 记录进度
this.totalSize = 0
this.readFileList = []
catchNetFail = false
clearTimeout(this.timer)
this.releaseDisable()
this.disableDrop()
// this.doClearFileLog()
}
}
},
mounted () {
// AWS.events.on('send', (req) => {
// console.log('req', req)
// if (req.retryCount > 5) {
// this.S3.uploadPart(
// { ...req.request.params }
// , (error, success) => {
// console.log(error, success, req)
// })
// }
// })
// this.needMock = true
// this.myecharts = this.$echarts.init(document.getElementById('loadChart'))
// this.renderChartPart()
// setTimeout(async () => {
// await this.renderLoadingChart(5)
// }, 1000)
document.addEventListener('keydown', function (event) {
if (event.code === 'Escape') {
event.preventDefault() // 取消默认行为
}
})
const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
this.form.accessKeyId = accessKeyId
// || 'http://10.0.2.153:9000'
this.form.endpoint = endpoint
// || 'http://10.0.2.153:9000'
// this.form.secretAccessKey = 'minioadmin'
// setTimeout(() => {
// this.getBucketList()
// })
this.init()
// get HostName、默认传递
// setTimeout(() => {
// this.getBucketList()
// // this.init()
// })
},
destroyed () {
clearTimeout(this.timer)
},
methods: {
dragEnterHandler (e) {
e.preventDefault()
if (!this.showDrop) {
this.showDrop = true
}
},
dragLeaveHandler (e) {
e.preventDefault()
e.relatedTarget || (this.showDrop = false)
// e.relatedTarget有效值仍在界面内
},
dropHandler (e) {
e.preventDefault()
this.showDrop = false
},
renderLoadingText () {
return this.needMock ? String(this.mockPutSize).replace('.00', '') + '%' : Number((this.putSize / this.totalSize) * 100).toFixed(2).replace('.00', '') + '%'
},
async renderLoadingChart (timeSeconds, initialValue = 0) {
// console.log(timeSeconds, 123)
const totalValue = initialValue ? 100 - initialValue : 100
const res = this.getMockTime(timeSeconds, totalValue)
this.mockPutSize = initialValue == 100 ? 100 : initialValue + Number(res[0]).toFixed(2)
this.renderChartPart()
// 比如5s
for (let i = 1; i <= timeSeconds; i++) {
await new Promise(resolve => {
setTimeout(() => {
// 这里放置每隔一秒执行的代码
this.mockPutSize = Number(res.shift()).toFixed(2)
this.renderChartPart()
// 进度 xdata 最终是 100
resolve(i)
}, 1000) // i * 1000 表示每次延迟 i 秒
})
}
return Promise.resolve(true)
},
renderChartPart () {
//
var num = 30 // 定义小块个数
var rate = this.needMock ? this.mockPutSize / 100 : this.putSize / this.totalSize // 完成率
const count = rate * 30
//
// 填充
for (let i = 1; i <= num; i++) {
if (i <= count) {
this.datas_outer[num - i].itemStyle.color = '#ff8746'
} else {
this.datas_outer[num - i].itemStyle.color = '#19272e'
}
}
this.myecharts && this.myecharts.setOption(this.options)
if (this.needMock) {
clearTimeout(this.timer)
} else {
this.timer = setTimeout(() => {
this.renderChartPart()
}, 1000)
}
},
getMockTime (totalTime, count) {
const res = []
count = count || 100
function nonLinearIncrease (currentTime, totalTime) {
// 非线性增长函数,这里使用了sin函数作为示例
const progress = Math.sin((Math.PI / 2) * (currentTime / totalTime))
const result = progress * count
return result
}
// 测试函数,模拟从0到100的非线性增长过程
function testNonLinearIncrease (totalTime) {
for (let t = 1; t <= totalTime; t++) {
const value = nonLinearIncrease(t, totalTime)
res.push(value)
// console.log(`Time: ${t}, Value: ${value}`)
}
return res
}
return testNonLinearIncrease(totalTime)
},
handlePutPath (file) {
const {
webkitRelativePath,
relativePath
} = file
return webkitRelativePath || relativePath
},
cleafFile () {
this.fileListArr = [] // 清除表格展示
this.$refs['uploadFile'].clearFiles() // 清除组件FileList
},
handleRequest (val) {
// 无功能、为自定义请求触发 beforeUpload校验文件
// console.log(val, '123')
},
handleSizeValidate (file) {
const size = file.size
const isExist = this.fileListArr.findIndex(x => {
return x.name === file.name && (x.webkitRelativePath || x.relativePath) === (file.webkitRelativePath || file.relativePath)
})
if (isExist > -1 || size > this.uploadSizeLimt) {
return false
}
this.fileListArr.push(file)
},
init () {
// const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
// this.form.accessKeyId = accessKeyId
// this.form.endpoint = endpoint
// this.form.hostName = 'Dc'
this.getHostLoading = true
getHostname().then(res => {
this.form.hostName = res.data || ''
}).finally(() => {
this.getHostLoading = false
const { accessKeyId = '', endpoint = '' } = JSON.parse(localStorage.getItem('form')) || {}
this.form.accessKeyId = accessKeyId
this.form.endpoint = endpoint
})
},
removeItem (row) {
const index = this.fileListArr.findIndex(x => x.relativePath === row.row.relativePath && x.name === row.row.name)
this.fileListArr.splice(index, 1)
// 最后一页删除后、切到1
if (this.fileListArr.length / this.pageSize <= 1) {
this.currentPage = 1
} else if (Math.ceil(this.fileListArr.length / this.pageSize) < this.currentPage) {
this.currentPage = this.currentPage - 1
}
},
handleSizeChange (val) {
this.pageSize = val
},
handleCurrentChange (val) {
this.currentPage = val
},
async handleMultUpload (fileArr) {
const {
Bucket,
hostName,
accessKeyId,
endpoint
} = this.form
// const arr = localStorage.getItem('fileList')
// if (!arr) localStorage.setItem('fileList', '[]')
// this.readFileList = JSON.parse(arr || '[]')
// 此处做断点续传
// return PromiseMultiple
// 此处不能统一执行、依次加入任务队列
const asyncTask = (file) => {
const fileSize = file.size
// 大于5GB、分片10m、500、
let chunkSize = ''
if (fileSize > 1024 * 1024 * 1024 * 10) {
chunkSize = 1024 * 1024 * 10
} else if (fileSize > 1024 * 1024 * 1024 * 5) {
chunkSize = 1024 * 1024 * 8
} else {
chunkSize = 1024 * 1024 * 5
}
// > 1000 ? 1024 * 1024 * 10 : this.uploadPartSize //5MB
const chunks = Math.ceil(fileSize / chunkSize)
const Key = hostName + '/' + this.handlePutPath(file)
file['Key'] = Key
return new Promise((resolve, rejected) => {
// 检测文件检测失败重传
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
const isExistReUploadPart = this.readFileList.find(x => {
return x.Key === Key && x.Bucket === Bucket && x.accessKeyId === accessKeyId && x.endpoint === endpoint
})
// console.log(
// isExistReUploadPart, '分片续传'
// )
// 存在文件的分片、调用listPart获取已上传的分片、并在下面的上传分片中跳过已有的分片
// 触发前置条件、 分片处理完添加分片到fileList上传列表、处理成功移除、
// 故递归处理的函数在此只有分片失败会进入此、createMultipartUpload和complete不会在此处理?
if (isExistReUploadPart) {
const {
UploadId
} = isExistReUploadPart
// 存在切片、在有效期且开启续传
const params = {
Bucket,
Key,
UploadId
}
this.S3.listParts(params, (err, data) => {
// 不存在err、complete完还有part、上传大文件失败
// console.log(data, 'err', err)
if (!err) {
// console.log(data.Parts, '===已上传的分片===')
// 分片续传、data.Parts 参数为已上传的分片list、需重置非空
isExistReUploadPart.parts = data.Parts || [];
(async () => {
const {
parts
} = isExistReUploadPart
let multiplePart = []
const listPartFin = []
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const doneUploadSize = end - start
const body = file.slice(start, end)
const PartNumber = chunkCount + 1
const reqParams = {
PartNumber,
Body: body,
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId
}
const jumpPass = parts.some(x => x.PartNumber === chunkCount + 1)
if (jumpPass) {
this.putSize += doneUploadSize
if (chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.doneUploadSize
: cur.reason.doneUploadSize
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
continue
} else {
continue
// 此处continue跳过已有的循环、若为最后循环、需等待任务队列结束并拿到分片结果
}
}
const p = new Promise((res, rej) => {
this.S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej({ ...uploadPartErr, doneUploadSize })
else res({ ...uploadPartData, doneUploadSize, PartNumber })
})
})
multiplePart.push(p)
if (multiplePart.length == 5 || chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.doneUploadSize
: cur.reason.doneUploadSize
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
}
}
// console.log(listPartFin, '=====剩下的分片=====')
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
const Parts = [...listPartFin, ...parts]
.map(x => {
return {
PartNumber: x.PartNumber || x.value.PartNumber,
ETag: x.ETag || x.value.ETag
}
})
.sort((a, b) => a.PartNumber - b.PartNumber)
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: isExistReUploadPart.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === isExistReUploadPart.UploadId
})
this.readFileList.splice(delIndex, 1)
resolve(compErrData)
}
// console.log(compErr, compErrData)
})
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const uploadPartError = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: uploadPartError,
file,
startTime,
endTime
})
// handle reUploadPart
}
})()
} else {
this.putSize += fileSize
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({ err, file, startTime, endTime })
}
})
} else {
// 处理上传进度
this.S3.createMultipartUpload({
Bucket,
Key
}, async (createErr, createData) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (createErr) {
// console.error('Error creating multipart upload:', createErr)
this.putSize += fileSize
// console.log(createErr, 'listPartFin')
rejected({ err: createErr, file, startTime, endTime })
} else {
let multiplePart = []
// writeAbortLog
this.readFileList.push({
accessKeyId,
endpoint,
Bucket,
Key,
UploadId: createData.UploadId
})
const listPartFin = []
// 此处同步的所以有问题了vuex先缓存一下
// endWrite 此处记录及最终Promise处处理完成判断、清楚记录或执行abortMultiple
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, fileSize)
const doneUploadSize = end - start
const body = file.slice(start, end)
const reqParams = {
PartNumber: chunkCount + 1,
Body: body,
Bucket,
Key,
UploadId: createData.UploadId
}
const p = new Promise((res, rej) => {
// if (chunkCount > chunks - 2) {
// reqParams.Bucket = '666'
// }
this.S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej({ ...uploadPartErr, doneUploadSize })
else res({ ...uploadPartData, doneUploadSize })
})
})
multiplePart.push(p)
if (multiplePart.length == 3 || chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
// console.log(partRes, '123')
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.doneUploadSize
: cur.reason.doneUploadSize
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
}
}
// uploadPart End
const partOver = listPartFin.every(x => x.status === 'fulfilled')
if (partOver) {
// listParts
// var params = {
// Bucket,
// Key,
// UploadId: createData.UploadId
// }
const Parts = listPartFin.map((x, i) => {
return {
PartNumber: i + 1,
ETag: x.value.ETag
}
})
this.S3.completeMultipartUpload({
Bucket,
Key,
UploadId: createData.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === createData.UploadId
})
this.readFileList.splice(delIndex, 1)
resolve(compErrData)
}
})
// this.S3.listParts(params, (partErr, partRes) => {
// const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
// if (partErr) {
// rejected({
// err: partErr,
// file,
// startTime,
// endTime
// })
// } else {
// const Parts = partRes.Parts.map(x => {
// return {
// PartNumber: x.PartNumber,
// ETag: x.ETag
// }
// }).sort((a, b) => a.PartNumber - b.PartNumber)
// // finish
// this.S3.completeMultipartUpload({
// Bucket,
// Key,
// UploadId: createData.UploadId,
// MultipartUpload: { Parts }
// }, (compErr, compErrData) => {
// if (compErr) {
// const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
// rejected({
// err: compErr,
// file,
// startTime,
// endTime
// })
// } else {
// resolve(compErrData)
// }
// // console.log(compErr, compErrData)
// })
// }
// })
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const err = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: err,
file,
startTime,
endTime
})
// handle reUploadPart
}
}
})
}
})
}
const finalList = []
while (fileArr.length) {
const file = fileArr.shift()
finalList.push(await asyncTask(file).catch(err => {
return {
hasError: true,
err
}
}))
}
//
return finalList[0].hasError ? Promise.reject(finalList) : Promise.resolve(finalList)
},
postFolder (type) {
if (type === 'file') {
document.querySelector('.el-upload__input').webkitdirectory = false
} else {
document.querySelector('.el-upload__input').webkitdirectory = true
}
},
releaseDisable () {
document.oncontextmenu = function () { }
document.onkeydown = function (event) { }
window.onbeforeunload = function () { }
},
enableDrop () {
window.addEventListener('dragenter', this.dragEnterHandler)
window.addEventListener('dragleave', this.dragLeaveHandler)
window.addEventListener('drop', this.dropHandler)
},
disableDrop () {
window.removeEventListener('dragenter', this.dragEnterHandler)
window.removeEventListener('dragleave', this.dragLeaveHandler)
window.removeEventListener('drop', this.dropHandler)
},
resetForm (formName) {
if (this.$refs[formName] != undefined) {
this.$refs[formName].resetFields()
}
},
onDrop (e) {
e.preventDefault()
const dataTransfer = e.dataTransfer
if (
dataTransfer.items &&
dataTransfer.items[0] &&
dataTransfer.items[0].webkitGetAsEntry
) {
this.webkitReadDataTransfer(dataTransfer)
}
},
webkitReadDataTransfer (dataTransfer) {
let fileNum = dataTransfer.items.length
const files = []
this.loading = true
// 递减计数,当fileNum为0,说明读取文件完毕
const decrement = () => {
if (--fileNum === 0) {
this.handleFiles(files)
this.loading = false
}
}
// 递归读取文件方法
const readDirectory = (reader) => {
// readEntries() 方法用于检索正在读取的目录中的目录条目,并将它们以数组的形式传递给提供的回调函数。
reader.readEntries((entries) => {
if (entries.length) {
fileNum += entries.length
entries.forEach((entry) => {
if (entry.isFile) {
entry.file((file) => {
readFiles(file, entry.fullPath)
}, readError)
} else if (entry.isDirectory) {
readDirectory(entry.createReader())
}
})
readDirectory(reader)
} else {
decrement()
}
}, readError)
}
// 文件对象
const items = dataTransfer.items
// 拖拽文件遍历读取
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry()
if (!entry) {
decrement()
return
}
if (entry.isFile) {
// 读取单个文件
return
// readFiles(items[i].getAsFile(), entry.fullPath, 'file')
} else {
// entry.createReader() 读取目录。
readDirectory(entry.createReader())
}
}
function readFiles (file, fullPath) {
file.relativePath = fullPath.substring(1)
files.push(file)
decrement()
}
function readError (fileError) {
throw fileError
}
},
handleFiles (files) {
// 按文件名称去存储列表,考虑到批量拖拽不会有同名文件出现
const dirObj = {}
// console.log(files, '1233')
// return
files.forEach((item) => {
// relativePath 和 name 一致表示上传的为文件,不一致为文件夹
// 文件直接放入table表格中
// 仍需考虑去重问题
const isExist = this.fileListArr.findIndex(x => x.name === item.name && (x.webkitRelativePath || x.relativePath) === (item.webkitRelativePath || item.relativePath))
if (isExist > -1 || item.size > this.uploadSizeLimt) {
return
// this.fileListArr.splice(isExist, 1)
}
this.fileListArr.push(item)
// if (item.relativePath === item.name) {
// this.tableData.push({
// name: item.name,
// filesList: [item.file],
// isFolder: false,
// size: item.size
// })
// }
// // 文件夹,需要处理后放在表格中
// if (item.relativePath !== item.name) {
// const filderName = item.relativePath.split('/')[0]
// if (dirObj[filderName]) {
// // 放入文件夹下的列表内
// const dirList = dirObj[filderName].filesList || []
// dirList.push(item)
// dirObj[filderName].filesList = dirList
// // 统计文件大小
// const dirSize = dirObj[filderName].size
// dirObj[filderName].size = dirSize ? dirSize + item.size : item.size
// } else {
// dirObj[filderName] = {
// filesList: [item],
// size: item.size
// }
// }
// }
})
// 放入tableData
Object.keys(dirObj).forEach((key) => {
this.tableData.push({
name: key,
filesList: dirObj[key].filesList,
isFolder: true,
size: dirObj[key].size
})
})
},
validateBucket () {
if (!this.form.Bucket) {
if (this.bucketList.length) {
this.$notify({
type: 'error',
title: '请选择一个bucket'
})
} else {
if (this.noBucket) {
this.$notify({
type: '无bucket可用,请先创建bucket'
})
} else {
this.$notify({
type: 'error',
title: '请点击“连接”按钮,设置bucket'
})
}
}
} else {
this.$refs['form'].validate((valid) => {
if (valid) {
document.onkeydown = function (event) {
var e = event || window.event || arguments.callee.caller.arguments[0]
if (e && e.keyCode == 116) {
return false
}
}
window.onbeforeunload = function (e) {
// 兼容ie
// 触发条件 产生交互、当前不支持自定义文字
e = e || window.event
if (e) e.returnValue = 'none'
return 'none'
}
document.oncontextmenu = function () { return false }
this.dirFlag = true
const { endpoint, accessKeyId } = this.form
localStorage.setItem('form', JSON.stringify({
endpoint,
accessKeyId
}))
}
})
}
},
getBucketList () {
const {
accessKeyId,
secretAccessKey,
endpoint
} = this.form
if (!endpoint) {
this.$notify({
type: 'error',
title: '请输入endpoint'
})
return
}
if (!accessKeyId) {
this.$notify({
type: 'error',
title: '请输入Access Key'
})
return
}
if (!secretAccessKey) {
this.$notify({
type: 'error',
title: '请输入Secret Key'
})
return
}
this.S3 = new AWS.S3({
accessKeyId,
secretAccessKey,
endpoint,
region: 'EastChain-1',
s3ForcePathStyle: true
})
this.S3.listBuckets((err, data) => {
if (err) {
// console.dir(err)
// console.log('%c 123', 'color:red;font-size:20px')
// "NetworkingError"
let title = ''
let message = ''
if (err.code === 'AccessDenied') {
title = '连接S3失败'
message = '请检查ak/sk是否输入正确'
} else if (Number(err.code) === 12) {
title = '网络异常'
message = '请检查endpoint是否正确'
} else if (err.code === 'NetworkingError') {
title = '网络异常'
message = '请检查endpoint是否正确,或稍后再试'
} else {
title = '连接S3失败'
message = this.$trans(err.message || '')
}
// console.dir(err, 'err')
this.$notify({
type: 'error',
title,
message,
showClose: false,
customClass: 'errorTip'
})
this.noBucket = false
this.bucketList = []
this.form.Bucket = ''
} else {
this.bucketList = (data.Buckets || []).map(x => x.Name)
if (!this.bucketList.length) {
this.$notify({
type: 'error',
title: '无bucket可用,请先创建bucket'
})
this.noBucket = true
} else {
this.form.Bucket = this.bucketList[0]
this.$notify({
type: 'success',
title: '连接S3成功'
})
// this.form.Bucket = 'test'
// 确保断网或刷新页面导致未完成的上传记录清除
// this.doClearFileLog()
}
}
})
},
doClearFileLog () {
const {
accessKeyId,
endpoint
} = this.form
const keyList = JSON.parse(JSON.stringify(this.readFileList))
// console.log(keyList, 'null')
const doAbortTasks = keyList.map((x, i) => {
return new Promise((resolve, rejected) => {
if (accessKeyId === x.accessKeyId && endpoint === x.endpoint) {
// 一致性确保listPart正常
const params = {
Bucket: x.Bucket,
Key: x.Key,
UploadId: x.UploadId
}
this.S3.listParts(params, (err, data) => {
// 不存在err、complete完还有part、上传大文件失败
// console.log(data, 'err', err)
if (!err) {
// doAbort
const reUpload = data.Parts && data.Parts.length > 0
// console.log(data, '1233', reUpload)
if (reUpload && this.enableReUpload) {
keyList[i].reUpload = true
keyList[i].parts = data.Parts
const expireTime = keyList[i].expireTime
if (expireTime) {
if (
expireTime < moment().valueOf()) {
this.S3.abortMultipartUpload(params, (err, data) => {
if (!err) {
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
} else {
resolve('keepReUpload')
}
} else {
keyList[i].expireTime = moment().add(15, 'day').valueOf()
resolve('reUpload')
}
// 有切片需要支持后续上传
} else {
this.S3.abortMultipartUpload(params, (err, data) => {
if (!err) {
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
}
} else {
// 此处问题、
keyList[i].delete = true
resolve('clearTask')
// 清除该条记录
}
})
} else {
rejected('notMatch')
// noThingTodo
}
})
})
Promise.allSettled(doAbortTasks).then(res => {
// localStorage.setItem('fileList', JSON.stringify(iterateArr))
// 结束清理status为删除的
const fileList = keyList.filter(x => x.delete !== true)
this.readFileList = []
localStorage.setItem('fileList', JSON.stringify(fileList))
// console.log('checkOver', keyList, localStorage.getItem('fileList'))
})
},
confirmPut () {
const {
Bucket,
hostName
} = this.form
this.finalList = []
const putObjectArr = []
const multUploadArr = []
this.disableDrop()
document.querySelector('#loadChart').style.display = 'block'
const judgeUploadType = async () => {
// 文件分流
this.fileListArr.forEach(x => {
this.totalSize = this.totalSize + x.size
if (x.size <= this.uploadPartSize) {
putObjectArr.push({
Bucket,
Key: hostName + '/' + this.handlePutPath(x),
Body: x
})
} else {
multUploadArr.push(x)
}
})
}
// 区分大文件
(async () => {
await judgeUploadType()
// startPutObject
// console.log(putObjectArr, multUploadArr)
// return
this.loadingBg = this.$loading({
lock: true,
text: '文件上传中,请勿关闭当前页面',
spinner: 'el-icon-loading',
background: 'rgba(1,1,1,.3)',
customClass: 'putLoading'
})
try {
const putObjectFin = []
// const taskList = []
this.putObjectNameArr = []
const needMock = multUploadArr.length === 0
// const putObjectStart = +new Date()
// const putObjectCount = putObjectArr.length
this.needMock = needMock && putObjectArr.length <= 5
this.myecharts = this.$echarts.init(document.getElementById('loadChart'))
this.renderChartPart()
//
const _this = this
let taskList = []
const len = this.fileListArr.length
for (let i = 0; i < len; i++) {
const file = this.fileListArr[i]
let asyncTask = null
if (file.size <= this.uploadPartSize) {
asyncTask = new Promise((res, rej) => {
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
_this.S3.putObject({
Bucket,
Key: hostName + '/' + _this.handlePutPath(file)
}, (err, data) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (err) rej({ err, file, startTime, endTime })
else res({ success: 'success', file, startTime, endTime })
})
})
} else {
asyncTask = this.handleMultUpload([file])
// 与putObject区分
}
taskList.push(asyncTask)
if (taskList.length == 2 || i === len - 1) {
const partRes = await Promise.allSettled(taskList)
this.putSize += partRes.reduce((pre, cur) => {
if (cur.status === 'fulfilled' && !Array.isArray(cur.value)) {
pre += cur.value.file.size
}
return pre
}, 0)
putObjectFin.push(...partRes)
taskList = []
}
}
setTimeout(() => {
_this.releaseDisable()
_this.loadingBg.close()
console.log(putObjectFin)
// console.log(result, 'result')
_this.writeErrorLog(putObjectFin)
}, 1200)
} catch (error) {
console.log('errorOperate', error)
}
})()
},
writeErrorLog (result) {
// console.log(result, '================')
const file = result
// console.log(result, '123')
const failList = file.filter(x => x.status === 'rejected' || (x.hasError))
// .map(x => {
// // while大文件异步promise格式化
// if (x.hasError) {
// x.reason = x.err
// }
// return x
// })
// const log = failList.reduce((pre, cur, i) => {
// return pre + '结束时间:' + cur.reason.endTime + ' ' + '对象Key: ' + cur.reason.file.Key + ' ' + ' ' + '错误原因: ' + cur.reason.err.message + '\n'
// }, '')
const total = file.length
const failCount = failList.length
const successCount = total - failCount
// console.log('===============', failList, log, result)
if (failCount && failCount > 0) {
this.$notify({
title: '上传完成',
dangerouslyUseHTMLString: true,
type: 'success',
message: `<p>
<strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
<br/>
</p>`
})
this.dirFlag = false
// upload({
// log
// }).then(res => {
// this.$notify({
// title: '上传完成',
// dangerouslyUseHTMLString: true,
// type: 'success',
// message: `<p>
// <strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
// <br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
// <br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
// <br/> <span style="color:#d3d6d8;font-size:15px">请到备份历史查看详情</span>
// </p>`
// })
// }).finally(() => {
// this.dirFlag = false
// })
} else {
this.$notify({
title: '上传完成',
dangerouslyUseHTMLString: true,
type: 'success',
message: `<p>
<strong style="color:#d3d6d8;font-size:15px">总计: ${total}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">成功: ${successCount}个</strong>
<br/> <strong style="color:#d3d6d8;font-size:15px">失败: ${failCount}个</strong>
</p>`
})
this.dirFlag = false
}
},
async continueUpload (putObjectFin, multUploadArr) {
// case putObject
this.loadingBg = this.$loading({
lock: true,
text: '文件上传中,请勿关闭当前页面',
spinner: 'el-icon-loading',
background: 'rgba(1,1,1,.3)',
customClass: 'putLoading'
})
const {
hostName
} = this.form
let taskList = []
const needMock = multUploadArr.length === 0
const putObjectStart = +new Date()
this.needMock = needMock && this.continueArr.length <= 5
// putObject 待上传的文件、
const count = this.continueArr.length
for (let i = 0; i < count; i++) {
const file = this.continueArr[i]
// i > 0 ? file.Bucket = '123' : null
const p = new Promise((res, rej) => {
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
this.S3.putObject(file, (err, data) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (err) rej({ err, file, startTime, endTime })
else res({ success: 'success', file, startTime, endTime })
})
})
taskList.push(p)
if (taskList.length == 5 || i === count - 1) {
const partRes = await Promise.allSettled(taskList)
// 罗列list 记录上传后的状态
// 存在reject newworkFailure 停止当前for循环putObject
if (partRes.some(x => x.status === 'rejected' && x.reason.err.code === 'NetworkingError')) {
break
} else {
console.log(partRes, '1233')
// 记录上传成功及非网络异常导致的上传失败
this.putObjectNameArr.push(...partRes.map(x => {
return x.status === 'fulfilled'
? hostName + '/' + this.handlePutPath(x.value.file.Body)
: hostName + '/' + this.handlePutPath(x.reason.file.Body)
}))
}
this.putSize += partRes.reduce((pre, cur) => {
pre += cur.status === 'fulfilled' ? cur.value.file.Body.size
: cur.reason.file.Body.size
return pre
}, 0)
putObjectFin.push(...partRes)
taskList = []
}
}
this.continueArr = this.continueArr.filter(x => {
return this.putObjectNameArr.every(y => {
return x.Key !== y
})
})
if (this.continueArr.length) {
this.loadingBg.close()
setTimeout(() => {
// 判断网络连接情况
this.$confirm('恢复上传对象', '请确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
closeOnClickModal: false,
closeOnPressEscape: false,
type: 'warning',
dangerouslyUseHTMLString: true
}).then(() => {
this.continueUpload(putObjectFin, multUploadArr)
})
}, 1000)
return
}
if (this.needMock) {
const putObjectEnd = +new Date()
const timeSeconds = Math.ceil((putObjectEnd - putObjectStart) / 1000)
await this.renderLoadingChart(timeSeconds)
}
const multipleObjects = await this.handleMultUpload(multUploadArr)
// console.log(this.totalSize, this.putSize, 'fin')
setTimeout(() => {
this.releaseDisable()
this.loadingBg.close()
// console.log(result, 'result')
this.writeErrorLog([...multipleObjects, ...putObjectFin])
}, 1200)
},
handleRemoveErrorUpload (arr) {
arr = JSON.parse(JSON.stringify(arr))
const ErrorConnect = arr.filter(x => x.hasError && x.err.err.code === 'NetworkingError')
if (ErrorConnect.length) {
const index = arr.findIndex(x => {
return x.Key === ErrorConnect[0].err.file.Key
})
arr.splice(index, 1)
}
return arr
}
}
}
</script>
<style lang="scss" scoped>
:deep(.form) {
label.el-form-item__label {
margin-left: 0 !important;
width: 150px !important;
}
.el-select {
width: 100%;
}
}
:deep(.el-dialog) {
.icon {
cursor: pointer;
font-size: 17px;
margin: 0 18px 0 3px;
vertical-align: middle !important;
}
}
:deep(.errorTip) {
background-color: aqua !important;
width: fit-content !important;
.el-notification__group {
.el-notification__content {
p {
color: #d3d6d8;
}
}
}
}
.el-icon-upload {
font-size: 40px;
margin: 0;
}
.el-upload__text {
display: flex;
align-items: center;
justify-content: center;
font-size: 25px;
margin: 40px 10px;
line-height: 25px;
text-align: center;
color: #d3d6d8;
}
.addFiles {
color: #337dff;
}
.drag {
width: 100%;
margin-top: 10px;
}
.el-table {
max-height: 600px;
overflow-y: auto;
}
#loadChart {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<style lang="scss">
.putLoading {
.el-loading-spinner {
position: fixed;
top: 10%;
left: 50%;
width: fit-content;
transform: translate(-50%);
}
.el-loading-spinner i {
font-size: 25px;
}
.el-loading-text {
font-size: 25px;
}
}
.uploadMenu {
width: fit-content;
display: flex;
justify-content: flex-start;
.el-button {
font-size: 25px;
height: 60px;
width: 120px;
padding: 15px;
margin-right: 50px;
box-sizing: border-box;
}
}
.picker__drop-zone {
position: fixed;
box-sizing: border-box;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: hsla(0, 0%, 100%, 0.9);
border: 6px solid #ff8746;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.anim-floating {
animation-name: anim-floating-6a50ffaa;
animation-duration: 1s;
animation-iteration-count: infinite;
}
.picker__drop-zone-label {
margin-top: 30px;
font-size: 25px;
color: #333;
}
.drop-arrow {
display: inline-block;
div {
display: block;
background-repeat: no-repeat;
background-position: 50%;
}
.arrow {
width: 38.68px;
height: 63.76px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 38.68 63.76' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M34.2 42.63 21.68 55V3c0-1.42-.88-3-2.34-3a3 3 0 0 0-2.66 3v52L4.47 42.63a2.68 2.68 0 0 0-1.85-.76 2.57 2.57 0 0 0-1.85.76 2.51 2.51 0 0 0 0 3.63L17.49 63a2.7 2.7 0 0 0 1.85.76 2.58 2.58 0 0 0 1.85-.76l16.72-16.75a2.51 2.51 0 0 0 0-3.63 2.69 2.69 0 0 0-3.7 0Zm0 0' fill='%23333'/%3E%3C/svg%3E");
margin-left: auto;
margin-right: auto;
margin-bottom: 0;
}
.base {
width: 88.98px;
height: 28.61px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 88.98 28.61' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M86.42.38A2.26 2.26 0 0 0 84 2.73v9.07a12.34 12.34 0 0 1-12 11.93H15.78C9.44 23.73 5 18.05 5 11.73V2.28A2.22 2.22 0 0 0 2.56 0 2.55 2.55 0 0 0 0 2.56v9.45a16.48 16.48 0 0 0 16.44 16.6h56.22A16.38 16.38 0 0 0 89 12.02V2.95A2.35 2.35 0 0 0 86.72.38h-.28z' fill='%23333'/%3E%3C/svg%3E");
}
}
}
@keyframes anim-floating-6a50ffaa {
0% {
transform: translateY(0);
}
50% {
transform: translateY(25%);
}
to {
transform: translateY(0);
}
}
</style>
# 完整上传组件化监控进度
store/download.js
const state = {
downLoadTaskLists: [], // 存储下载任务列表
downLoadTaskQueue: [], //控制进度
downLoadAbortController: {}, // 存储中止请求的控制器
};
const getters = {
downLoadTaskLists: (state) => state.downLoadTaskLists,
downLoadTaskQueue: (state) => state.downLoadTaskQueue
};
const mutations = {
ADD_DOWNLOAD_TASK (state, task) {
state.downLoadTaskLists.push(task);
},
ADD_DOWNLOAD_QUEUE (state, task) {
state.downLoadTaskQueue.push(task);
},
};
const actions = {
addTask ({ commit }, task) {
commit('ADD_TASK', task);
},
};
export default {
state,
getters,
mutations,
actions,
};
store/upload.js
const state = {
uploadTaskLists: [], // 存储下载任务列表
uploadTaskQueue: [], //控制进度
uploadAbortController: {}, // 存储中止请求的控制器
};
const getters = {
uploadTaskLists: (state) => state.uploadTaskLists,
// .concat([
// { "taskName": "testDownload/postman-win64-9.31.30.rar", "execSize": 15728640, "totalSize": 119722352, "uniqueKey": 1750039264853, "execCount": 0, "totalCount": 1, "pending": false }
// ]),
uploadTaskQueue: (state) => state.uploadTaskQueue
};
const mutations = {
ADD_UPLOAD_TASK (state, task) {
state.uploadTaskLists.push(task);
},
ADD_UPLOAD_QUEUE (state, task) {
state.uploadTaskQueue.push(task);
},
};
const actions = {
addTask ({ commit }, task) {
commit('ADD_TASK', task);
},
};
export default {
state,
getters,
mutations,
actions,
};
store引入download、upload
module:{
download,
upload
}
<el-button class="golden" @click="uploadFile">上传</el-button>
<el-dialog title="上传" :visible.sync="dirFlag" width="65%" destroy-on-close :close-on-press-escape="false"
:close-on-click-modal="false">
<el-form ref="createForm" :model="createForm" size="mini" label-width="150px"
style="padding:0 5%;position:relative">
<el-row class="uploadMenu">
<el-upload ref="uploadFile" action="#" :http-request="() => { }" multiple :show-file-list="false"
:before-upload="handleSizeValidate">
<!-- <el-button
size="small"
class="golden"
@click="postFolder('file')"
>上传文件</el-button> -->
<el-button size="small" class="golden" @click="postFolder('folder')">目录</el-button>
<el-button size="small" class="golden" @click="postFolder('file')">文件</el-button>
</el-upload>
<el-button class="blue" :disabled="!fileListArr.length" @click="cleafFile">清空</el-button>
</el-row>
<!-- <input type="file" id="upload" ref="inputer" name="file" multiple /> -->
<div draggable="true" class="drag tableBox" :style="renderPadding">
<div v-show="!fileListArr.length" class="el-upload__text">
<i class="el-icon-upload" style="margin-right: 6px" />点击上传或拖拽文件夹到此处
<!-- <el-button type="text" @click="addFiles">添加文件</el-button> -->
</div>
<div v-show="!fileListArr.length" class="el-upload__text">
<!-- 文件上传数量不能超过100个,总大小不超过5GB -->
单个文件大小不超过50GB
</div>
<el-table v-show="fileListArr.length"
:data="fileListArr.slice((currentPage - 1) * pageSize, currentPage * pageSize)"
style="max-height: 600px;overflow-y: auto;">
<el-table-column label="对象key" prop="name" min-width="120px" />
<el-table-column label="目录" min-width="120px">
<template slot-scope="scope">
{{ renderFileRelative(scope.row)
}}
</template>
</el-table-column>
<el-table-column label="类型" width="180px">
<template slot-scope="scope">
{{ scope.row.type }}
</template>
</el-table-column>
<el-table-column label="大小" width="120px">
<template slot-scope="scope">
{{ byteConvert(scope.row.size) }}
</template>
</el-table-column>
<el-table-column label="移除" width="100px">
<template slot-scope="scope">
<svg style="cursor: pointer;color: #f34e4e;" class="icon" @click="removeItem(scope)">
<use xlink:href="#icon-trash" />
</svg>
</template>
</el-table-column>
</el-table>
<el-pagination v-show="fileListArr.length" :current-page="currentPage" :page-sizes="[5, 10, 50, 100]"
:page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="fileListArr.length"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button class="golden" :disabled="fileListArr.length == 0" @click="confirmPut()">{{ $ts('button.confirm')
}}</el-button>
<el-button @click="dirFlag = false;">{{ $ts('button.cancel') }}</el-button>
</div>
<div id="loadChart" style="width:400px;height:300px;display:none" />
</el-dialog>
<div v-if="showDrop" class="picker__drop-zone" @dragover="(e) => e.preventDefault()" @drop="onDrop">
<div class="drop-arrow">
<div class="arrow anim-floating" />
<div class="base" />
</div>
<div class="picker__drop-zone-label">拖拽文件或文件夹到此处</div>
</div>
watch:{
dirFlag (val) {
if (val) {
this.$nextTick(() => {
this.$refs['uploadFile'].clearFiles()
})
this.enableDrop()
this.fileListArr = []
this.datas_outer = []
for (let i = 30; i > 0; i--) {
this.datas_outer.push({
value: 1, // 占位用
name: '未完成',
itemStyle: { color: '#19272e' }
})
}
} else {
this.currentPage = 1
this.pageSize = 10
this.mockPutSize = 0
// this.needMock = false
this.myecharts = null
this.continueArr = []
this.putSize = 0 // 记录进度
this.totalSize = 0
this.readFileList = [] // 记录大文件上传
this.finList = []
clearTimeout(this.timer)
this.releaseDisable()
this.disableDrop()
// this.doClearFileLog()
}
},
}
methods:{
releaseDisable () {
document.oncontextmenu = function () { }
document.onkeydown = function (event) { }
window.onbeforeunload = function () { }
},
enableDrop () {
window.addEventListener('dragenter', this.dragEnterHandler)
window.addEventListener('dragleave', this.dragLeaveHandler)
window.addEventListener('drop', this.dropHandler)
},
disableDrop () {
window.removeEventListener('dragenter', this.dragEnterHandler)
window.removeEventListener('dragleave', this.dragLeaveHandler)
window.removeEventListener('drop', this.dropHandler)
},
dragEnterHandler (e) {
e.preventDefault()
if (!this.showDrop) {
this.showDrop = true
}
},
dragLeaveHandler (e) {
e.preventDefault()
e.relatedTarget || (this.showDrop = false)
// e.relatedTarget有效值仍在界面内
},
dropHandler (e) {
e.preventDefault()
this.showDrop = false
},
uploadFile () {
this.enableDisable()
this.dirFlag = true
},
enableDisable () {
// 禁用 F5 刷新
document.onkeydown = function (event) {
var e = event || window.event || arguments.callee.caller.arguments[0];
if (e && e.keyCode == 116) {
return false;
}
};
// 添加关闭标签页提示
window.onbeforeunload = function (e) {
// 兼容ie
// 触发条件 产生交互、当前不支持自定义文字
e = e || window.event
if (e) e.returnValue = 'none'
return 'none'
}
},
postFolder (type) {
if (type === 'file') {
document.querySelector('.el-upload__input').webkitdirectory = false
} else {
document.querySelector('.el-upload__input').webkitdirectory = true
}
},
onDrop (e) {
e.preventDefault()
const dataTransfer = e.dataTransfer
if (
dataTransfer.items &&
dataTransfer.items[0] &&
dataTransfer.items[0].webkitGetAsEntry
) {
this.webkitReadDataTransfer(dataTransfer)
}
},
webkitReadDataTransfer (dataTransfer) {
// console.log(dataTransfer, 'datatransfer')
let fileNum = dataTransfer.items.length
const files = []
this.loading = true
// 递减计数,当fileNum为0,说明读取文件完毕
const decrement = () => {
if (--fileNum === 0) {
this.handleFiles(files)
this.loading = false
}
}
const readDirectory = (reader, fullPath) => {
reader.readEntries((entries) => {
if (entries.length) {
fileNum += entries.length
entries.forEach((entry) => {
if (entry.isFile) {
entry.file((file) => {
readFiles(file, entry.fullPath)
}, readError)
} else if (entry.isDirectory) {
readDirectory(entry.createReader(), entry.fullPath)
}
})
readDirectory(reader, fullPath)
} else {
// // 如果 entries 为空,表示这是一个空文件夹
// const filterDir = fullPath.split('/')
// if (filterDir.length > 2) {
// files.push({
// relativePath: fullPath.substring(1), isDirectory: true, name: '/' + filterDir.slice(2).join('/'),
// size: 0
// })
// }
decrement()
}
}, readError)
};
const items = dataTransfer.items;
// 拖拽文件遍历读取
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry()
if (!entry) {
decrement()
return
}
if (entry.isFile) {
readFiles(items[i].getAsFile(), entry.fullPath)
} else {
readDirectory(entry.createReader(), entry.fullPath)
}
}
function readFiles (file, fullPath) {
file.relativePath = fullPath.substring(1)
files.push(file)
decrement()
}
function readError (fileError) {
throw fileError
}
},
handleFiles (files) {
// 按文件名称去存储列表,考虑到批量拖拽不会有同名文件出现
files.forEach((item) => {
// relativePath 和 name 一致表示上传的为文件,不一致为文件夹
// 文件直接放入table表格中
// 仍需考虑去重问题
const isExist = this.fileListArr.findIndex(x => {
return !!(this.showFileDir(x)) ? (x.webkitRelativePath || x.relativePath) === (item.webkitRelativePath || item.relativePath) : x.name === item.name
})
if (isExist > -1) return false
if (item.size > this.uploadSizeLimt) {
this.$notify({
type: 'error',
text: '当前上传文件大于50G'
})
return false
}
this.fileListArr.push(item)
})
},
showPutFileKey (file) {
const {
webkitRelativePath,
relativePath
} = file
const hasDirPath = webkitRelativePath || relativePath
return (this.$route.query.filename || '') + (!!hasDirPath ? hasDirPath : file.name)
},
// 同步更新store、激活组件状态
confirmPut () {
try {
this.$nextTick(() => {
document.querySelector('.upload + span').classList.add('circleStatus')
})
//end
document.querySelector('.upload + span').classList.add('active')
// 刷新下载状态
setTimeout(() => {
document.querySelector('.upload + span').classList.remove('active')
}, 1000);
const uniqueKey = Date.now()
// 同步vuex
const totalSize = this.fileListArr.reduce((pre, cur) => pre + (cur.size || 0), 0)
const firstFile = this.fileListArr[0]
const taskName = this.fileListArr.length > 1 ? this.showPutFileKey(firstFile) + ' ... ' : this.showPutFileKey(firstFile)
// 展示进度
this.$store.commit('ADD_UPLOAD_TASK', {
taskName,
execSize: 0,
totalSize,
uniqueKey,
execCount: 0,
totalCount: this.fileListArr.length,
pending: true,
})
// 实际上传
this.$store.commit('ADD_UPLOAD_QUEUE', {
fileList: this.fileListArr,
prefix: this.$route.query.filename,
Bucket: this.$route.params.id,
taskName,
uniqueKey
})
this.dirFlag = false
this.$bus.$emit("upload")
} catch (error) {
console.log(error, '123')
}
},
}
<style>
.picker__drop-zone {
position: fixed;
box-sizing: border-box;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: hsla(0, 0%, 100%, 0.9);
border: 6px solid #ff8746;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.anim-floating {
animation-name: anim-floating-6a50ffaa;
animation-duration: 1s;
animation-iteration-count: infinite;
}
.picker__drop-zone-label {
margin-top: 30px;
font-size: 25px;
color: #333;
}
.drop-arrow {
display: inline-block;
div {
display: block;
background-repeat: no-repeat;
background-position: 50%;
}
.arrow {
width: 38.68px;
height: 63.76px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 38.68 63.76' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M34.2 42.63 21.68 55V3c0-1.42-.88-3-2.34-3a3 3 0 0 0-2.66 3v52L4.47 42.63a2.68 2.68 0 0 0-1.85-.76 2.57 2.57 0 0 0-1.85.76 2.51 2.51 0 0 0 0 3.63L17.49 63a2.7 2.7 0 0 0 1.85.76 2.58 2.58 0 0 0 1.85-.76l16.72-16.75a2.51 2.51 0 0 0 0-3.63 2.69 2.69 0 0 0-3.7 0Zm0 0' fill='%23333'/%3E%3C/svg%3E");
margin-left: auto;
margin-right: auto;
margin-bottom: 0;
}
.base {
width: 88.98px;
height: 28.61px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 88.98 28.61' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M86.42.38A2.26 2.26 0 0 0 84 2.73v9.07a12.34 12.34 0 0 1-12 11.93H15.78C9.44 23.73 5 18.05 5 11.73V2.28A2.22 2.22 0 0 0 2.56 0 2.55 2.55 0 0 0 0 2.56v9.45a16.48 16.48 0 0 0 16.44 16.6h56.22A16.38 16.38 0 0 0 89 12.02V2.95A2.35 2.35 0 0 0 86.72.38h-.28z' fill='%23333'/%3E%3C/svg%3E");
}
}
}
</style>
# 上传
upload.vue
<template>
<el-popover ref="uploadPopover" placement="left" width="400" trigger="click">
<div class="customProgressContainer">
<div class="customProgressC title">
<span>上传任务名称</span>
<div
style="width: 105px;height: fit-content;display: flex;flex-wrap: wrap;justify-content: flex-end;height: 44px;">
<span>已完成/总对象数</span>
<el-tooltip placement="left" content="清空任务">
<i @click="removeTask" class="el-icon-delete"></i>
</el-tooltip>
</div>
</div>
<div class="customProgressC"
v-for="({ pending, key, taskName, execSize, totalSize, execCount, totalCount }, index) in uploadTaskLists"
:key="key">
<div class="flexName">
<showToolTip :text="taskName" />
<el-tooltip placement="left" content="移除任务">
<i @click="removeTask(index)" class="el-icon-close"></i>
</el-tooltip>
</div>
<div class="customProgress" v-loading="pending">
<el-progress text-color="#d3d6d8" :percentage="renderLoadingTask(pending, execSize, totalSize)"
:color="'#4ccb92'">
</el-progress>
<showToolTip use-slot :text="preciseDemi(execCount) + '/' + preciseDemi(totalCount)">
<span slot="data" class="txt">{{ preciseDemi(execCount) + '/' + preciseDemi(totalCount) }}</span>
</showToolTip>
</div>
</div>
</div>
<div slot="reference" @click="handleActive" class="downLoadList">
<el-tooltip content="上传">
<div>
<svg class="icon upload">
<use xlink:href="#icon-upload" />
</svg>
<span class=""></span>
</div>
</el-tooltip>
</div>
</el-popover>
</template>
<script>
import moment from 'moment'
import { mapGetters } from 'vuex';
import { writeLog } from '@/api/uploadLog'
export default {
name: '',
props: {},
components: {},
data () {
return {
breakUpload: false,
readFileList: [],
finList: [], //上传记录
netWorkFail: false, //断点续传
visible: false,
abortController: {},
maxConcurrentRequests: 1, // 设置最大并发请求数
currentRequests: 0, // 当前进行的请求数
uploadPartSize: 1024 * 1024 * 5, // 分段大小&&文件启用分段大小,
};
},
computed: {
...mapGetters(['uploadTaskLists', 'uploadTaskQueue'])
},
watch: {
},
created () { },
mounted () {
this.$bus.on("upload", () => {
// console.log('getUploadddd')
this.processQueue()
});
},
methods: {
doCheckNetWork () {
// 检测网络
return new Promise((resolve, reject) => {
this.connectingFlag = true
this.$store.state._S3.listBuckets((err, data) => {
this.connectingFlag = false
if (err && (err.code === 'NetworkingError' || err.code === 'TimeoutError')) {
reject(err)
// console.log(err, 'ConnectTest')
} else {
resolve()
}
})
})
},
showPutFileKey (prefix, file) {
const {
webkitRelativePath,
relativePath
} = file
const hasDirPath = webkitRelativePath || relativePath
return (prefix || '') + (!!hasDirPath ? hasDirPath : file.name)
},
handleActive () {
document.querySelector('.upload + span') && document.querySelector('.upload + span').classList.remove('circleStatus')
this.visible = !this.visible
},
renderLoadingTask (pending, execSize, totalSize) {
if (pending) return 0
return execSize === 0 ? totalSize === 0 ? 100 : 0 : parseFloat(((execSize / totalSize) * 100).toFixed(2))
},
async processQueue () {
// if (this.isProcessingQueue || this.uploadTaskQueue.length === 0) {
// return
// }
// this.isProcessingQueue = true
if (this.uploadTaskQueue.length === 0) return
try {
while (this.uploadTaskQueue.length > 0) {
// 等待当前请求数量低于最大并发数
while (this.currentRequests >= this.maxConcurrentRequests) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// 任务队列和展示队列、任务队列没有同步移除、所以执行前需要判断是否存在
// 获取任务需要正确的截取数组
// const task = this.uploadTaskQueue.find(x => x.key === key)
// console.log('gggg')
if (!this.uploadTaskQueue.length) return
const task = this.uploadTaskQueue[0]
const { uniqueKey } = task
// 检查任务是否被取消
const taskIndex = this.uploadTaskLists.findIndex(x => x.uniqueKey === uniqueKey)
if (taskIndex === -1) {
this.uploadTaskQueue.shift()
continue
}
await this.doUploadFile(task, uniqueKey)
this.uploadTaskQueue.shift()
// this.finList = []
// try {
// if (task.type === 'd') {
// await this.processDirectoryTask(task, key)
// } else {
// await this.processFileTask(task, key)
// }
// } catch (error) {
// console.error(`Task failed: ${task.taskName}`, error)
// // 可以添加失败状态显示
// }
//移除任务
}
} finally {
this.isProcessingQueue = false
}
},
async removeTask (index) {
console.log(this.abortController, 'abortController')
// 清除分片
// 需要注意若是分片、清理还需额外调用abortMultipartUpload
if (!isNaN(index)) {
// console.log(index, '123')
const item = this.uploadTaskLists.splice(index, 1); // 移除任务
const id = item[0]?.uniqueKey
// keys是目录才执行
// 区分目录和单一文件需要将request请求 包装
// console.log(this.abortController[id], this.abortController, id, index, item)
// 任务列表是存在的、但abortController未添 加、需全部清除
// 移除单个上传的任务、需要找出带有uploadId的、即需要清除分片、同时需要过滤后续的分片任务
console.log(this.abortController[id], 'idTest', item)
// const partUpload = {}
if (this.abortController[id]) {
const arr = this.abortController[id]
// 目录打包下载需要遍历调用
const len = arr.length
for (let i = 0; i < len; i++) {
console.log(arr[i], 'iii')
// 中断所有请求
if (arr[i].abort) {
arr[i].abort()
}
// toDo
// 取消分片清除
// const { UploadId, Bucket, Key } = arr[i].params
// // 如果是分片上传,需要调用abortMultipartUpload、分片调用一次即可
// if (UploadId && !partUpload[UploadId]) {
// partUpload[UploadId] = true
// await this.$store.state._S3.abortMultipartUpload({
// Bucket,
// Key,
// UploadId
// }, (err) => {
// if (err) console.error('终止分片上传失败:', err);
// else console.log('分片上传已终止');
// })
// }
}
}
delete this.abortController[id]
if (this.currentRequests > 0) {
this.currentRequests -= 1
}
} else {
this.uploadTaskLists.splice(0)
this.currentRequests = 0
Object.keys(this.abortController).forEach((x) => {
// arr是数组索引
Object.keys(this.abortController[x]).forEach(y => {
if (this.abortController[x][y]) {
// 中断所有请求
if (this.abortController[x][y].abort) {
this.abortController[x][y].abort()
}
// 如果是分片上传,需要调用abortMultipartUpload
// toDo
// 取消分片清除
// if (this.abortController[x][y].uploadId) {
// this.$store.state._S3.abortMultipartUpload({
// Bucket: this.abortController[x][y].Bucket,
// Key: this.abortController[x][y].Key,
// UploadId: this.abortController[x][y].uploadId
// }, (err) => {
// if (err) console.error('终止分片上传失败:', err);
// else console.log('分片上传已终止');
// })
// }
}
})
})
this.abortController = {}
}
if (!this.uploadTaskLists.length) {
this.$refs['uploadPopover'].doClose()
// this.visible = false; // 如果没有任务,关闭打开的popover
}
},
getRenderZipPath (path, dir, prefix) {
return prefix ?
path.substring(prefix.length + dir.length)
: path.substring(dir.length)
},
doUploadFile (task, taskKey) {
// task: Bucket,fileList,taskName,prefix
this.currentRequests++
const findExist = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (findExist === -1) {
// console.log('iam gone')
return
}
// 添加重试记录
if (!this.abortController[taskKey]) {
this.abortController[taskKey] = []
}
this.uploadTaskLists[findExist].pending = false
const { Bucket, fileList, prefix } = task
// 处理分片及小文件
// this.finList = []
const onProgree = async (fileList) => {
try {
const successFileList = []
let taskList = []
const len = fileList.length
this.netWorkFail = false
for (let i = 0; i < len; i++) {
// 中断请求
if (this.netWorkFail || !this.abortController[taskKey]) {
break
}
const file = fileList[i]
const { size } = file
const Key = this.showPutFileKey(prefix, file)
// 添加
file['Key'] = Key
let asyncTask = null
// 默认5M开启分片
writeLog(
`开始上传: Bucket:${Bucket}, Key: ${Key} `
)
if (size <= this.uploadPartSize) {
asyncTask = new Promise((res, rej) => {
const reqParams = {
Bucket,
Key,
Body: file
}
if (size === 0) {
reqParams.Body = new Blob()
reqParams.ContentType = 'application/octet-stream'
}
let uploadSize = 0
let activeRequest = this.$store.state._S3.putObject(
reqParams
, (err, data) => {
if (err) rej({ err, file, Bucket, Key })
else res({ success: 'success', file, Bucket, Key })
}).on('httpUploadProgress', (progress) => {
// 计算上传进度
// const percent = Math.round((progress.loaded / progress.total) * 100);
// console.log(`上传进度: ${percent}%`, progress);
const loaded = progress.loaded - uploadSize
// 更新下载的进度
// loaded 已下载的大小、
uploadSize = progress.loaded
// this.putSize += loaded
const currentIdx = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (currentIdx > -1) {
// 这里得判断是否已经是重试
this.uploadTaskLists[currentIdx].execSize += loaded
if (progress.loaded === size) {
// 上传结束
this.uploadTaskLists[currentIdx].execCount += 1
// 当前上传任务完成、需要判断是否已结束当前执行任务、即判断
}
}
// console.log(progress, 'progress')
});
this.abortController[taskKey].push(activeRequest)
})
} else {
asyncTask = this.handleMultUpload([file], Bucket, taskKey)
// 与putObject区分
}
taskList.push(asyncTask)
// 并发上传文件数1、上传文件分片6
if (taskList.length === 1 || i == len - 1) {
try {
const partRes = await Promise.allSettled(taskList)
// console.log(partRes, '上传文件结束')
// this.putSize +=
partRes.reduce((pre, cur) => {
if (cur.status === 'fulfilled') {
// 不具有Bucket、putObject、上传结束进度++
if (!cur.value.Bucket) {
// pre += cur.value.file.size
successFileList.push(cur.value.file.Key)
writeLog(
`上传结束: Bucket:${cur.value.Bucket}, Key: ${cur.value.Key} `
)
} else {
successFileList.push(cur.value.Key)
writeLog(
`上传结束: Bucket:${cur.value.Bucket}, Key: ${cur.value.Key} `
)
}
// this.finList.push(cur)
} else if (cur.status === 'rejected') {
// console.log(cur, 'errrorrrr')
if (cur.reason.err?.err?.code === 'NetworkingError' || cur.reason.err?.code === 'NetworkingError' || cur.reason.err?.err?.code === 'TimeoutError' || cur.reason.err?.code === 'TimeoutError') {
// console.log('cur', cur)
this.netWorkFail = true
// console.log('Upload error details:', {
// error: cur.reason.err,
// errorType: cur.reason.err.constructor.name,
// errorCode: cur.reason.err.code,
// errorMessage: cur.reason.err.message,
// errorStack: cur.reason.err.stack,
// bucket: cur.reason.Bucket,
// key: cur.reason.Key,
// statusCode: cur.reason.err.statusCode,
// requestId: cur.reason.err.requestId,
// cfId: cur.reason.err.cfId,
// extendedRequestId: cur.reason.err.extendedRequestId
// });
// 构建详细的错误信息
let errorDetails = `中断上传: `;
errorDetails += `Bucket: ${cur.reason.Bucket}, `;
errorDetails += `Key: ${cur.reason.Key}, `;
errorDetails += `错误类型: ${cur.reason.err.constructor.name}, `;
errorDetails += `错误代码: ${cur.reason.err.code || 'N/A'}, `;
errorDetails += `状态码: ${cur.reason.err.statusCode || 'N/A'}, `;
errorDetails += `错误信息: ${cur.reason.err.message}`;
// 添加错误描述
if (cur.reason.err.code) {
errorDetails += `, 错误描述: ${this.getErrorDescription(cur.reason.err)}`;
}
if (cur.reason.err.statusCode) {
errorDetails += `, 状态描述: ${this.getStatusCodeDescription(cur.reason.err.statusCode)}`;
}
// 添加额外的错误信息
if (cur.reason.err.requestId) {
errorDetails += `, RequestId: ${cur.reason.err.requestId}`;
}
if (cur.reason.err.cfId) {
errorDetails += `, CF-Id: ${cur.reason.err.cfId}`;
}
if (cur.reason.err.extendedRequestId) {
errorDetails += `, ExtendedRequestId: ${cur.reason.err.extendedRequestId}`;
}
// console.dir(cur, '123')
// writeLog(errorDetails);
} else {
// console.dir(cur, '123')
writeLog(
`中断上传: message:${decodeURIComponent(cur.reason.err.message)}, Bucket:${cur.reason.Bucket}, Key: ${cur.reason.Key} `
)
// this.finList.push(cur)
}
}
return pre
}, 0)
taskList = []
} catch (error) {
console.error('Error during file upload:', error);
// this.netWorkFail = true; // 标记网络失败
}
}
}
// End
if (this.netWorkFail) {
const recoverFile = fileList.filter(x => {
return !successFileList.includes(x.Key)
})
// console.log(recoverFile, '=====ERROR', successFileList, fileList)
this.timerFail = setInterval(() => {
if (!this.connectingFlag) {
this.doCheckNetWork().then(() => {
clearInterval(this.timerFail)
this.netWorkFail = false
setTimeout(() => {
onProgree(recoverFile)
}, 500)
})
}
// doCheckNetWork
}, 10000)
// 待重试文件
} else {
// this.releaseDisable()
// this.loadingBg.close()
// console.log(this.finList, '===OVER===')
// console.log(result, 'result')
// this.writeErrorLog(this.finList)
if (this.currentRequests > 0) {
this.currentRequests -= 1
}
// 刷新页面
this.$bus.emit('refreshList')
}
} catch (error) {
console.log('Error in onProgree:', error);
}
}
onProgree(fileList)
//
},
async handleMultUpload (fileArr, Bucket, taskKey) {
const asyncTask = (file) => {
const { Key, size } = file
// console.log(file, '1233')
// 大于5GB、分片10m、500、
let chunkSize = ''
if (size > 1024 * 1024 * 1024 * 10) {
chunkSize = 1024 * 1024 * 10
} else if (size > 1024 * 1024 * 1024 * 5) {
chunkSize = 1024 * 1024 * 8
} else {
chunkSize = 1024 * 1024 * 5
}
// > 1000 ? 1024 * 1024 * 10 : this.uploadPartSize //5MB
const chunks = Math.ceil(size / chunkSize)
return new Promise((resolve, rejected) => {
// 检测文件检测失败重传
try {
const startTime = moment().format('YYYY-MM-DD HH:mm:ss')
// this.readFileList = [{ 'accessKeyId': 'minioadmin', 'endpoint': 'http://10.0.2.153:9000', 'Bucket': 'test', 'Key': '/testBig/1223.exe', 'UploadId': 'N2IwOTE3MDctYzgxZi00NTFlLThjZGMtM2FiNGZkYjE0MjIzLjAyMTJiNjExLWRlMDItNDNiMi04OWIzLWUwMjA2NjA1NWRlNg' }]
const s3Client = JSON.parse(localStorage.getItem('s3Client'))
const {
accessKeyId,
endpoint
} = s3Client
const isExistReUploadPart = this.readFileList.findIndex(x => {
return x.Key === Key && x.Bucket === Bucket && x.accessKeyId === accessKeyId && x.endpoint === endpoint
})
// console.log(this.readFileList, 'readFileList')
// 存在文件的分片、调用listPart获取已上传的分片、并在下面的上传分片中跳过已有的分片
// 一旦uploadPart开始进行中途断网则需要恢复
// 所以断网续传2中、重试和重传
// 重传下面得逻辑得调用listPart辅助记录已上传得文件并跳过进度
// 且用于记录上传part的uploadId得同步新的uploadId
// 多次中断会有问题、所以得记录已上传的partNumber、在新的上传失败时
// 譬如、第一次上传1、2、3、4、5、5失败、则记录到4的size
// 第二次1、2、3、5成功、4失败、则5不会同步
// 所以断网对于大文件的记录得全部清空、从头开始??
if (isExistReUploadPart !== -1) {
const {
UploadId
} = this.readFileList[isExistReUploadPart]
// 存在切片、在有效期且开启续传
const params = {
Bucket,
Key,
UploadId
}
// console.log(this.readFileList, 'cover', params)
const listPartRequest = this.$store.state._S3.listParts(params, async (err, data) => {
if (!err) {
let multiplePart = []
const listPartFin = []
if (chunks !== data.Parts.length) {
// 1)删除已上传得part、进度会倒退
// 2)fileList记录上传part、并在每次listPart后去重part
// 同步已完成的part、
// const res = await this.$store.state._S3.createMultipartUpload({ Bucket, Key }).promise()
// const newUploadId = res.UploadId
// this.readFileList[isExistReUploadPart].UploadId = newUploadId
const hasUploadPart = data.Parts
let netBreak = false
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
if (netBreak || !this.abortController[taskKey]) break
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, size)
const doneUploadSize = end - start
const body = file.slice(start, end)
const PartNumber = chunkCount + 1
const reqParams = {
PartNumber,
Body: body,
Bucket,
Key,
UploadId
}
const jumpPass = hasUploadPart.findIndex(x => x.PartNumber === PartNumber)
if (jumpPass !== -1) {
continue
}
// 计算上传量
const p = new Promise((res, rej) => {
let uploadSize = 0
const uploadPartRequest = this.$store.state._S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej({ ...uploadPartErr, doneUploadSize })
else {
// console.log(uploadPartData, '123')
res({ ...uploadPartData, doneUploadSize, PartNumber })
}
}).on('httpUploadProgress', (progress) => {
// 计算上传进度
const loaded = progress.loaded - uploadSize
uploadSize = progress.loaded
// this.putSize += loaded
const currentIdx = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (currentIdx > -1) {
this.uploadTaskLists[currentIdx].execSize += loaded
// 当前是处理分片
// if (uploadSize === size && chunkCount === chunks - 1) {
// // 上传结束
// this.uploadTaskLists[currentIdx].execCount += 1
// // 当前上传任务完成、需要判断是否已结束当前执行任务、即判断
// }
}
});
this.abortController[taskKey].push(uploadPartRequest)
})
multiplePart.push(p)
// 这里得特殊处理、并发或者最后一个可以满足、但依据顺序来、会丢失、所以不并发
if (multiplePart.length == 1 || chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
partRes.reduce((pre, cur) => {
if (cur.status === 'fulfilled') {
} else {
netBreak = cur.reason.code === 'NetworkingError' ||
cur.reason.code === 'TimeoutError'
}
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
}
}
// console.log(listPartFin, '=====剩下的分片=====')
const allParts = [...listPartFin, ...hasUploadPart]
const partOver = listPartFin.every(x => x.status === 'fulfilled') && allParts.length === chunks
// console.log(partOver, 'partOver', allParts)
// return
if (partOver) {
let Parts = allParts.map((x, i) => {
if (x.value) {
return {
PartNumber: x.value.PartNumber,
ETag: x.value.ETag
}
} else {
return {
PartNumber: x.PartNumber,
ETag: x.ETag
}
}
}).sort((a, b) => a.PartNumber - b.PartNumber)
let hasETag = Parts.every(x => x.ETag)
if (!hasETag) {
const listPartRequest = this.$store.state._S3.listParts({
Bucket,
Key,
UploadId
})
this.abortController[taskKey].push(listPartRequest)
const resListParts = await listPartRequest.promise()
Parts = resListParts.Parts.map(x => {
return {
PartNumber: x.PartNumber,
ETag: x.ETag
}
}).sort((a, b) => a.PartNumber - b.PartNumber)
}
const completeRequest = this.$store.state._S3.completeMultipartUpload({
Bucket,
Key,
UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === this.readFileList[isExistReUploadPart].UploadId
})
const currentIdx = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (currentIdx > -1) {
this.uploadTaskLists[currentIdx].execCount += 1
}
this.readFileList.splice(delIndex, 1)
resolve({ ...compErrData, Key })
}
})
this.abortController[taskKey].push(completeRequest)
} else {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const err = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: err,
file,
startTime,
endTime
})
}
} else {
// 上传成功、complete失败
const Parts = [...data.Parts]
.map(x => {
return {
PartNumber: x.PartNumber || x.value.PartNumber,
ETag: x.ETag || x.value.ETag
}
})
.sort((a, b) => a.PartNumber - b.PartNumber)
const completeRequest = this.$store.state._S3.completeMultipartUpload({
Bucket,
Key,
UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === this.readFileList[isExistReUploadPart].UploadId
})
const currentIdx = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (currentIdx > -1) {
this.uploadTaskLists[currentIdx].execCount += 1
this.uploadTaskLists[currentIdx].execSize = this.uploadTaskLists[currentIdx].totalSize
}
this.readFileList.splice(delIndex, 1)
resolve({ ...compErrData, Key })
}
})
this.abortController[taskKey].push(completeRequest)
}
} else {
rejected({ err: err, ...params })
// toDO 待重试
// 删除记录
}
})
this.abortController[taskKey].push(listPartRequest)
} else {
// 正常上传
const activeRequest = this.$store.state._S3.createMultipartUpload({
Bucket,
Key
}, async (createErr, createData) => {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
if (createErr) {
// console.error('Error creating multipart upload:', createErr)
// this.putSize += fileSize
// console.log(createErr, 'listPartFin')
rejected({ err: createErr, file, startTime, endTime })
} else {
let multiplePart = []
// writeAbortLog
this.readFileList.push({
accessKeyId,
endpoint,
Bucket,
Key,
UploadId: createData.UploadId
})
const listPartFin = []
// 此处同步的所以有问题了vuex先缓存一下
// endWrite 此处记录及最终Promise处处理完成判断、清楚记录或执行abortMultiple
let netBreak = false
for (let chunkCount = 0; chunkCount < chunks; chunkCount++) {
// catch未进入、
if (netBreak || !this.abortController[taskKey]) break
const start = chunkCount * chunkSize
const end = Math.min(start + chunkSize, size)
const doneUploadSize = end - start
const body = file.slice(start, end)
const reqParams = {
PartNumber: chunkCount + 1,
Body: body,
Bucket,
Key,
UploadId: createData.UploadId
}
const p = new Promise((res, rej) => {
// if (chunkCount > chunks - 2) {
// reqParams.Bucket = '666'
// }
let uploadSize = 0
const uploadPartRequest = this.$store.state._S3.uploadPart(reqParams
, (uploadPartErr, uploadPartData) => {
if (uploadPartErr) rej({ ...uploadPartErr, doneUploadSize })
else {
// console.log(uploadPartData, '123')
res({ ...uploadPartData, doneUploadSize })
}
}).on('httpUploadProgress', (progress) => {
// 计算上传进度
const loaded = progress.loaded - uploadSize
uploadSize = progress.loaded
// this.putSize += loaded
const currentIdx = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (currentIdx > -1) {
this.uploadTaskLists[currentIdx].execSize += loaded
// 当前是处理分片
// if (uploadSize === size && chunkCount === chunks - 1) {
// // 上传结束
// this.uploadTaskLists[currentIdx].execCount += 1
// // 当前上传任务完成、需要判断是否已结束当前执行任务、即判断
// }
}
});
this.abortController[taskKey].push(uploadPartRequest)
})
multiplePart.push(p)
if (multiplePart.length == 3 || chunkCount === chunks - 1) {
const partRes = await Promise.allSettled(multiplePart)
// console.log(partRes, '123')
partRes.reduce((pre, cur) => {
if (cur.status === 'fulfilled') {
// pre += cur.value.doneUploadSize
} else {
// console.log(cur, 'uploadPartERROR======')
netBreak = cur.reason.code === 'NetworkingError' ||
cur.reason.code === 'TimeoutError'
}
return pre
}, 0)
listPartFin.push(...partRes)
multiplePart = []
}
}
// uploadPart End
const partOver = listPartFin.every(x => x.status === 'fulfilled') && listPartFin.length === chunks
if (partOver) {
// listParts
// var params = {
// Bucket,
// Key,
// UploadId: createData.UploadId
// }
// const Parts = listPartFin.map((x, i) => {
// console.log(x, '12333')
// return {
// PartNumber: i + 1,
// ETag: x.value.ETag
// }
// })
// 判断是否有part
let Parts = listPartFin.map((x, i) => {
return {
PartNumber: i + 1,
ETag: x.value.ETag
}
})
console.log(Parts, 'Parts')
let hasETag = Parts.every(x => x.ETag)
if (!hasETag) {
const listPartRequest = this.$store.state._S3.listParts({
Bucket,
Key,
UploadId: createData.UploadId
})
this.abortController[taskKey].push(listPartRequest)
const resListParts = await listPartRequest.promise()
console.log(resListParts, 'resListParts')
Parts = resListParts.Parts.map(x => {
return {
PartNumber: x.PartNumber,
ETag: x.ETag
}
}).sort((a, b) => a.PartNumber - b.PartNumber)
}
// console.log(resListParts, '12PartsPartsParts=====3')
const completeRequest = this.$store.state._S3.completeMultipartUpload({
Bucket,
Key,
UploadId: createData.UploadId,
MultipartUpload: { Parts }
}, (compErr, compErrData) => {
if (compErr) {
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
rejected({
err: compErr,
file,
startTime,
endTime
})
} else {
const delIndex = this.readFileList.findIndex(x => {
return x.UploadId === createData.UploadId
})
this.readFileList.splice(delIndex, 1)
const currentIdx = this.uploadTaskLists.findIndex(x => x.uniqueKey === taskKey)
if (currentIdx > -1) {
this.uploadTaskLists[currentIdx].execCount += 1
}
resolve({ ...compErrData, Key })
}
})
this.abortController[taskKey].push(completeRequest)
} else {
// 处理uploadpart错误、取其中一个error
const endTime = moment().format('YYYY-MM-DD HH:mm:ss')
const err = listPartFin.find(x => x.status === 'rejected')?.reason
rejected({
err: err,
file,
startTime,
endTime
})
// handle reUploadPart
}
}
})
this.abortController[taskKey].push(activeRequest)
//
}
} catch (error) {
console.log(error, 'error')
}
})
// 处理异常及时任务
}
let finalRes = null
while (fileArr.length) {
const file = fileArr.shift()
finalRes = await asyncTask(file).catch(err => {
return {
hasError: true,
err
}
})
}
return finalRes.hasError ? Promise.reject(finalRes) : Promise.resolve(finalRes)
},
// 错误分类和处理函数
getErrorDescription (error) {
const errorCode = error.code;
const statusCode = error.statusCode;
// 根据错误代码分类
switch (errorCode) {
case 'AccessDenied':
return '访问被拒绝 - 检查权限配置';
case 'NoSuchBucket':
return '存储桶不存在';
case 'NoSuchKey':
return '对象不存在';
case 'InvalidAccessKeyId':
return '无效的访问密钥ID';
case 'SignatureDoesNotMatch':
return '签名不匹配 - 检查密钥配置';
case 'RequestTimeout':
return '请求超时 - 检查网络连接';
case 'NetworkingError':
return '网络错误 - 检查网络连接';
case 'CredentialsError':
return '凭证错误 - 检查访问密钥';
case 'TokenRefreshRequired':
return '需要刷新令牌';
case 'ExpiredTokenException':
return '令牌已过期';
case 'InvalidToken':
return '无效的令牌';
case 'MalformedXML':
return 'XML格式错误';
case 'InvalidArgument':
return '参数无效';
case 'InvalidDigest':
return '摘要无效';
case 'EntityTooLarge':
return '文件过大';
case 'InvalidObjectState':
return '对象状态无效';
case 'OperationAborted':
return '操作被中止';
case 'SlowDown':
return '请求过于频繁,请稍后重试';
case 'ServiceUnavailable':
return '服务不可用';
case 'InternalError':
return '内部服务器错误';
default:
return '未知错误';
}
},
// 根据状态码获取错误描述
getStatusCodeDescription (statusCode) {
switch (statusCode) {
case 400:
return '请求错误 - 检查请求参数';
case 401:
return '未授权 - 检查认证信息';
case 403:
return '禁止访问 - 检查权限';
case 404:
return '资源不存在';
case 408:
return '请求超时';
case 429:
return '请求过于频繁';
case 500:
return '服务器内部错误';
case 502:
return '网关错误';
case 503:
return '服务不可用';
case 504:
return '网关超时';
default:
return 'HTTP错误';
}
},
}
}
</script>
<style lang="scss" scoped>
.customProgressContainer {
max-height: 420px;
overflow-y: auto;
.customProgressC {
margin: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e2e2;
&.title {
display: flex;
justify-content: space-between;
.el-icon-delete {
cursor: pointer;
color: #ff8746;
margin-left: 5px;
}
span {
color: #d3d6d8;
}
}
.flexName {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
color: #ff8746;
.tooltip-container {
width: 90%;
}
}
.el-icon-close {
cursor: pointer;
}
.customProgress {
display: flex;
align-items: center;
::v-deep .el-progress {
width: 80%;
display: flex;
justify-content: space-between;
align-items: center;
// .el-progress-bar {
// width: 90%;
// }
.el-progress__text {
width: 52px !important;
}
}
.tooltip-container {
max-width: 150px;
width: 120px;
text-align: right;
.text-box {
vertical-align: super;
text-align: right;
}
}
// .txt {
// flex: 1;
// text-align: right;
// width: fit-content;
// text-align: right;
// white-space: pre;
// }
}
}
}
.downLoadList {
// position: absolute;
// right: 54px;
// top: 90px;
// width: 25px;
// height: 25px;
position: relative;
.upload {
font-size: 25px;
color: #1f6bb8;
transition: all .5s;
cursor: pointer;
&+span {
display: block;
width: 8px;
height: 8px;
}
}
.circleStatus {
position: absolute;
top: 10px;
right: 10px;
border-radius: 20px;
transition: all .2s;
background: rgb(76, 203, 146);
&.active {
animation: activeLoadIco .5s linear 1;
}
}
}
</style>
# 下载
<template>
<el-popover ref="downloadPopover" placement="left" width="400" trigger="click">
<div class="customProgressContainer">
<div class="customProgressC title">
<span>下载任务名称</span>
<div
style="width: 105px;height: fit-content;display: flex;flex-wrap: wrap;justify-content: flex-end;height: 44px;">
<span>已完成/总对象数</span>
<el-tooltip placement="left" content="清空任务">
<i @click="removeTask" class="el-icon-delete"></i>
</el-tooltip>
</div>
</div>
<div class="customProgressC"
v-for="({ pending, key, taskName, execSize, totalSize, execCount, totalCount }, index) in downLoadTaskLists"
:key="key">
<div class="flexName">
<showToolTip :text="taskName" />
<el-tooltip placement="left" content="移除任务">
<i @click="removeTask(index)" class="el-icon-close"></i>
</el-tooltip>
</div>
<div class="customProgress" v-loading="pending">
<el-progress text-color="#d3d6d8" :percentage="renderLoadingTask(pending, execSize, totalSize)"
:color="'#4ccb92'">
</el-progress>
<showToolTip use-slot :text="preciseDemi(execCount) + '/' + preciseDemi(totalCount)">
<span slot="data" class="txt">{{ preciseDemi(execCount) + '/' + preciseDemi(totalCount) }}</span>
</showToolTip>
</div>
</div>
</div>
<div slot="reference" @click="handleActive" class="downLoadList">
<el-tooltip content="下载">
<div>
<svg class="icon download">
<use xlink:href="#icon-download" />
</svg>
<span class=""></span>
</div>
</el-tooltip>
</div>
</el-popover>
</template>
<script>
import { mapGetters } from 'vuex';
import { createWriteStream } from 'streamsaver';
import * as fflate from "fflate";
import { createDownloadStream } from '@/utils/downStream'
// import { GetObjectCommand, S3Client, } from "@aws-sdk/client-s3";
// import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import axios from 'axios';
export default {
name: '',
props: {},
components: {},
data () {
return {
visible: false,
abortController: {},
maxConcurrentRequests: 1, // 设置最大并发请求数
currentRequests: 0, // 当前进行的请求数
};
},
computed: {
...mapGetters(['downLoadTaskLists', 'downLoadTaskQueue'])
},
watch: {
},
created () { },
mounted () {
this.$bus.on("download", () => {
this.processQueue()
});
// const { accessKeyId = '', endpoint = '', secretAccessKey = '' } = JSON.parse(localStorage.getItem('s3Client')) || {}
// this.$3Client = new S3Client({
// region: "EastChain-1",
// endpoint,
// credentials: {
// accessKeyId: accessKeyId,
// secretAccessKey: secretAccessKey
// }
// })
},
methods: {
// async generateSignedUrl (bucket, key, start, end) {
// const command = new GetObjectCommand({
// Bucket: bucket,
// Key: key,
// Range: `bytes=${start}-${end}`
// });
// return await getSignedUrl(this.$3Client, command, { expiresIn: 3600 });
// },
// 使用示例
handleActive () {
document.querySelector('.download + span') && document.querySelector('.download + span').classList.remove('circleStatus')
// this.visible = !this.visible
},
renderLoadingTask (pending, execSize, totalSize) {
if (pending) return 0
return execSize === 0 ? totalSize === 0 ? 100 : 0 : parseFloat(((execSize / totalSize) * 100).toFixed(2))
},
async processQueue () {
// if (this.isProcessingQueue || this.downLoadTaskQueue.length === 0) {
// return
// }
// this.isProcessingQueue = true
if (this.downLoadTaskQueue.length === 0) return
try {
while (this.downLoadTaskQueue.length > 0) {
// 等待当前请求数量低于最大并发数
while (this.currentRequests >= this.maxConcurrentRequests) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// 任务队列和展示队列、任务队列没有同步移除、所以执行前需要判断是否存在
// 获取任务需要正确的截取数组
// const task = this.downLoadTaskQueue.find(x => x.key === key)
// console.log('gggg')
if (!this.downLoadTaskQueue.length) return
const task = this.downLoadTaskQueue[0]
const key = task.uniqueKey
// 检查任务是否被取消
const taskIndex = this.downLoadTaskLists.findIndex(x => x.key === key)
if (taskIndex === -1) {
this.downLoadTaskQueue.shift()
continue
}
try {
if (task.type === 'd') {
await this.processDirectoryTask(task, key)
} else {
await this.processFileTask(task, key)
}
} catch (error) {
console.error(`Task failed: ${task.taskName}`, error)
// 可以添加失败状态显示
}
//移除任务
this.downLoadTaskQueue.shift()
}
} finally {
// this.isProcessingQueue = false
}
},
removeTask (index) {
if (!isNaN(index)) {
// console.log(index, '123')
const item = this.downLoadTaskLists.splice(index, 1); // 移除任务
const id = item[0]?.key
// keys是目录才执行
// 区分目录和单一文件需要将request请求 包装
// console.log(this.abortController[id], this.abortController, id, index, item)
// 任务列表是存在的、但abortController未添加、需全部清除
if (this.abortController[id]) {
const arr = Object.keys(this.abortController[id])
// 目录打包下载需要遍历调用
arr.forEach((x) => {
this.abortController[id][x].abort()
// this.abortController[id][x].removeAllListeners('httpDownloadProgress');
})
// console.log('iam gone')
delete this.abortController[id]
if (this.currentRequests > 0) {
this.currentRequests -= 1
}
}
} else {
this.downLoadTaskLists.splice(0)
this.currentRequests = 0
Object.keys(this.abortController).forEach((x) => {
// arr是数组索引
Object.keys(this.abortController[x]).forEach(y => {
if (this.abortController[x][y]) {
this.abortController[x][y].abort()
// this.abortController[x][y].removeAllListeners('httpDownloadProgress');
}
})
})
this.abortController = {}
}
if (!this.downLoadTaskLists.length) {
this.$refs['downloadPopover'].doClose()
// this.visible = false; // 如果没有任务,关闭打开的popover
}
},
getRenderZipPath (path, dir, PreDir) {
return PreDir ?
path.substring(PreDir.length + dir.length)
: path.substring(dir.length)
},
async loadAllFile (bucket, prefix, key) {
let allObjects = [];
let continuationToken = null;
do {
const beExeIndex = this.downLoadTaskLists.findIndex(x => x.key === key)
// console.log('continue', key)
if (beExeIndex === -1) {
// console.log('iam gone')
throw new Error('cancelTask')
}
const params = {
Bucket: bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
Delimiter: '/'
};
const data = await this.$store.state._S3.listObjectsV2(params).promise();
// 1. 处理空目录
if (data.Contents) {
allObjects = allObjects.concat(data.Contents.map(x => {
if (x.Key.endsWith('/') && x.Size === 0) x.IsDirectory = true
return x
}))
}
// 2. 递归处理子目录
if (data.CommonPrefixes) {
const subDirPromises = data.CommonPrefixes.map(async subDir =>
await this.loadAllFile(bucket, subDir.Prefix, key)
);
// 加载数据并发限制
const subDirFiles = await Promise.all(subDirPromises);
allObjects = allObjects.concat(subDirFiles.flat());
}
continuationToken = data.NextContinuationToken;
} while (continuationToken);
// console.log(allObjects, 'allObjects')
return allObjects;
},
async processDirectoryTask (row, key) {
this.currentRequests++
const dirName = row.Prefix.slice(0, row.Prefix.length - 1)
// const stream = (await createDownloadStream(`${dirName}.zip`)).getWriter();
const fileStream = createWriteStream(`${dirName}.zip`);
const stream = fileStream.getWriter();
// 创建目录 使用nodeJS、其下的文件等、采用流式下载
// const url = `http://localhost:3000/download/${stream}`
// console.log(stream, dirName)
// return
// console.log('1233', dirName)
const baseUrl = process.env.NODE_ENV === 'production'
? window.location.origin // 生产环境使用当前域名
: 'http://localhost:8081' // 开发环境使用本地服务器
const { Bucket, PreDir, Prefix } = row
const zip = new fflate.Zip((err, data, final) => {
if (err) {
console.error("Zip error:", err);
stream.close();
return;
}
if (final) {
stream.write(data).then(() => {
stream.close();
// 更新最终完成状态
const currentIdx = this.downLoadTaskLists.findIndex(x => x.key === key);
if (currentIdx > -1) {
this.downLoadTaskLists[currentIdx].execCount = this.downLoadTaskLists[currentIdx].totalCount;
this.downLoadTaskLists[currentIdx].execSize = this.downLoadTaskLists[currentIdx].totalSize;
// 任务完成,清理资源
this.currentRequests--;
delete this.abortController[key];
}
});
} else if (data) {
stream.write(data);
}
});
try {
const beExeIndex = this.downLoadTaskLists.findIndex(x => x.key === key)
if (beExeIndex === -1) {
return
}
const data = await this.loadAllFile(Bucket, PreDir ? PreDir + Prefix : Prefix, key)
const totalSize = data.reduce((pre, cur) => pre + (Number(cur['Size']) || 0), 0);
let len = data.length
const matchIndex = this.downLoadTaskLists.findIndex(x => x.key === key)
this.downLoadTaskLists[matchIndex].totalCount = len
this.downLoadTaskLists[matchIndex].totalSize = totalSize
this.downLoadTaskLists[matchIndex].pending = false
this.downLoadTaskLists[matchIndex].execSize = 0
this.downLoadTaskLists[matchIndex].execCount = 0
if (!this.abortController[key]) {
this.abortController[key] = []
}
// 流式下载打包
for (let i = 0; i < len; i++) {
const relativePath = this.getRenderZipPath(data[i].Key, row.Prefix, row.PreDir)
if (data[i].IsDirectory) {
// 创建空目录
const zipStream = new fflate.ZipDeflate(relativePath, {
level: 5,
});
zip.add(zipStream);
// 打包结束
zipStream.push(new Uint8Array(), true);
const currentIdx = this.downLoadTaskLists.findIndex(x => x.key === key)
this.downLoadTaskLists[currentIdx].execCount += 1
} else {
const fileName = data[i].Key
const abort = new AbortController()
data[i].download = 0
this.abortController[key].push(abort)
const chunkSize = 1024 ** 2 * 5;
const totalChunks = Math.ceil(data[i]['Size'] / chunkSize);
let downloadedSize = 0;
const zipStream = new fflate.ZipDeflate(relativePath, {
level: 5,
});
zip.add(zipStream);
const signedUrlResponse = await axios.post(`${baseUrl}/generate-signed-url`, {
bucket: Bucket,
key: fileName
}, {
headers: {
'Content-Type': 'application/json'
}
});
if (!signedUrlResponse.data || !signedUrlResponse.data.url) {
throw new Error('Failed to get signed URL');
}
const { url } = signedUrlResponse.data;
for (let j = 0; j < totalChunks; j++) {
const start = j * chunkSize;
const end = Math.min(start + chunkSize - 1, data[i]['Size'] - 1);
try {
if (!url || typeof url !== 'string') {
console.error('Invalid signed URL:', url);
throw new Error('Invalid signed URL format');
}
const response = await axios.post(`${baseUrl}/proxy-download`, {
url: url,
range: `bytes=${start}-${end}`
}, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
signal: abort.signal,
responseType: 'arraybuffer'
});
if (response.status !== 200) {
console.error('Proxy download failed:', {
status: response.status,
statusText: response.statusText
});
throw new Error(`Download failed for chunk ${j}: ${response.statusText}`);
}
const chunk = new Uint8Array(response.data);
// 确保每个分片都被正确添加到zip流中
await zipStream.push(chunk, j === totalChunks - 1);
downloadedSize += chunk.byteLength;
// 更新进度
const currentIdx = this.downLoadTaskLists.findIndex(x => x.key === key);
if (currentIdx > -1) {
// 更新已下载大小
this.downLoadTaskLists[currentIdx].execSize = Math.min(
this.downLoadTaskLists[currentIdx].execSize + chunk.byteLength,
this.downLoadTaskLists[currentIdx].totalSize
);
// 如果当前文件下载完成,增加完成计数
if (downloadedSize === data[i]['Size']) {
this.downLoadTaskLists[currentIdx].execCount += 1;
}
}
} catch (fetchError) {
console.error(`Fetch error for ${fileName}:`, fetchError);
throw new Error('Download failed');
}
}
// 确保文件流被正确关闭
// await zipStream.push(new Uint8Array(), true);
}
}
// 完成所有文件的下载和打包
zip.end();
} catch (error) {
console.error('Directory download error:', error);
const index = this.downLoadTaskLists.findIndex(x => x.key === key)
index !== -1 && this.removeTask(index)
this.currentRequests--
}
},
async processFileTask (row, key) {
this.currentRequests++
try {
// ... 校对索引开始下载 ...
const findExist = this.downLoadTaskLists.findIndex(x => x.key === key)
if (findExist === -1) {
// console.log('iam gone')
return
}
const { taskName, Bucket } = row
const totalSize = row['Size']
this.downLoadTaskLists[findExist].totalCount = 1
this.downLoadTaskLists[findExist].pending = false
// 下载队列等待过程中任务会取消 需要判断是否需要继续执行
if (!this.abortController[key]) {
this.abortController[key] = []
}
const abort = new AbortController()
// const link = document.createElement('a');
// link.href = this.$store.state._S3.getSignedUrl('getObject', {
// Bucket: this.$route.params.id,
// Key: taskName,
// ResponseContentDisposition: `attachment; filename="${encodeURIComponent(taskName)}"`
// // ResponseContentDisposition: 'attachment; filename="your-filename.ext"', // 动态文件名示例
// // ResponseContentDisposition: `attachment; filename="${encodeURIComponent(row.Key)}"`,
// // ResponseContentType: 'application/octet-stream'
// });
// window.location.href = link.href;
// const activeRequest = this.$store.state._S3
// .getObject({ Bucket: this.$route.params.id, Key: taskName })
// .createReadStream()
this.abortController[key].push(abort)
// range 分片处理
const chunkSize = 1024 ** 2 * 5;
const totalChunks = Math.ceil(totalSize / chunkSize);
let downloadedSize = 0;
// const response = await fetch(this.$store.state._S3.getSignedUrl('getObject', {
// Bucket: this.$route.params.id,
// Key: taskName,
// Expires: 3600 * 5
// // ResponseContentDisposition: `attachment; filename="${encodeURIComponent(taskName)}"`
// // ResponseContentDisposition: 'attachment; filename="your-filename.ext"', // 动态文件名示例
// // ResponseContentDisposition: `attachment; filename="${encodeURIComponent(row.Key)}"`,
// // ResponseContentType: 'application/octet-stream'
// }),
// { signal: abort.signal }
// )
// const activeRequest = null
// // 需要找到匹配的项
// // 大文件就分片流式下载
// // 小文件流式下载
// activeRequest.on('httpDownloadProgress', (progress) => {
// // 先判断是否请求中断
// if (!this.abortController[key]) {
// return
// }
// const matchIndex = this.downLoadTaskLists.findIndex(x => x.key === key)
// // console.log(this.downLoadTaskLists, matchIndex)
// if (matchIndex > -1) {
// this.downLoadTaskLists[matchIndex].execSize = progress.loaded
// if (progress.loaded === totalSize) {
// this.downLoadTaskLists[matchIndex].execCount = 1
// }
// }
// });
// const reader = response.body.getReader();
// console.log(reader, 'reader', this.abortController);
// 使用readableStream()替代serviceWorker
// console.log(row.Key, '1233',)
const baseUrl = process.env.NODE_ENV === 'production'
? window.location.origin // 生产环境使用当前域名
: 'http://localhost:8081' // 开发环境使用本地服务器
// 获取预签名URL
const signedUrlResponse = await axios.post(`${baseUrl}/generate-signed-url`, {
bucket: Bucket,
key: taskName
}, {
headers: {
'Content-Type': 'application/json'
}
});
if (!signedUrlResponse.data || !signedUrlResponse.data.url) {
throw new Error('Failed to get signed URL');
}
const { url } = signedUrlResponse.data;
const fileStream = createWriteStream(row.Key, {
size: totalSize
});
const writer = fileStream.getWriter();
try {
// 分片下载
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize - 1, totalSize - 1);
try {
// 使用代理服务器转发请求
const response = await axios.post(`${baseUrl}/proxy-download`, {
url: url,
range: `bytes=${start}-${end}`
}, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
signal: abort.signal,
responseType: 'arraybuffer'
});
if (response.status !== 200) {
console.error('Proxy download failed:', {
status: response.status,
statusText: response.statusText
});
throw new Error(`Download failed for chunk ${i}: ${response.statusText}`);
}
// 直接使用response.data作为二进制数据
const chunk = new Uint8Array(response.data);
// await writer.seek(start);
await writer.write(chunk);
downloadedSize += chunk.byteLength;
// 更新进度
const currentIdx = this.downLoadTaskLists.findIndex(x => x.key === key);
if (currentIdx > -1) {
this.downLoadTaskLists[currentIdx].execSize = downloadedSize;
if (downloadedSize === totalSize) {
this.downLoadTaskLists[currentIdx].execCount = 1;
this.currentRequests--;
delete this.abortController[key];
}
}
} catch (error) {
console.error(`Error downloading chunk ${i}:`, error);
throw error;
}
}
// 所有分片下载完成后关闭writer
await writer.close();
} catch (err) {
// 发生错误时也要关闭writer
try {
await writer.close();
} catch (closeError) {
console.error('Error closing writer:', closeError);
}
// 浏览器手动 取消任务
console.log(err, '123')
const index = this.downLoadTaskLists.findIndex(x => x.key === key)
index !== -1 && this.removeTask(index)
this.currentRequests--
}
} catch (err) {
// 浏览器手动 取消任务
console.log(err, '123')
const index = this.downLoadTaskLists.findIndex(x => x.key === key)
// writable && writable?.abort()
// reader && reader?.cancel();
// 此处为浏览器手动清除或者按钮取消任务 index===-1
index !== -1 && this.removeTask(index)
this.currentRequests--
// 浏览器取消下载任务
}
finally {
// this.currentRequests--
// delete this.abortController[key]
}
},
},
};
</script>
<style lang="scss" scoped>
.customProgressContainer {
max-height: 420px;
overflow-y: auto;
.customProgressC {
margin: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e2e2;
&.title {
display: flex;
justify-content: space-between;
.el-icon-delete {
cursor: pointer;
color: #ff8746;
margin-left: 5px;
}
span {
color: #d3d6d8;
}
}
.flexName {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
color: #ff8746;
.tooltip-container {
width: 90%;
}
}
.el-icon-close {
cursor: pointer;
}
.customProgress {
display: flex;
align-items: center;
::v-deep .el-progress {
width: 80%;
display: flex;
justify-content: space-between;
align-items: center;
.el-progress__text {
width: 52px !important;
}
}
// .el-progress-bar {
// width: 90%;
// }
.tooltip-container {
max-width: 150px;
width: 120px;
text-align: right;
.text-box {
vertical-align: super;
text-align: right;
}
}
// .txt {
// flex: 1;
// text-align: right;
// width: fit-content;
// text-align: right;
// white-space: pre;
// }
}
}
}
.downLoadList {
// position: absolute;
// right: 54px;
// top: 90px;
// width: 25px;
// height: 25px;
position: relative;
.download {
font-size: 25px;
color: #ff8746;
transition: all .5s;
cursor: pointer;
&+span {
display: block;
width: 8px;
height: 8px;
}
}
.circleStatus {
position: absolute;
top: 10px;
right: 10px;
border-radius: 20px;
transition: all .2s;
background: rgb(76, 203, 146);
&.active {
animation: activeLoadIco .5s linear 1;
}
}
}
</style>
# nodejs 提供后台下载链接(解决页面预签名提示异常跨域问题CORS)
store/index
async initS3 ({ commit }, { S3, accessKeyId, secretAccessKey, endpoint }) {
commit('getS3', S3)
// 初始化服务器端S3客户端
try {
const baseUrl = process.env.NODE_ENV === 'production'
? window.location.origin // 生产环境使用当前域名
: 'http://localhost:8081' // 开发环境使用本地服务器
// console.log('Requesting URL:', `${baseUrl}/init-s3`); // 添加调试日志
const response = await axios.post(`${baseUrl}/init-s3`, {
accessKeyId,
secretAccessKey,
endpoint
}, {
headers: {
'Content-Type': 'application/json'
}
});
if (response.status !== 200) {
throw new Error(`Failed to initialize server S3 client: ${response.status} ${response.statusText}`);
}
console.log('Server S3 client initialized:', response.data);
} catch (error) {
console.error('Server S3 initialization error:', error);
// this.$notify({
// type: 'error',
// title: '初始化失败',
// message: error.message
// });
}
},
// 先初始化后端s3服务传递相关参数
store.dispatch('initS3', { S3, accessKeyId, secretAccessKey, endpoint })
.then(() => {
next()
})
.catch((error) => {
console.error('Error initializing S3:', error)
next('/main/bucket')
})
# nodejs
server/app.js
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const path = require('path');
const fs = require('fs');
const app = express();
const port = process.env.PORT || 8081;
app.use(cors());
app.use(express.json());
let s3Client = null;
// 创建 logs 目录
const logsDir = path.join(__dirname, 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// 日志记录函数
function writeLog (data) {
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const { timestamp, message } = data;
const logEntry = `[${timestamp}] ${JSON.stringify(message)}\n`;
const logFile = path.join(logsDir, `${dateStr}.txt`);
fs.appendFileSync(logFile, logEntry);
}
// API路由
app.post('/init-s3', (req, res) => {
try {
const { accessKeyId, secretAccessKey, endpoint } = req.body;
s3Client = new S3Client({
region: "EastChain-1",
endpoint,
credentials: {
accessKeyId,
secretAccessKey
},
forcePathStyle: true
});
res.json({ success: true, message: 'S3 client initialized successfully' });
} catch (error) {
console.error('S3 initialization error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/generate-signed-url', async (req, res) => {
if (!s3Client) {
return res.status(400).json({ error: 'S3 client not initialized' });
}
const { bucket, key, start, end } = req.body;
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
// Range: `bytes=${start}-${end}`
});
try {
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
res.json({ url });
} catch (err) {
console.error('Generate signed URL error:', err);
res.status(500).json({ error: err.message });
}
});
// 添加CORS中间件
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
next();
});
// 修改代理下载接口使用 axios
app.post('/proxy-download', async (req, res) => {
console.log('Received request body:', req.body);
const { url, range } = req.body;
if (!url) {
console.error('Missing URL parameter');
return res.status(400).json({ error: 'Missing URL parameter' });
}
if (!range) {
console.error('Missing range parameter');
return res.status(400).json({ error: 'Missing range parameter' });
}
try {
let parsedUrl;
try {
parsedUrl = new URL(url);
if (!parsedUrl.protocol || !parsedUrl.hostname) {
throw new Error('Invalid URL format');
}
} catch (e) {
console.error('URL validation error:', e.message);
return res.status(400).json({
error: 'Invalid URL format',
details: e.message,
url: url
});
}
console.log('Making request to:', {
url: parsedUrl.toString(),
range: range
});
// 使用 axios 发送请求
const response = await axios({
method: 'get',
url: parsedUrl.toString(),
headers: {
Range: range
},
responseType: 'stream'
});
// 转发响应头
res.setHeader('Content-Type', response.headers['content-type'] || 'application/octet-stream');
res.setHeader('Content-Length', response.headers['content-length']);
res.setHeader('Content-Range', response.headers['content-range']);
res.setHeader('Accept-Ranges', 'bytes');
// 流式转发响应体
response.data.pipe(res);
} catch (error) {
console.error('Proxy error:', error);
if (!res.headersSent) {
res.status(500).json({
error: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
}
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.error('Server error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.post('/logData', (req, res) => {
try {
const data = req.body;
writeLog(data);
res.json({ success: true, message: '数据已成功记录' });
} catch (error) {
console.error('记录日志时出错:', error);
res.status(500).json({ success: false, message: '记录日志失败', error: error.message });
}
});
// 静态文件服务
app.use(express.static(path.join(__dirname, 'dist')));
// 所有其他路由重定向到 index.html(支持 Vue Router 的 history 模式)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// 启动服务器
app.listen(port, () => {
console.log(`Proxy server running on port ${port}`);
});