feat(upload): 重构分片上传功能,支持微信小程序与APP端统一处理

- 将原有的微信小程序和APP端分开实现的分片上传逻辑,
  统一整合到新的 `ChunkUpload` 类中,提升代码复用性和可维护性
- 修改 API 接口参数 `chunkIndex` 为 `partNumber`,以适配标准分片上传协议
- 引入 `UploadConfig` 配置项,支持自定义分片大小和并发限制
- 新增上传进度展示逻辑,优化用户交互体验
- 移除无用的调试日志及冗余上传方法,精简业务流程
- 完善类型定义,新增 `UploadConfig` 和 `ProgressInfo` 接口
```
This commit is contained in:
liuBingWei 2025-09-26 19:13:31 +08:00
parent adf17d57fc
commit 19b065c09d
7 changed files with 995 additions and 821 deletions

View File

@ -3,10 +3,6 @@ import config from "@/config";
import { getToken } from "@/utils/auth";
/**初始化上传 */
export function initChunkUpload(fileName, fileSize) {
return request({
@ -34,7 +30,7 @@ export function uploadChunk(uploadId, filePath, chunkIndex, formattedPath) {
formData: {
uploadId: uploadId,
filePath: filePath,
chunkIndex: chunkIndex,
partNumber: chunkIndex,
},
success: (res) => {
try {

View File

@ -16,8 +16,7 @@
<script setup>
import { ref } from 'vue'
import modal from '@/plugins/modal'
import { wxChunkUploader } from '@/utils/ChunkUploaderWx'
import appChunkUploader from '@/utils/ChunkUploaderApp'
import { chunkUpload } from '@/utils/ChunkUpload'
const videoFile = ref(null)
@ -27,7 +26,6 @@ const videoFile = ref(null)
*/
const handleUpload = (data) => {
videoFile.value = data
console.log('上传的视频文件:', data)
}
/**
@ -35,7 +33,6 @@ const handleUpload = (data) => {
*/
const handleDelete = () => {
videoFile.value = null
console.log('视频文件已删除')
}
/**
@ -48,12 +45,14 @@ const validateParams = () => {
return false
}
/**
* 微信小程序分片上传
* @returns {Promise<boolean>} 上传结果
* 开始上传
*/
const handleWxChunkUpload = async () => {
const result = await wxChunkUploader.upload({
const start = async () => {
if (!validateParams()) return
const result = await chunkUpload.upload({
file: videoFile.value,
onSuccess: () => {
modal.msgSuccess('上传成功')
@ -64,50 +63,6 @@ const handleWxChunkUpload = async () => {
});
return result
}
/**
* APP端分片上传
* @returns {Promise<boolean>} 上传结果
*/
const handleAppChunkUpload = async () => {
try {
const file = videoFile.value
const result = await appChunkUploader.upload({
file: file,
onSuccess: (result) => {
console.log('上传成功:', result)
},
onError: (error) => {
console.error('上传失败:', error)
}
})
return result
} catch (error) {
console.error('APP上传失败:', error)
throw error
}
}
/**
* 开始上传
*/
const start = async () => {
if (!validateParams()) return
try {
// #ifdef MP-WEIXIN
await handleWxChunkUpload()
// #endif
// #ifdef APP-PLUS
await handleAppChunkUpload()
// #endif
} catch (error) {
console.error('上传过程出错:', error)
}
}
</script>
<style lang="scss" scoped>

View File

@ -7,6 +7,8 @@ export interface UploadOptions {
onSuccess?: (result: any) => void;
/**失败回调 */
onError?: (error: any) => void;
/**上传配置 */
options?: UploadConfig;
}
export interface File {
@ -16,13 +18,18 @@ export interface File {
size: number;
}
export interface UploadConfig {
/**分片大小,单位字节 */
chunkSize?: number;
/**并发上传限制 */
concurrentLimit?: number;
}
export interface UploadData {
/**上传编号 */
uploadId: string;
/**文件在云端保存路径 */
saveFilePath: string;
/**上传文件的名称 */
uploadFileName: string;
/**上传文件的大小 */
fileSize: number;
/**分片数量 */
@ -43,5 +50,15 @@ export interface ChunkTask {
end: number;
}
/**
*
*/
export interface ProgressInfo {
/** 已完成的分片数量 */
completedChunks: number;
/** 当前显示的上传进度(整数,如 0, 10, 20... */
uploadProgress: number;
/** 总分片数量 */
chunkCount: number;
}

721
src/utils/ChunkUpload.ts Normal file
View File

@ -0,0 +1,721 @@
import modal from '@/plugins/modal'
import { initChunkUpload, uploadChunk, completeChunkUpload } from '@/api/system/chunkUpload'
import { UploadOptions, PartETag, File, UploadData, ProgressInfo } from '@/types/upload'
import TaskQueue from '@/utils/TaskQueue'
// 声明微信小程序全局对象
declare const wx: any;
/**
*
*
*/
export class ChunkUpload {
/**
*
*/
private chunkSize: number;
/**
*
*/
private concurrentLimit: number;
/**
*
*/
private static readonly PROGRESS_UPDATE_INTERVAL = 10;
/**
* -
* 15MB2
*/
constructor() {
this.chunkSize = 15 * 1024 * 1024; // 默认分片大小15MB
this.concurrentLimit = 2; // 并发上传的分片数量
}
/**
*
* @param params
*
* @param params.file path和size属性
* @param params.onSuccess { success: true }
* @param params.onError
* @param params.options { chunkSize?: number; concurrentLimit?: number }
* @param params.options.chunkSize 15MB (15 * 1024 * 1024)
* @param params.options.concurrentLimit 2
*
* @returns Promise<boolean> Promiseresolve(true)resolve(false)
*/
async upload(params: UploadOptions): Promise<boolean> {
const {
file,
onSuccess,
onError,
options = {} as { chunkSize?: number; concurrentLimit?: number }
} = params
try {
// 1.检验文件的参数
this._validateParams(file);
//2.获取文件信息
const { actualFilePath, actualFileSize, actualFileName } = this.getFileInfo(file);
modal.loading("准备上传...")
// 3.初始化分片数据
const chunkSize = options.chunkSize || this.chunkSize;
const chunkCount = Math.ceil(actualFileSize / chunkSize);
const concurrentLimit = options.concurrentLimit || this.concurrentLimit;
let partETags: PartETag[] = [];
//4.初始化分片上传
const initResult = await initChunkUpload(actualFileName, actualFileSize)
if (initResult.code !== 200) throw new Error("初始化上传失败")
const { uploadId, filePath: serverFilePath } = initResult.data
//5.将文件移动到应用 沙盒 目录
// #ifdef APP-PLUS
const localFilePath = await this.copyFileToSandbox(actualFilePath)
// #endif
//6.开始上传分片
modal.closeLoading()
modal.loading("上传中...")
const progressInfo: ProgressInfo = {
completedChunks: 0,
uploadProgress: 0,
chunkCount
}
// 7.并发上传数据
const uploadData = {
uploadId,
saveFilePath: serverFilePath,
fileSize: actualFileSize,
chunkCount,
filePath: actualFilePath
};
// #ifdef APP-PLUS
partETags = await this.uploadChunksWithTaskQueue(
uploadData,
chunkSize,
concurrentLimit,
localFilePath,
progressInfo
)
// #endif
// #ifdef MP-WEIXIN
partETags = await this._uploadChunks(uploadData, concurrentLimit, progressInfo);
// #endif
//8.合并分片
modal.closeLoading();
modal.loading("正在合并分片...")
//完成分片上传
await completeChunkUpload(
uploadId, serverFilePath, actualFileSize, actualFileName, partETags
)
// 9.将临时文件删除,防止占用空间
// #ifdef APP-PLUS
await this.deleteLocalFile(localFilePath)
// #endif
modal.closeLoading()
// 10.执行成功回调
onSuccess?.({ success: true })
return true
} catch (error) {
modal.closeLoading()
const errorMessage = error instanceof Error ? error.message : `上传失败`
onError?.(errorMessage)
return false
}
}
/**
*
*
* @param file -
* @throws {Error}
* @throws {Error}
*/
_validateParams(file: File) {
if (!file.path) throw new Error("文件路径不存在");
if (!file.size) throw new Error("文件大小不存在");
}
/**
*
*
* @param file path和size属性
* @returns
* @returns actualFilePath
* @returns actualFileSize
* @returns actualFileName
*/
getFileInfo(file: File): { actualFilePath: string; actualFileSize: number; actualFileName: string } {
const actualFilePath = file.path;
const actualFileSize = file.size;
let actualFileName: string;
// #ifdef APP-PLUS
actualFileName = this.getFileName(file.path);
// #endif
// #ifdef MP-WEIXIN
actualFileName = `weixin_${Date.now()}.${this.getFileExtension(file.path)}`;
// #endif
return {
actualFilePath,
actualFileSize,
actualFileName
};
}
/**
*
* @param filePath
* @returns string
*/
getFileName(filePath: string): string {
if (!filePath) return ""
const slashIndex = filePath.lastIndexOf("/");
if (slashIndex === -1) return filePath;
return filePath.substring(slashIndex + 1);
};
/**
*
* @param filePath
* @returns string
*/
getFileExtension(filePath: string): string {
if (!filePath) return ""
const dotIndex = filePath.lastIndexOf(".");
if (dotIndex === -1) return ""
return filePath.substring(dotIndex + 1).toLowerCase();
};
/**
*
* @param srcFilePath
* @returns Promise<string>
*/
copyFileToSandbox(srcFilePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const newName = `file_${Date.now()}.${this.getFileExtension(srcFilePath)}`;
plus.io.requestFileSystem(
plus.io.PRIVATE_DOC,
(dstEntry) => {
plus.io.resolveLocalFileSystemURL(
srcFilePath,
(srcEntry) => {
srcEntry.copyTo(
dstEntry.root,
newName,
(entry) => {
if (entry.fullPath) {
resolve(entry.fullPath);
} else {
reject(new Error('File path is undefined'));
}
},
(e) => reject(e)
);
},
(e) => reject(e)
);
},
(e) => reject(e)
);
});
};
/**
* end位置
* @param start
* @param chunkSize
* @param fileSize
* @param index
* @param totalChunks
* @returns number
*/
getSliceEnd(start: number, chunkSize: number, fileSize: number, index: number, totalChunks: number) {
return index < totalChunks - 1 ? start + chunkSize - 1 : fileSize
}
/**
* 使TaskQueue并发上传分片APP端
*
* @param uploadData uploadIdsaveFilePathfileSize
* @param chunkSize
* @param concurrentLimit
* @param localFilePath APP端沙盒中的本地文件路径
* @param progressInfo completedChunksuploadProgresschunkCount等属性
*
* @returns Promise<PartETag[]> ETag信息数组
*
* @throws {Error}
*
*/
async uploadChunksWithTaskQueue(
uploadData: UploadData,
chunkSize: number,
concurrentLimit: number,
localFilePath: string,
progressInfo: ProgressInfo
): Promise<PartETag[]> {
const { chunkCount, fileSize, uploadId, saveFilePath } = uploadData;
const taskQueue = new TaskQueue(concurrentLimit);
const partETags: PartETag[] = [];
// 创建所有分片上传任务
const uploadPromises: Promise<PartETag>[] = [];
for (let i = 0; i < chunkCount; i++) {
const task = {
index: i + 1,
start: i * chunkSize,
end: this.getSliceEnd(i * chunkSize, chunkSize, fileSize, i, chunkCount),
};
const promise = taskQueue.add(async () => {
const chunk = await this.readAppFileChunk(localFilePath, task.start, task.end - task.start);
const response = await this.uploadAppChunk(uploadId, saveFilePath, task.index, chunk) as any;
if (!response.data || !response.data.etag) throw new Error('分片上传失败');
// 更新进度
this.updateUploadProgress(progressInfo);
return {
partNumber: task.index,
ETag: response.data.etag,
};
});
uploadPromises.push(promise);
}
// 等待所有任务完成
try {
const results = await Promise.all(uploadPromises);
// 收集所有 partETags
results.forEach(partETag => {
if (partETag) partETags.push(partETag);
});
// 按 partNumber 排序确保顺序正确
partETags.sort((a, b) => a.partNumber - b.partNumber);
return partETags;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '分片上传失败';
throw new Error(`分片上传失败: ${errorMessage}`);
}
}
/**
* APP端分片上传单个分片
* @param uploadId ID
* @param filePath
* @param chunkIndex
* @param chunk ArrayBuffer或字符串
* @returns Promise<any>
*/
async uploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
try {
const response = await this.startUploadAppChunk(uploadId, filePath, chunkIndex, chunk)
return response
} catch (error) {
throw new Error('分片上传失败')
}
}
/**
* APP端分片上传
* @param uploadId ID
* @param filePath
* @param chunkIndex
* @param chunk ArrayBuffer或字符串
* @returns Promise Promise
*/
startUploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
return new Promise(async (resolve, reject) => {
try {
// 1. 准备临时文件信息
const tempFileName = `temp_chunk/chunk_${uploadId}_${chunkIndex}.bin`
const tempDirPath = plus.io.PRIVATE_DOC
// 2. 创建并写入临时文件
const tempFilePath = await this.createAndWriteTempFile(
tempDirPath,
tempFileName,
chunk
)
//设置文件的全路径
let formattedPath = tempFilePath
if (tempFilePath && !tempFilePath.startsWith("file://")) {
formattedPath = `file://${tempFilePath}`
}
// 3. 上传文件
const result = await uploadChunk(uploadId, filePath, chunkIndex, formattedPath)
// 4. 删除临时文件
await this.deleteTempFile(tempDirPath, tempFileName)
resolve(result)
} catch (error) {
reject(error)
}
})
}
/**
*
* @param filePath
* @returns Promise<boolean>
*/
deleteLocalFile(filePath: string): Promise<boolean> {
return new Promise((resolve) => {
if (!filePath) {
resolve(false);
return;
}
plus.io.resolveLocalFileSystemURL(
filePath,
(entry) => {
entry.remove(
() => { resolve(true); },
() => { resolve(false); }
);
},
() => { resolve(false); }
);
});
};
/**
*
* @param dirPath plus.io.PRIVATE_DOC等
* @param fileName
* @param data ArrayBuffer或字符串
* @returns Promise<string>
*/
createAndWriteTempFile(dirPath: number, fileName: String, data: ArrayBuffer | string): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
dirPath,
(dirEntry: any) => {
dirEntry.root.getFile(
fileName,
{ create: true, exclusive: false },
(fileEntry: any) => {
fileEntry.createWriter(
(writer: any) => {
const filePath = fileEntry.fullPath
writer.onwrite = function () { resolve(filePath) }
writer.onerror = function (e: any) { reject(e) }
try {
if (data) writer.writeAsBinary(data)
} catch (e) { reject(e) }
},
(err: any) => reject(err)
)
},
(err: any) => reject(err)
)
},
(err) => { reject(err) }
)
})
}
/**
*
* @param dirPath plus.io.PRIVATE_DOC等
* @param fileName
* @returns Promise<boolean>
*/
deleteTempFile(dirPath: number, fileName: string): Promise<boolean> {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
dirPath,
(dirEntry) => {
if (!dirEntry || !dirEntry.root) {
reject(new Error('Directory entry or root is undefined'));
return;
}
dirEntry.root.getFile(
fileName,
{ create: false },
(fileEntry) => {
fileEntry.remove(
() => { resolve(true); },
() => { resolve(true); }
);
},
() => resolve(true)
);
},
() => resolve(true)
);
});
}
/**
* APP端文件分片的数据
* @param filePath
* @param start
* @param length
* @returns Promise<string> Base64编码的分片数据
*/
readAppFileChunk(filePath: string, start: number, length: number): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry: any) => {
entry.file(
(file: any) => {
const reader = new plus.io.FileReader();
try {
const slice = file.slice(start, start + length);
reader.readAsDataURL(slice);
} catch (sliceError) {
reject(sliceError);
}
reader.onloadend = (e: any) => {
if (e.target.readyState == 2) {
try {
const base64 = e.target.result.split(",")[1];
resolve(base64);
} catch (err) {
reject(err);
}
}
};
reader.onerror = (err) => { reject(err); };
},
(error: any) => { reject(error); }
);
},
(error) => { reject(error); }
);
});
};
/**
* 使TaskQueue并发上传分片
*
* @param uploadData
* @param concurrentLimit
* @param progressInfo
*
* @returns Promise<PartETag[]> ETag信息数组partNumber排序
*
* @throws {Error}
*/
async _uploadChunks(uploadData: UploadData, concurrentLimit: number, progressInfo: ProgressInfo) {
try {
const { uploadId, saveFilePath, fileSize, chunkCount, filePath } = uploadData;
const fileManager = uni.getFileSystemManager();
const partETags: PartETag[] = [];
const taskQueue = new TaskQueue(concurrentLimit);
// 创建所有分片上传任务
const uploadTasks = [];
for (let i = 0; i < chunkCount; i++) {
const task = taskQueue.add(async () => {
return await this._uploadSingleChunk(
fileManager,
uploadId,
saveFilePath,
filePath,
i,
fileSize,
progressInfo
);
});
uploadTasks.push(task);
}
// 等待所有任务完成
const results = await Promise.all(uploadTasks);
// 收集所有 partETags
results.forEach(partETag => {
if (partETag) partETags.push(partETag);
});
// 按 partNumber 排序确保顺序正确
partETags.sort((a, b) => a.partNumber - b.partNumber);
return partETags;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '上传分片失败';
throw new Error(errorMessage);
}
}
/**
*
* @param fileManager
* @param uploadId ID
* @param saveFilePath
* @param filePath
* @param chunkIndex 0
* @param fileSize
* @param progressInfo
* @returns Promise<PartETag> ETag信息
*/
private async _uploadSingleChunk(
fileManager: UniApp.FileSystemManager,
uploadId: string,
saveFilePath: string,
filePath: string,
chunkIndex: number,
fileSize: number,
progressInfo: ProgressInfo
): Promise<PartETag> {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, fileSize);
const tempChunkPath = `${wx.env.USER_DATA_PATH}/chunk_${chunkIndex}_${Date.now()}.tmp`;
try {
// 1. 处理分片数据
await this._processChunk(fileManager, filePath, tempChunkPath, start, end - start);
// 2. 上传分片
const partNumber = chunkIndex + 1;
const response = await uploadChunk(uploadId, saveFilePath, partNumber, tempChunkPath);
if (!response.data?.etag) {
throw new Error(`分片 ${partNumber} 上传失败,无效响应`);
}
// 3. 更新进度
this.updateUploadProgress(progressInfo);
return {
partNumber,
ETag: response.data.etag,
};
} finally {
// 4. 清理临时文件(无论成功失败都要清理)
this._cleanupTempFile(fileManager, tempChunkPath);
}
}
/**
*
*
* @param fileManager - uni-app文件系统管理器实例
* @param filePath -
* @param tempChunkPath -
* @param start -
* @param length -
* @returns Promise<void> - Promise
* @throws {Error}
*/
async _processChunk(fileManager: UniApp.FileSystemManager, filePath: string, tempChunkPath: string, start: number, length: number) {
const readRes = await new Promise<ArrayBuffer | string>((resolve, reject) => {
fileManager.readFile({
filePath: filePath,
position: start,
length: length,
success: (res: any) => {
resolve(res.data as ArrayBuffer | string)
},
fail: (err) => {
reject(err)
},
});
});
// 写入临时文件
await new Promise((resolve, reject) => {
fileManager.writeFile({
filePath: tempChunkPath,
data: readRes,
success: () => {
resolve(true)
},
fail: (err) => {
reject(err)
},
});
});
}
/**
*
*
* @param fileManager - uni-app文件系统管理器实例
* @param tempChunkPath -
* @throws {Error}
*/
_cleanupTempFile(fileManager: UniApp.FileSystemManager, tempChunkPath: string) {
try {
fileManager.unlinkSync(tempChunkPath);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '未知错误';
throw new Error(`删除临时文件失败: ${tempChunkPath}, 错误: ${errorMessage}`);
}
}
/**
*
*
* @param progressInfo completedChunksuploadProgresschunkCount等属性
*/
private updateUploadProgress(progressInfo: ProgressInfo): void {
// 增加已完成分片数
progressInfo.completedChunks++;
// 计算当前进度百分比
const percent = Math.floor((progressInfo.completedChunks / progressInfo.chunkCount) * 100);
// 计算显示进度按间隔更新避免过于频繁的UI更新
const displayPercent = Math.floor(percent / ChunkUpload.PROGRESS_UPDATE_INTERVAL) * ChunkUpload.PROGRESS_UPDATE_INTERVAL;
// 当显示进度发生变化或上传完成时更新UI
if (displayPercent !== progressInfo.uploadProgress || progressInfo.completedChunks === progressInfo.chunkCount) {
modal.closeLoading();
const displayPercentForUI = progressInfo.completedChunks === progressInfo.chunkCount ? 100 : displayPercent;
modal.loading(`上传中 ${displayPercentForUI}% (请勿离开此页面)`);
progressInfo.uploadProgress = displayPercent;
}
}
}
export const chunkUpload = new ChunkUpload();

