414 lines
12 KiB
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>
|
|
<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> |