mrr.sj.front/components/ChunkUpload/ChunkUpload.vue

414 lines
12 KiB
Vue

<template>
<view class="chunk-upload">
<!-- 上传按钮 -->
<button @click="chooseVideo" :disabled="uploading">
{{ uploading ? `上传中...${progress}%` : '选择视频上传' }}
</button>
<!-- 进度条 -->
<progress v-if="uploading" :percent="progress" show-info stroke-width="3" />
<!-- 上传状态 -->
<view class="status" v-if="status">
{{ status }}
</view>
</view>
</template>
<script>
// 引入 spark-md5 计算文件哈希
import SparkMD5 from 'spark-md5'
export default {
name: 'ChunkUpload',
props: {
// 上传接口地址
uploadUrl: {
type: String,
required: true
},
// 分片大小 (默认2MB)
chunkSize: {
type: Number,
default: 2 * 1024 * 1024
},
// 允许的视频类型
accept: {
type: String,
default: 'video/*'
},
// 最大文件大小 (MB)
maxSize: {
type: Number,
default: 500
},
},
data() {
return {
uploading: false,
progress: 0,
status: '',
file: null,
fileMd5: '',
cancelToken: null
};
},
methods: {
// 选择视频文件
async chooseVideo() {
if (this.uploading) {
uni.showToast({ title: '正在上传中', icon: 'none' })
return
}
try {
const res = await this.getFile()
if (!res) return
this.file = res
const sizeMB = res.size / 1024 / 1024
if (sizeMB > this.maxSize) {
uni.showToast({ title: `文件不能超过${this.maxSize}MB`, icon: 'none' })
return
}
uni.showLoading({ title: '计算文件MD5...', mask: true })
this.fileMd5 = await this.calculateFileMD5(res)
uni.hideLoading()
this.startUpload()
} catch (error) {
console.error('选择文件出错:', error)
uni.hideLoading()
uni.showToast({ title: '选择文件失败', icon: 'none' })
}
},
// 获取文件 (兼容各平台)
getFile() {
return new Promise((resolve, reject) => {
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = this.accept
input.onchange = (e) => {
resolve(e.target.files[0])
}
input.click()
// #endif
// #ifndef H5
uni.chooseMedia({
count: 1,
mediaType: ['video'],
sourceType: ['album', 'camera'],
success: (res) => {
resolve(res.tempFiles[0])
},
fail: (err) => {
reject(err)
}
})
// #endif
})
},
// 计算文件MD5 (分块计算)
calculateFileMD5(file) {
return new Promise((resolve) => {
const chunkSize = 1 * 1024 * 1024 // 1MB分块计算MD5
const chunks = Math.ceil(file.size / chunkSize)
const spark = new SparkMD5.ArrayBuffer()
let currentChunk = 0
const loadNext = () => {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
// #ifdef H5
const reader = new FileReader()
reader.onload = (e) => {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
reader.readAsArrayBuffer(file.slice(start, end))
// #endif
// #ifdef APP-PLUS
const fileReader = new plus.io.FileReader()
fileReader.onload = (e) => {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
fileReader.readAsArrayBuffer(file.slice(start, end))
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-TOUTIAO
const fs = uni.getFileSystemManager()
fs.readFile({
filePath: file.path,
position: start,
length: end - start,
arrayBuffer: true,
success: (res) => {
spark.append(res.data)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
},
fail: (err) => {
console.error("读取文件失败:", err)
reject(err)
}
})
// #endif
}
loadNext()
})
},
// 开始分片上传
async startUpload() {
if (!this.file) return
this.uploading = true
this.progress = 0
this.status = '准备上传...'
try {
// 1. 检查文件是否已上传过
const checkRes = await this.checkFile()
if (checkRes.uploaded) {
this.status = '文件已存在,秒传成功'
this.progress = 100
this.$emit('success', checkRes)
return
}
// 2. 开始分片上传
const uploadedChunks = checkRes.uploadedChunks || []
const chunks = Math.ceil(this.file.size / this.chunkSize)
let uploadedSize = 0
for (let i = 0; i < chunks; i++) {
if (uploadedChunks.includes(i)) {
uploadedSize += this.getChunkSize(i)
this.updateProgress(uploadedSize)
continue
}
// 上传当前分片
await this.uploadChunk(i)
uploadedSize += this.getChunkSize(i)
this.updateProgress(uploadedSize)
}
// 3. 合并分片
this.status = '正在合并文件...'
const mergeRes = await this.mergeChunks()
this.status = '上传成功'
this.$emit('success', mergeRes)
} catch (error) {
console.error('上传出错:', error)
this.status = '上传失败: ' + (error.message || error.errMsg || '未知错误')
this.$emit('fail', error)
} finally {
this.uploading = false
}
},
// 检查文件状态
checkFile() {
return new Promise((resolve, reject) => {
uni.request({
url: `${this.uploadUrl}/check`,
method: 'POST',
data: {
fileMd5: this.fileMd5,
fileName: this.file.name,
fileSize: this.file.size,
chunkSize: this.chunkSize
},
success: (res) => {
if (res.data.code === 0) {
resolve(res.data.data)
} else {
reject(new Error(res.data.msg))
}
},
fail: (err) => {
reject(err)
}
})
})
},
// 上传分片
uploadChunk(chunkIndex) {
return new Promise((resolve, reject) => {
const start = chunkIndex * this.chunkSize
const end = Math.min(start + this.chunkSize, this.file.size)
const chunkBlob = this.file.slice(start, end)
// #ifdef H5 || APP-PLUS
const formData = new FormData()
formData.append('file', chunkBlob)
formData.append('chunkIndex', chunkIndex)
formData.append('fileMd5', this.fileMd5)
formData.append('chunkSize', this.chunkSize)
formData.append('totalChunks', Math.ceil(this.file.size / this.chunkSize))
const xhr = new XMLHttpRequest()
this.cancelToken = xhr
xhr.open('POST', `${this.uploadUrl}/upload`, true)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
// 计算当前分片的上传进度
const chunkProgress = Math.round((e.loaded / e.total) * 100)
const baseProgress = (chunkIndex / Math.ceil(this.file.size / this.chunkSize)) * 100
this.progress = Math.min(baseProgress + (chunkProgress / Math.ceil(this.file.size / this.chunkSize)), 100)
}
}
xhr.onload = () => {
if (xhr.status === 200) {
const res = JSON.parse(xhr.responseText)
if (res.code === 0) {
resolve(res.data)
} else {
reject(new Error(res.msg))
}
} else {
reject(new Error(`上传失败: ${xhr.status}`))
}
}
xhr.onerror = () => {
reject(new Error('上传出错'))
}
xhr.send(formData)
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-TOUTIAO
uni.uploadFile({
url: `${this.uploadUrl}/upload`,
filePath: this.file.path,
name: 'file',
formData: {
chunkIndex,
fileMd5: this.fileMd5,
chunkSize: this.chunkSize,
totalChunks: Math.ceil(this.file.size / this.chunkSize)
},
header: {
'Content-Type': 'multipart/form-data'
},
success: (res) => {
const data = JSON.parse(res.data)
if (data.code === 0) {
resolve(data.data)
} else {
reject(new Error(data.msg))
}
},
fail: (err) => {
reject(err)
}
})
// #endif
})
},
// 合并分片
mergeChunks() {
return new Promise((resolve, reject) => {
uni.request({
url: `${this.uploadUrl}/merge`,
method: 'POST',
data: {
fileMd5: this.fileMd5,
fileName: this.file.name,
fileSize: this.file.size,
chunkSize: this.chunkSize
},
success: (res) => {
if (res.data.code === 0) {
resolve(res.data.data)
} else {
reject(new Error(res.data.msg))
}
},
fail: (err) => {
reject(err)
}
})
})
},
// 获取当前分片大小
getChunkSize(chunkIndex) {
const start = chunkIndex * this.chunkSize
const end = Math.min(start + this.chunkSize, this.file.size)
return end - start
},
// 更新进度
updateProgress(uploadedSize) {
this.progress = Math.round((uploadedSize / this.file.size) * 100)
},
// 取消上传
cancelUpload() {
if (this.cancelToken) {
// #ifdef H5 || APP-PLUS
this.cancelToken.abort()
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-TOUTIAO
// 小程序端无法直接取消上传,需要标记取消状态
// #endif
this.cancelToken = null
}
this.uploading = false
this.status = '上传已取消'
this.$emit('cancel')
}
}
}
</script>
<style scoped>
.chunk-upload {
padding: 20rpx;
}
button {
margin-bottom: 20rpx;
}
.status {
margin-top: 20rpx;
color: #666;
font-size: 24rpx;
}
</style>