View File

@ -1,479 +0,0 @@
import modal from '@/plugins/modal'
import { initChunkUpload, uploadChunk, completeChunkUpload } from '@/api/system/chunkUpload'
import { UploadOptions, PartETag, ChunkTask } from '@/types/upload'
/**
* APP端分片上传工具类
*
*/
class AppChunkUploader {
/**
*
*/
private chunkSize: number;
/**
*
*/
private concurrentLimit: number;
/**
* -
* 15MB2
*/
constructor() {
this.chunkSize = 15 * 1024 * 1024; // 默认分片大小15MB
this.concurrentLimit = 2; // 并发上传的分片数量
}
/**
*
* @param options
* @param options.file path和size属性
* @param options.onSuccess
* @param options.onError
* @returns Promise<boolean>
*/
async upload(options: UploadOptions): Promise<boolean> {
const { file, onSuccess, onError } = options
try {
const actualFilePath = file.path
const actualFileSize = file.size
if (!actualFilePath) throw new Error('文件路径不存在')
if (!actualFileSize) throw new Error('文件大小不存在')
//初始化文件状态
let localFilePath = actualFilePath
const actualFileName = this.getFileName(localFilePath)
modal.loading("准备上传...")
// 1.计算分片数量
const chunkSize = this.chunkSize
const chunkCount = Math.ceil(actualFileSize / chunkSize)
//2.初始化分片上传
const initResult = await initChunkUpload(actualFileName, actualFileSize)
if (initResult.code !== 200) throw new Error("初始化上传失败")
const { uploadId, filePath: serverFilePath } = initResult.data
const partETags: PartETag[] = [];
//3.将文件移动到应用 沙盒 目录
localFilePath = await this.copyFileToSandbox(localFilePath)
//4.上传所有分片
modal.closeLoading()
modal.loading("上传中...")
//5.进度信息对象
const progressInfo = {
completedChunks: 0,
uploadProgress: 0,
chunkCount
}
// 创建分片任务队列
const chunkTasks: ChunkTask[] = []
for (let i = 0; i < chunkCount; i++) {
chunkTasks.push({
index: i,
start: i * chunkSize,
end: this.getSliceEnd(i * chunkSize, chunkSize, actualFileSize, i, chunkCount),
})
}
//并发上传数据
await this.uploadChunksInBatches(
chunkTasks,
this.concurrentLimit,
uploadId,
serverFilePath,
localFilePath,
partETags,
progressInfo
)
//合并分片
modal.closeLoading();
modal.loading("正在合并分片...")
//完成分片上传
await completeChunkUpload(
uploadId, serverFilePath, actualFileSize, actualFileName, partETags
)
//将临时文件删除,防止占用空间
await this.deleteLocalFile(localFilePath)
modal.closeLoading()
// 执行成功回调
onSuccess?.({ success: true })
return true
} catch (error) {
modal.closeLoading()
const errorMessage = error instanceof Error ? error.message : `上传失败`
onError?.(errorMessage)
return false
}
}
/**
* end位置
* @param start
* @param chunkSize
* @param fileSize
* @param index
* @param totalChunks
* @returns number
*/
getSliceEnd(start: number, chunkSize: number, fileSize: number, index: number, totalChunks: number) {
return index < totalChunks - 1 ? start + chunkSize - 1 : fileSize
}
/**
*
* @param tasks
* @param batchSize
* @param uploadId ID
* @param filePath
* @param localFilePath
* @param partETags ETag数组
* @param progressInfo
* @returns Promise<any[]>
*/
async uploadChunksInBatches(tasks: ChunkTask[], batchSize: number, uploadId: string, filePath: string, localFilePath: string, partETags: PartETag[], progressInfo: any): Promise<any[]> {
const results = []
for (let i = 0; i < tasks.length; i += batchSize) {
const batch = tasks.slice(i, i + batchSize)
try {
const batchResults = await Promise.all(
batch.map((task) => this.uploadChunkConcurrently(task, uploadId, filePath, localFilePath, partETags, progressInfo))
)
results.push(...batchResults)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '并发上传失败'
throw new Error(errorMessage)
}
}
return results
}
/**
* APP端分片上传单个分片
* @param uploadId ID
* @param filePath
* @param chunkIndex
* @param chunk ArrayBuffer或字符串
* @returns Promise<any>
*/
async uploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
try {
const response = await this.startUploadAppChunk(uploadId, filePath, chunkIndex, chunk)
return response
} catch (error) {
throw new Error('分片上传失败')
}
}
/**
* APP端分片上传
* @param uploadId ID
* @param filePath
* @param chunkIndex
* @param chunk ArrayBuffer或字符串
* @returns Promise Promise
*/
startUploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
return new Promise(async (resolve, reject) => {
try {
// 1. 准备临时文件信息
const tempFileName = `temp_chunk/chunk_${uploadId}_${chunkIndex}.bin`
const tempDirPath = plus.io.PRIVATE_DOC
// 2. 创建并写入临时文件
const tempFilePath = await this.createAndWriteTempFile(
tempDirPath,
tempFileName,
chunk
)
//设置文件的全路径
let formattedPath = tempFilePath
if (tempFilePath && !tempFilePath.startsWith("file://")) {
formattedPath = `file://${tempFilePath}`
}
// 3. 上传文件
const result = await uploadChunk(uploadId, filePath, chunkIndex, formattedPath)
// 4. 删除临时文件
await this.deleteTempFile(tempDirPath, tempFileName)
resolve(result)
} catch (error) {
reject(error)
}
})
}
/**
*
* @param chunkTask indexstartend等信息
* @param uploadId ID
* @param filePath
* @param localFilePath
* @param partETags ETag数组
* @param progressInfo completedChunksuploadProgresschunkCount等
* @returns Promise<any>
*/
async uploadChunkConcurrently(chunkTask: any, uploadId: string, filePath: string, localFilePath: string, partETags: PartETag[], progressInfo: any) {
const { index, start, end } = chunkTask
const { chunkCount } = progressInfo
const chunk = await this.readAppFileChunk(localFilePath, start, end - start)
const response = await this.uploadAppChunk(uploadId, filePath, index, chunk) as any
if (response.data && response.data.etag) {
partETags.push({
partNumber: index + 1,
ETag: response.data.etag,
});
}
progressInfo.completedChunks++
const percent = Math.floor((progressInfo.completedChunks / chunkCount) * 100)
const displayPercent = Math.floor(percent / 10) * 10 // 每10%更新一次
if (displayPercent !== progressInfo.uploadProgress || progressInfo.completedChunks === chunkCount) {
modal.closeLoading()
modal.loading(`上传中 ${percent}% (请勿离开此页面)`)
progressInfo.uploadProgress = displayPercent
}
return response
}
/**
*
* @param filePath
* @returns string
*/
getFileName(filePath: string): string {
if (!filePath) {
return "";
}
// 查找最后一个斜杠位置
const slashIndex = filePath.lastIndexOf("/");
if (slashIndex === -1) {
return filePath; // 没有斜杠,整个字符串可能就是文件名
}
// 从最后一个斜杠后面提取文件名
return filePath.substring(slashIndex + 1);
};
/**
*
* @param srcUrl URL路径
* @returns Promise<string>
*/
copyFileToSandbox(srcUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
const newName = `file_${Date.now()}.${this.getFileExtension(srcUrl)}`;
plus.io.requestFileSystem(
plus.io.PRIVATE_DOC,
(dstEntry) => {
plus.io.resolveLocalFileSystemURL(
srcUrl,
(srcEntry) => {
srcEntry.copyTo(
dstEntry.root,
newName,
(entry) => {
if (entry.fullPath) {
resolve(entry.fullPath);
} else {
reject(new Error('File path is undefined'));
}
},
(e) => reject(e)
);
},
(e) => reject(e)
);
},
(e) => reject(e)
);
});
};
/**
*
* @param filePath
* @returns string
*/
getFileExtension(filePath: string): string {
if (!filePath) {
return "";
}
// 查找最后一个点号位置
const dotIndex = filePath.lastIndexOf(".");
if (dotIndex === -1) {
return ""; // 没有找到扩展名
}
// 从点号后面提取扩展名
return filePath.substring(dotIndex + 1).toLowerCase();
};
/**
*
* @param filePath
* @returns Promise<boolean>
*/
deleteLocalFile(filePath: string): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!filePath) {
resolve(false);
return;
}
plus.io.resolveLocalFileSystemURL(
filePath,
(entry) => {
entry.remove(
() => { resolve(true); },
(error) => { resolve(false); }
);
},
(error) => { resolve(false); }
);
});
};
/**
*
* @param dirPath plus.io.PRIVATE_DOC等
* @param fileName
* @param data ArrayBuffer或字符串
* @returns Promise<string>
*/
createAndWriteTempFile(dirPath: number, fileName: String, data: ArrayBuffer | string): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
dirPath,
(dirEntry: any) => {
dirEntry.root.getFile(
fileName,
{ create: true, exclusive: false },
(fileEntry: any) => {
fileEntry.createWriter(
(writer: any) => {
const filePath = fileEntry.fullPath
writer.onwrite = function () { resolve(filePath) }
writer.onerror = function (e: any) { reject(e) }
try {
if (data) writer.writeAsBinary(data)
} catch (e) { reject(e) }
},
(err: any) => reject(err)
)
},
(err: any) => reject(err)
)
},
(err) => { reject(err) }
)
})
}
/**
*
* @param dirPath plus.io.PRIVATE_DOC等
* @param fileName
* @returns Promise<boolean>
*/
deleteTempFile(dirPath: number, fileName: string): Promise<boolean> {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
dirPath,
(dirEntry) => {
if (!dirEntry || !dirEntry.root) {
reject(new Error('Directory entry or root is undefined'));
return;
}
dirEntry.root.getFile(
fileName,
{ create: false },
(fileEntry) => {
fileEntry.remove(
() => { resolve(true); },
(err) => { resolve(true); }
);
},
() => resolve(true)
);
},
() => resolve(true)
);
});
}
/**
* APP端文件分片的数据
* @param filePath
* @param start
* @param length
* @returns Promise<string> Base64编码的分片数据
*/
readAppFileChunk(filePath: string, start: number, length: number): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry: any) => {
entry.file(
(file: any) => {
const reader = new plus.io.FileReader();
try {
const slice = file.slice(start, start + length);
reader.readAsDataURL(slice);
} catch (sliceError) {
reject(sliceError);
}
reader.onloadend = (e: any) => {
if (e.target.readyState == 2) {
try {
const base64 = e.target.result.split(",")[1];
resolve(base64);
} catch (err) {
reject(err);
}
}
};
reader.onerror = (err) => { reject(err); };
},
(error: any) => { reject(error); }
);
},
(error) => { reject(error); }
);
});
};
}
export default new AppChunkUploader()
export { AppChunkUploader }

