1. 效果图:
图片上传不上去
可暂停、继续、取消上传,失败五次后停止上传
2. fileSlicingUpload.js
js

import { reactive, h, ref, computed, nextTick } from 'vue'
import { ElMessageBox, ElProgress, ElButton, ElMessage } from 'element-plus'
import axios from 'axios'
import { createTask, getPreSignedUrl, completeTask } from './upload.js'
/** worker 线程部分 开始*/
let worker = null
const workerStatus = ref('terminated')
const initWorker = (callback) => {
if (workerStatus.value === 'terminated') {
worker = new Worker(new URL('./fileWorker.js', import.meta.url))
workerStatus.value = 'ready'
console.log('worker-status', workerStatus.value)
}
callback && callback()
worker.onerror = (e) => {
console.log('worker-error : ', e)
}
console.log('worker-status', workerStatus.value)
}
/** worker 线程部分 结束 */
/** 开始上传文件 */
let messageBox = null
export const startUploadFile = async (file) => {
try {
// 初始话worker线程
initWorker(() => {
workerStatus.value = 'working'
})
const chunkSize = 1024 * 1024 * 5 // 5M
const chunkCount = Math.ceil(file.size / chunkSize)
messageBox = showProgressMsgDialog()
const id = await startCreateTask(file, chunkSize)
if (messageBox._isClosed) return
const urls = await startGetPreSignedUrl(id, chunkCount)
if (messageBox._isClosed) return
const chunks = await startChunksFile(file, chunkSize, chunkCount)
if (messageBox._isClosed) return
const result = await startEncryptChunks(chunks)
if (messageBox._isClosed) return
// 等待所有上传任务完成
await startUploadChunks(result, urls)
// 确保所有任务完成后才执行合并
return await startMergeChunks(id)
} catch (e) {
console.log('startUploadFile error: ', e)
addProgressMsg('update', '文件上传失败...请重新上传', 100, '#f56c6c')
resetAllStates('报错了')
}
}
const statusStore = ref([])
const status = computed({
get() {
return [h('div', { style: { width: '100%' }, className: 'container' }, [...statusStore.value])]
},
set(val) {
statusStore.value.push(val)
},
})
const showProgressMsgDialog = () => {
return ElMessageBox({
title: '上传进度',
closeOnClickModal: false,
customStyle: { width: '100%' },
customClass: 'message-box-upload',
draggable: true,
showClose: false,
confirmButtonText: '确定',
message: () => {
return h(
'div',
{
style: { display: 'flex', width: '100%' },
class: 'message-box',
},
status.value,
)
},
beforeClose(action, instance, done) {
if ((uploadCurrent === uploadTotal && !uploadControl.isPaused) || uploadControl.isCancelled) {
resetAllStates('用户主动关闭')
nextTick(() => {
messageBox._isClosed = true
done()
})
} else {
ElMessage.warning('当前阶段不可关闭')
}
},
})
}
const addProgressMsg = (...params) => {
const createEl = (...p) => {
const [text, percentage, color, hEl = []] = Array.from(...p)
return h('div', { style: { width: '100%', marginTop: '10px' } }, [
h('p', {}, text),
h(ElProgress, {
type: 'line',
percentage: percentage,
color: color,
style: { flex: 1, marginLeft: '10px' },
}),
...hEl,
])
}
if (params[0] === 'add') {
params.shift()
statusStore.value.push(createEl(params))
return
}
params.shift()
const index = statusStore.value.length - 1
statusStore.value[index] = createEl(params)
}
// 开始创建任务
const startCreateTask = async (file, chunkSize) => {
const md5 = await fileEncryption(file, chunkSize)
if (messageBox._isClosed) return
try {
const res = await createTask({
fileMetadata: {
name: file.name,
size: file.size,
md5: md5,
open: true,
},
partSize: chunkSize,
})
if (res.code === 200) {
addProgressMsg('update', '任务创建成功...', 100, '#67c23a')
return res.data.id
}
} catch (e) {
addProgressMsg('update', '任务创建失败...请重新上传', 100, '#f56c6c')
throw new Error('任务创建失败...请重新上传')
}
}
// 文件加密(对整个文件)
const fileEncryption = async (file, chunkSize) => {
addProgressMsg('add', '文件哈希计算中...', 0, '#e6a23c')
return new Promise((resolve, reject) => {
let percentage = 0
worker.onmessage = (e) => {
switch (e.data.type) {
case 'encryptFileProgress':
requestAnimationFrame(() => {
if (messageBox._isClosed) {
return console.log('文件哈希计算...停止')
}
percentage = e.data.percentage
addProgressMsg('update', '文件哈希计算中...', e.data.percentage, '#e6a23c')
})
break
case 'allEncryptFileComplete':
if (percentage === 100) {
console.log('文件哈希计算完成')
resolve(e.data.md5)
} else {
let time = setInterval(() => {
if (percentage === 100) {
clearInterval(time)
time = null
resolve(e.data.md5)
console.log('文件哈希计算完成')
}
}, 500)
}
break
}
}
worker.postMessage({
type: 'allEncryptFile',
file,
chunkSize,
})
})
}
/** 开始获取地址 */
const startGetPreSignedUrl = async (id, chunkCount) => {
try {
addProgressMsg('update', '开始获取地址...', 0, '#e6a23c')
const envType_url = 'presignedUrlLists'
// const envType_url = 'presignedUrls';
const res = await getPreSignedUrl({
id,
partNums: generateArray(chunkCount),
})
if (res.code === 200) {
addProgressMsg('update', '成功获取地址...', 100, '#67c23a')
return envType_url === 'presignedUrlLists'
? Object.values(res.data[envType_url]).map((item) => item[0])
: res.data[envType_url]
}
} catch (e) {
addProgressMsg('update', '获取地址失败...', 100, '#f56c6c')
throw new Error('获取地址失败...请重新上传')
}
}
/** 根据给定数字,生成一个数组,数组的元素是从1开始的数字 */
const generateArray = (num) => {
const arr = []
for (let i = 1; i <= num; i++) {
arr.push(i)
}
return arr.join(',')
}
/** 开始文件切片 */
const startChunksFile = async (file, chunkSize, chunkCount) => {
// 添加切片进度条
addProgressMsg('add', '开始文件切片...', 0, '#409eff')
return new Promise((resolve) => {
let percentage = 0
worker.onmessage = (e) => {
switch (e.data.type) {
case 'chunkProgress':
requestAnimationFrame(() => {
if (messageBox._isClosed) {
return console.log('文件切片中...停止')
}
const text = `切片进度:${e.data.current}/${e.data.total}`
percentage = Number(((e.data.current / e.data.total) * 100).toFixed(2))
addProgressMsg('update', text, percentage, '#409eff')
})
break
case 'chunkComplete':
if (percentage === 100) {
console.log('切片完成')
resolve(e.data.chunks)
} else {
let time = setInterval(() => {
if (percentage === 100) {
clearInterval(time)
time = null
resolve(e.data.chunks)
console.log('切片完成')
}
}, 500)
}
break
}
}
worker.postMessage({
type: 'chunk',
file,
chunkSize,
})
})
}
/** 开始对每一个切片进行加密 */
const startEncryptChunks = async (chunks) => {
addProgressMsg('add', '准备加密...', 0, '#e6a23c')
return new Promise((resolve) => {
let percentage = 0
worker.onmessage = (e) => {
switch (e.data.type) {
case 'encryptProgress':
requestAnimationFrame(() => {
if (messageBox._isClosed) {
return console.log('文件加密中...停止')
}
const text = `加密进度:${e.data.current}/${e.data.total}`
percentage = Number(((e.data.current / e.data.total) * 100).toFixed(2))
addProgressMsg('update', text, percentage, '#e6a23c')
})
break
case 'encryptComplete':
if (percentage === 100) {
console.log('加密完成')
resolve(e.data.encryptedChunks)
} else {
let time = setInterval(() => {
if (percentage === 100) {
clearInterval(time)
time = null
resolve(e.data.encryptedChunks)
console.log('加密完成')
}
}, 500)
}
break
}
}
worker.postMessage({
type: 'encrypt',
chunks,
})
})
}
// 开始上传切片
const startUploadChunks = async (chunks, urls) => {
const tasks = chunks.map((item, index) => {
return {
file: item.chunk,
url: urls[index],
}
})
return uploadFile(tasks, 3)
}
// 新增状态控制变量
const uploadControl = reactive({
isPaused: false, // 是否暂停上传
isCancelled: false, // 是否取消上传
cancelTokens: new Map(), // 存储每个任务的取消令牌
retryCounts: new Map(), // 存储每个任务的失败次数,
})
let uploadCurrent = 0
let uploadTotal = 0
// 接口发起请求时控制并发数,上传失败时需要记录失败的切片,重新上传失败的切片
const uploadFile = (tasks, maxConcurrency = 3) => {
return new Promise((resolve) => {
const results = []
let queue = [...tasks]
let activeCount = 0
const btnEl = h(
'div',
{
style: {
marginTop: '10px',
display: 'flex',
gap: '10px',
},
},
[
h(
ElButton,
{
type: uploadControl.isPaused ? 'success' : 'warning',
onClick: () => {
uploadControl.isPaused = !uploadControl.isPaused
if (!uploadControl.isPaused) {
run() // 恢复上传
}
},
},
{
// 使用default插槽
default: () => [uploadControl.isPaused ? '继续上传' : '暂停上传'],
},
),
h(
ElButton,
{
type: 'danger',
onClick: () => {
queue = []
uploadControl.isCancelled = true
pauseAllUploads('用户取消上传') // 调用暂停所有上传的方法
resetAllStates()
ElMessageBox.alert('上传已取消', '提示')
},
},
{
default: () => ['取消上传'],
},
),
],
)
addProgressMsg('add', '开始上传...', 0, '#67c23a', [btnEl])
// 初始化上传状态
uploadControl.isPaused = false
uploadControl.cancelTokens.clear()
uploadCurrent = 0
uploadTotal = tasks.length
let run = async () => {
if (queue.length === 0 && activeCount === 0) {
// 上传完成后更新状态,隐藏暂停按钮
if (!uploadControl.isCancelled) {
addProgressMsg('update', '上传完成...', 100, '#67c23a')
resolve(results)
}
return
}
while (queue.length > 0 && activeCount < maxConcurrency && !uploadControl.isPaused) {
const task = queue.shift()
const { file, url } = task
// 创建取消令牌
const cancelToken = axios.CancelToken.source()
uploadControl.cancelTokens.set(url, cancelToken)
// 初始化重试次数
if (!uploadControl.retryCounts.has(url)) {
uploadControl.retryCounts.set(url, 0)
}
activeCount++
try {
const res = await axios.post(url, file, {
headers: {
'X-HTTP-Method-Override': 'PUT',
'Content-Type': '',
},
maxContentLength: 1024 * 1024 * 10,
maxBodyLength: 1024 * 1024 * 10,
timeout: 0,
cancelToken: cancelToken.token,
onUploadProgress: (progressEvent) => {
if (!uploadControl.isPaused) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
)
addProgressMsg(
'update',
`上传进度:${uploadCurrent + 1}/${uploadTotal} (${percentCompleted}%)`,
parseInt((((uploadCurrent + 1) / uploadTotal) * 100).toFixed(0)),
'#67c23a',
[btnEl],
)
}
},
})
results.push(res.data)
uploadControl.cancelTokens.delete(url)
uploadCurrent += 1
addProgressMsg(
'update',
`上传进度:${uploadCurrent}/${uploadTotal} (100%)`,
parseInt(((uploadCurrent / uploadTotal) * 100).toFixed(0)),
'#67c23a',
[btnEl],
)
activeCount--
if (!uploadControl.isPaused) {
run()
}
} catch (error) {
if (!axios.isCancel(error)) {
const retryCount = uploadControl.retryCounts.get(url) + 1
uploadControl.retryCounts.set(url, retryCount)
if (retryCount == 5) {
// 超过5次重试,取消上传
uploadControl.isCancelled = true
ElMessageBox.alert('上传失败次数过多,已取消上传', '错误')
} else {
queue.push(task) // 重新加入队列重试
}
}
activeCount--
if (!uploadControl.isPaused && !uploadControl.isCancelled) {
run()
}
}
}
}
run()
})
}
// 暂停所有上传的方法
const pauseAllUploads = (msg = '用户暂停上传') => {
uploadControl.isPaused = true
uploadControl.cancelTokens.forEach((cancelToken) => {
cancelToken.cancel('用户暂停上传')
})
uploadControl.cancelTokens.clear()
nextTick(() => {
ElMessageBox.close()
})
}
const resetAllStates = (msg) => {
// 重置上传控制状态
uploadControl.isPaused = false
uploadControl.isCancelled = false
uploadControl.cancelTokens.clear()
uploadControl.retryCounts.clear()
uploadCurrent = 0
uploadTotal = 0
statusStore.value = []
workerStatus.value = 'terminated'
worker.postMessage({ type: 'cancelAll' })
console.log(msg)
}
// 所有任务上传完成后,进行合并
const startMergeChunks = async (id) => {
const res = await completeTask(id)
if (res.code === 200) {
resetAllStates()
ElMessageBox.close()
ElMessageBox.alert('上传完成', '提示')
return res.data
}
}
3. worker.js
js
// 导入SparkMD5库用于文件哈希计算
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js')
// 定义控制器对象,用于管理切片和加密过程的中断
const controllers = {
chunk: new AbortController(), // 文件切片控制器
encrypt: new AbortController(), // 加密过程控制器
allEncryptFile: new AbortController(), // 整个文件加密控制器
}
/**
* 带中断检查的异步处理函数
* @param {string} type - 处理类型('chunk'或'encrypt')
* @param {Function} processFn - 实际要执行的处理函数
*/
const processWithAbortCheck = async (type, processFn) => {
const signal = controllers[type].signal // 获取中断信号
try {
await processFn(signal) // 执行处理函数
} finally {
// 如果操作被中断,则重置控制器
if (signal.aborted) {
controllers[type] = new AbortController()
}
}
}
/**
* 文件切片函数
* @param {File} file - 要切片的文件对象
* @param {number} chunkSize - 每个切片的大小(字节)
* @param {AbortSignal} signal - 中断信号
* @returns {Array} 切片后的文件块数组
*/
const chunkFile = async (file, chunkSize, signal) => {
const chunks = []
// 计算需要的切片数量
const chunkCount = Math.ceil(file.size / chunkSize)
// 循环处理每个切片
for (let i = 0; i < chunkCount; i++) {
// 检查是否收到中断信号
if (signal.aborted) return
// 计算当前切片的起始和结束位置
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
// 切片并存入数组
chunks.push(file.slice(start, end))
// 向主线程发送进度信息
self.postMessage({
type: 'chunkProgress', // 消息类型:切片进度
current: i + 1, // 当前切片序号
total: chunkCount, // 总切片数
})
}
return chunks
}
/**
* 文件切片加密函数
* @param {Array} chunks - 文件切片数组
* @param {AbortSignal} signal - 中断信号
* @returns {Array} 加密后的切片数组
*/
const encryptChunks = async (chunks, signal) => {
const encryptedChunks = []
// 创建SparkMD5实例用于计算哈希
const spark = new self.SparkMD5.ArrayBuffer()
// 循环处理每个切片
for (let i = 0; i < chunks.length; i++) {
// 检查是否收到中断信号
if (signal.aborted) return
// 读取切片内容并计算哈希
const buffer = await chunks[i].arrayBuffer()
spark.append(buffer)
// 存储加密后的切片信息
encryptedChunks.push({
chunk: chunks[i], // 原始切片
hash: spark.end(), // 切片哈希值
})
// 向主线程发送进度信息
self.postMessage({
type: 'encryptProgress', // 消息类型:加密进度
current: i + 1, // 当前加密序号
total: chunks.length, // 总切片数
})
}
return encryptedChunks
}
const allEncryptFile = async (file, chunkSize, signal) => {
const spark = new self.SparkMD5.ArrayBuffer()
let start = 0
console.log('allEncryptFile - worker')
while (start < file.size) {
// 检查是否收到中断信号
if (signal.aborted) return
const chunk = file.slice(start, start + chunkSize)
const buffer = await chunk.arrayBuffer()
spark.append(buffer)
start += chunkSize
const percentage = parseInt(((start / file.size) * 100).toFixed(2))
self.postMessage({
type: 'encryptFileProgress', // 消息类型: 加密进度
percentage: percentage >= 100 ? 100 : percentage,
})
}
const md5 = spark.end()
return md5
}
// 监听主线程发送的消息
self.onmessage = async (e) => {
const { type, file, chunkSize, chunks } = e.data
// 处理取消所有操作的请求
if (type === 'cancelAll') {
// 中断所有控制器
Object.values(controllers).forEach((ctrl) => ctrl.abort())
return
}
// 处理文件切片请求
if (type === 'chunk') {
await processWithAbortCheck('chunk', async (signal) => {
// 执行文件切片
const chunks = await chunkFile(file, chunkSize, signal)
// 切片完成后发送完成消息
self.postMessage({ type: 'chunkComplete', chunks })
})
}
// 处理加密请求
if (type === 'encrypt') {
await processWithAbortCheck('encrypt', async (signal) => {
// 执行切片加密
const encryptedChunks = await encryptChunks(chunks, signal)
// 加密完成后发送完成消息
self.postMessage({ type: 'encryptComplete', encryptedChunks })
})
}
// 对整个文件进行加密
if (type === 'allEncryptFile') {
await processWithAbortCheck('allEncryptFile', async (signal) => {
const md5 = await allEncryptFile(file, chunkSize, signal)
self.postMessage({ type: 'allEncryptFileComplete', md5 })
})
}
}
4. upload.js
js
export const createTask = (data) => {
return request({
url: '请求地址',
method: 'post',
data,
})
}
export const getPreSignedUrl = (data) => {
return request({
url: `请求地址`,
method: 'post',
data,
})
}
// 分片上传任务完成
export const completeTask = (id) => {
return request({
url: `请求地址`,
method: 'post',
data: {
id,
type: 'person',
},
})
}