View File

@ -1,282 +0,0 @@
import { initChunkUpload, uploadChunk, completeChunkUpload } from '@/api/system/chunkUpload'
import modal from "@/plugins/modal";
import { UploadOptions, File, UploadData, PartETag } from "@/types/upload";
// 声明微信小程序全局对象
declare const wx: any;
/**
*
*
*
*
*/
export class WxChunkUploader {
/** 分片大小,单位字节 */
private chunkSize: number;
/** 上次显示的上传进度百分比 */
private lastDisplayPercent: number;
/**
*
* @param config
* @param config.chunkSize 15MB
*/
constructor() {
this.chunkSize = 15 * 1024 * 1024; //默认分片大小,为 15MB
this.lastDisplayPercent = 0; //初始化上次显示的上传进度百分比为0
}
/**
*
*
* @param options -
* @param options.file -
* @param options.onSuccess -
* @param options.onError -
* @returns Promise<boolean> -
*/
async upload(options: UploadOptions): Promise<boolean> {
const { file, onSuccess, onError } = options;
try {
// 1. 校验数据
this._validateParams(file);
// 2. 准备上传数据
modal.loading("准备上传...");
const uploadData = await this._prepareUploadData(file);
// 3. 执行分片上传
modal.closeLoading();
modal.loading("上传中...");
const partETags = await this._uploadChunks(uploadData);
// 4. 合并文件
modal.closeLoading();
modal.loading("合并文件中...");
//模仿上传的时间,可删除
// await new Promise(resolve => setTimeout(resolve, 5000));
await this._completeUpload(uploadData, partETags);
setTimeout(() => {
modal.closeLoading();
onSuccess?.({ success: true });
}, 1000);
return true;
} catch (error) {
modal.closeLoading();
const errorMessage = error instanceof Error ? error.message : '上传失败';
onError?.(errorMessage);
return false;
}
}
/**
*
*
* @param file -
* @throws {Error}
* @throws {Error}
*/
_validateParams(file: File) {
if (!file.path) throw new Error("文件路径不存在");
if (!file.size) throw new Error("文件大小不存在");
}
/**
*
*
* @param file -
* @returns Promise<UploadData> - ID
* @throws {Error}
*/
async _prepareUploadData(file: File) {
try {
const fileSize = file.size;
const filePath = file.path;
const uploadFileName = `weixin_${Date.now()}.${this.getFileExtension(filePath)}`;
const chunkCount = Math.ceil(fileSize / this.chunkSize);
// 初始化分片上传
const initResult = await initChunkUpload(uploadFileName, fileSize);
if (initResult.code !== 200) throw new Error("初始化上传失败");
return {
uploadId: initResult.data.uploadId,
saveFilePath: initResult.data.filePath,
uploadFileName: uploadFileName,
fileSize: fileSize,
chunkCount: chunkCount,
filePath: filePath,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '准备上传数据失败';
throw new Error(`${errorMessage}`);
}
}
/**
*
*
* @param uploadData - ID
* @returns Promise<PartETag[]> - ETag信息数组
* @throws {Error}
*/
async _uploadChunks(uploadData: UploadData) {
try {
const { uploadId, saveFilePath, fileSize, chunkCount, filePath } = uploadData;
const fileManager = uni.getFileSystemManager();
const partETags: PartETag[] = [];
for (let i = 0; i < chunkCount; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, fileSize);
const tempChunkPath = `${wx.env.USER_DATA_PATH}/chunk_${i}.tmp`;
// 读取并写入分片
await this._processChunk(fileManager, filePath, tempChunkPath, start, end - start);
// 上传分片
const response = await uploadChunk(uploadId, saveFilePath, i, tempChunkPath);
if (response.data?.etag) {
partETags.push({
partNumber: i + 1,
ETag: response.data.etag,
});
}
// 清理临时文件
this._cleanupTempFile(fileManager, tempChunkPath);
// 更新进度 - 确保完全执行完毕
this._updateProgress(i, chunkCount);
}
return partETags;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '上传分片失败';
throw new Error(errorMessage);
}
}
/**
*
*
* @param fileManager - uni-app文件系统管理器实例
* @param filePath -
* @param tempChunkPath -
* @param start -
* @param length -
* @returns Promise<void> - Promise
* @throws {Error}
*/
async _processChunk(fileManager: UniApp.FileSystemManager, filePath: string, tempChunkPath: string, start: number, length: number) {
const readRes = await new Promise<ArrayBuffer | string>((resolve, reject) => {
fileManager.readFile({
filePath: filePath,
position: start,
length: length,
success: (res: any) => resolve(res.data as ArrayBuffer | string),
fail: reject,
});
});
// 写入临时文件
await new Promise((resolve, reject) => {
fileManager.writeFile({
filePath: tempChunkPath,
data: readRes,
success: resolve,
fail: reject,
});
});
}
/**
*
*
* @param fileManager - uni-app文件系统管理器实例
* @param tempChunkPath -
* @throws {Error}
*/
_cleanupTempFile(fileManager: UniApp.FileSystemManager, tempChunkPath: string) {
try {
fileManager.unlinkSync(tempChunkPath);
} catch (e) {
throw new Error("删除临时文件错误");
}
}
/**
*
*
* @param currentIndex - 0
* @param totalCount -
*/
_updateProgress(currentIndex: number, totalCount: number) {
const percent = Math.floor(((currentIndex + 1) / totalCount) * 100);
const displayPercent = Math.floor(percent / 20) * 20;
if (displayPercent !== this.lastDisplayPercent || currentIndex === totalCount - 1) {
modal.closeLoading();
modal.loading(`上传中${displayPercent}%`);
this.lastDisplayPercent = displayPercent;
}
}
/**
*
*
* @param uploadData - ID等关键信息
* @param partETags - ETag信息数组
* @returns Promise<void> - Promise
* @throws {Error}
*/
async _completeUpload(uploadData: UploadData, partETags: PartETag[]) {
try {
const { uploadId, saveFilePath, fileSize, uploadFileName } = uploadData;
await completeChunkUpload(uploadId, saveFilePath, fileSize, uploadFileName, partETags);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '上传失败';
throw new Error(errorMessage);
}
}
/**
*
*
* @param filePath -
* @returns string - 'jpg', 'mp4', 'pdf'
* @example
* getFileExtension('/path/to/video.mp4') // 返回 'mp4'
* getFileExtension('/path/to/image.JPG') // 返回 'jpg'
* getFileExtension('/path/to/file') // 返回 ''
*/
getFileExtension(filePath: string): string {
if (!filePath) {
return "";
}
// 查找最后一个点号位置
const dotIndex = filePath.lastIndexOf(".");
if (dotIndex === -1) {
return ""; // 没有找到扩展名
}
// 从点号后面提取扩展名
return filePath.substring(dotIndex + 1).toLowerCase();
};
}
export const wxChunkUploader = new WxChunkUploader();

246
src/utils/TaskQueue.ts Normal file
View File

@ -0,0 +1,246 @@
import { nextTick } from "vue";
export type TaskFn<T = unknown> = (signal?: AbortSignal) => Promise<T>;
export type ErrorMode = 'continue' | 'abort';
export interface TaskQueueOptions {
concurrency?: number; // 并发数,默认 4
errorMode?: ErrorMode; // 出错策略:继续 or 中止
autoStart?: boolean; // add 时是否自动启动,默认 true
}
export interface AddTaskOptions {
priority?: number; // 优先级,数值越大越先执行,默认 0
signal?: AbortSignal; // 任务级取消信号
timeout?: number; // 单任务超时ms
id?: string; // 任务标识,便于调试
}
class AbortError extends Error {
name = 'AbortError';
constructor(message = 'Aborted') {
super(message);
}
}
interface QueueItem<T = unknown> {
id?: string;
priority: number;
fn: TaskFn<T>;
resolve: (v: T) => void;
reject: (e: unknown) => void;
signal?: AbortSignal;
timeout?: number;
addedAt: number;
}
export class TaskQueue {
/** 最大并发数 */
private concurrency!: number; // 在构造函数中设置
/** 当前正在执行的任务数 */
private runningCount = 0;
/** 待执行任务队列(使用 unknown 以避免泛型入队时的类型不兼容) */
private taskQueue: QueueItem<unknown>[] = [];
/** 出错策略 */
private errorMode: ErrorMode = 'abort';
/** 自动启动 */
private autoStart: boolean = true;
/** 是否暂停调度 */
private paused = false;
/** 是否已中止(如因错误或手动 abort */
private aborted = false;
private abortReason?: string;
/** 错误收集errorMode=continue 时有用) */
private errors: unknown[] = [];
/** empty/idle 等待者 */
private emptyWaiters: Array<() => void> = [];
private idleWaiters: Array<() => void> = [];
constructor(options: number | TaskQueueOptions = 4) {
if (typeof options === 'number') {
this.concurrency = Math.max(1, options);
this.errorMode = 'abort';
this.autoStart = true;
} else {
this.concurrency = Math.max(1, options.concurrency ?? 4);
this.errorMode = options.errorMode ?? 'abort';
this.autoStart = options.autoStart ?? true;
}
}
// 状态只读属性
get size() { return this.taskQueue.length; }
get pending() { return this.runningCount; }
get isPaused() { return this.paused; }
get isAborted() { return this.aborted; }
get collectedErrors() { return this.errors.slice(); }
/**
* Promise
*/
public add<T>(fn: TaskFn<T>, opts: AddTaskOptions = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
if (this.aborted) {
reject(new AbortError(this.abortReason || 'Queue aborted'));
return;
}
const item: QueueItem<T> = {
id: opts.id,
priority: opts.priority ?? 0,
fn,
resolve,
reject,
signal: opts.signal,
timeout: opts.timeout,
addedAt: Date.now(),
};
this.enqueue(item as unknown as QueueItem<unknown>);
if (this.autoStart && !this.paused) this.runNext();
});
}
/** 批量添加 */
public addAll<T>(fns: Array<TaskFn<T>>, opts?: AddTaskOptions): Promise<T>[] {
return fns.map((fn) => this.add(fn, opts));
}
/** 等待队列空(无排队任务) */
public onEmpty(): Promise<void> {
if (this.size === 0) return Promise.resolve();
return new Promise((resolve) => this.emptyWaiters.push(resolve));
}
/** 等待完全空闲(无排队、无运行中) => 等待所有任务完成 */
public waitAll(): Promise<void> {
if (this.size === 0 && this.runningCount === 0) return Promise.resolve();
return new Promise((resolve) => this.idleWaiters.push(resolve));
}
/** 暂停调度 */
public pause() { this.paused = true; }
/** 恢复调度 */
public resume() {
if (!this.paused) return;
this.paused = false;
this.runNext();
}
/** 手动中止:清空剩余队列并拒绝它们 */
public abort(reason = 'Aborted by user') {
if (this.aborted) return;
this.aborted = true;
this.abortReason = reason;
this.clear(new AbortError(reason));
}
/** 清空待执行任务(不影响已在运行中的任务) */
public clear(err: unknown = new AbortError('Cleared')) {
const pending = this.taskQueue.splice(0, this.taskQueue.length);
for (const item of pending) item.reject(err);
this.notifyEmptyIfNeeded();
this.notifyIdleIfNeeded();
}
/** 动态调整并发度 */
public setConcurrency(n: number) {
this.concurrency = Math.max(1, n | 0);
this.runNext();
}
/** 修改错误策略 */
public setErrorMode(mode: ErrorMode) { this.errorMode = mode; }
/** 入队(按优先级降序,稳定插入) */
private enqueue(item: QueueItem<unknown>) {
const idx = this.taskQueue.findIndex((q) => q.priority < item.priority);
if (idx === -1) this.taskQueue.push(item);
else this.taskQueue.splice(idx, 0, item);
}
/** 调度下一批任务(在下一帧启动) */
private runNext() {
if (this.paused || this.aborted) return;
while (this.runningCount < this.concurrency && this.taskQueue.length > 0) {
const item = this.taskQueue.shift()!;
if (this.taskQueue.length === 0) this.notifyEmptyIfNeeded();
this.runningCount++;
nextTick(() => {
this.execute(item)
.catch(() => { /* 错误在 execute 中处理 */ })
.finally(() => {
this.runningCount--;
if (!this.aborted) this.runNext();
this.notifyIdleIfNeeded();
});
});
}
}
/** 实际执行(处理 signal、timeout、错误策略 */
private async execute(item: QueueItem<unknown>): Promise<void> {
if (item.signal?.aborted) {
item.reject(new AbortError('Task aborted before start'));
return;
}
let timer: number | undefined;
const onAbort = () => {
if (timer) clearTimeout(timer);
item.reject(new AbortError('Task aborted'));
};
if (item.signal) item.signal.addEventListener('abort', onAbort, { once: true });
if (item.timeout && item.timeout > 0) {
timer = window.setTimeout(() => {
item.reject(new Error(`Task timeout after ${item.timeout}ms`));
}, item.timeout);
}
try {
const result = await item.fn(item.signal);
if (timer) clearTimeout(timer);
(item.resolve as (v: unknown) => void)(result);
} catch (err) {
if (timer) clearTimeout(timer);
this.errors.push(err);
item.reject(err);
if (this.errorMode === 'abort' && !this.aborted) {
this.aborted = true;
this.abortReason = 'Aborted due to previous error';
this.clear(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (item.signal) item.signal.removeEventListener('abort', onAbort);
}
}
private notifyEmptyIfNeeded() {
if (this.taskQueue.length === 0 && this.emptyWaiters.length) {
const callbacks = this.emptyWaiters.splice(0, this.emptyWaiters.length);
for (const cb of callbacks) cb();
}
}
private notifyIdleIfNeeded() {
if (this.taskQueue.length === 0 && this.runningCount === 0 && this.idleWaiters.length) {
const callbacks = this.idleWaiters.splice(0, this.idleWaiters.length);
for (const cb of callbacks) cb();
}
}
}
export default TaskQueue;