diff --git a/package.json b/package.json index 17f4887..b9059f9 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,11 @@ "@dcloudio/uni-cli-shared": "3.0.0-4060420250429001", "@dcloudio/uni-stacktracey": "3.0.0-4060420250429001", "@dcloudio/vite-plugin-uni": "3.0.0-4060420250429001", + "@types/html5plus": "^1.0.5", "@vue/runtime-core": "^3.5.12", "@vue/tsconfig": "^0.5.1", "less": "^4.2.0", + "miniprogram-api-typings": "^4.1.0", "sass": "1.78.0", "sass-loader": "^16.0.1", "typescript": "^5.6.2", diff --git a/src/api/system/chunkUpload/index.js b/src/api/system/chunkUpload/index.js new file mode 100644 index 0000000..e59ab6f --- /dev/null +++ b/src/api/system/chunkUpload/index.js @@ -0,0 +1,85 @@ +import request from '@/utils/request' +import config from "@/config"; +import { getToken } from "@/utils/auth"; + + +/**初始化上传 */ +export function initChunkUpload(fileName, fileSize) { + return request({ + url: '/file/initUpload', + method: 'post', + params: { + fileName, + fileSize + } + }) +} + + +/**上传分片视频 */ +export function uploadChunk(uploadId, filePath, chunkIndex, formattedPath) { + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: `${config.baseUrl}/file/uploadChunk`, + filePath: formattedPath, + name: "chunk", + timeout: 60000, // 增加超时时间到60秒 + header: { + Authorization: `Bearer ${getToken()}`, + }, + formData: { + uploadId: uploadId, + filePath: filePath, + partNumber: chunkIndex, + }, + success: (res) => { + try { + const resultData = JSON.parse(res.data); + resolve(resultData); + } catch (error) { + console.error("解析上传结果失败:", error); + reject(error); + } + }, + fail: (err) => { + console.error(`分片${chunkIndex}上传请求失败:`, err); + reject(err); + }, + }); + }); +} + + +/**完成分片上传 */ +export function completeChunkUpload(uploadId, filePath, fileSize, fileName, partETags) { + return request({ + url: '/file/completeUpload', + method: 'post', + params: { + uploadId, + filePath, + fileSize, + fileName, + }, + data: partETags + }) +} + + + + + + + + + + + + + + + + + + + diff --git a/src/components/geek-xd/components/geek-confirm-dialog/geek-confirm-dialog.vue b/src/components/geek-xd/components/geek-confirm-dialog/geek-confirm-dialog.vue new file mode 100644 index 0000000..aafb105 --- /dev/null +++ b/src/components/geek-xd/components/geek-confirm-dialog/geek-confirm-dialog.vue @@ -0,0 +1,123 @@ + + + + + \ No newline at end of file diff --git a/src/components/geek-xd/components/geek-uploadbox/geek-uploadbox.vue b/src/components/geek-xd/components/geek-uploadbox/geek-uploadbox.vue new file mode 100644 index 0000000..6d4a58b --- /dev/null +++ b/src/components/geek-xd/components/geek-uploadbox/geek-uploadbox.vue @@ -0,0 +1,270 @@ + + + + + \ No newline at end of file diff --git a/src/pages.json b/src/pages.json index 0e977d5..8248469 100644 --- a/src/pages.json +++ b/src/pages.json @@ -236,6 +236,9 @@ }, { "path": "code/index" + }, + { + "path": "upload/index" } ] } diff --git a/src/pages/template.config.js b/src/pages/template.config.js index 1e606a4..2e582d6 100644 --- a/src/pages/template.config.js +++ b/src/pages/template.config.js @@ -14,7 +14,13 @@ export default [ icon: 'wxCenter', title: '二维码', title_en: 'index', - } + }, + { + path: '/pages_geek/pages/upload/index', + icon: 'wxCenter', + title: '分片上传', + title_en: 'index', + }, ] }, { diff --git a/src/pages_geek/pages/upload/index.vue b/src/pages_geek/pages/upload/index.vue new file mode 100644 index 0000000..e3f4a30 --- /dev/null +++ b/src/pages_geek/pages/upload/index.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/types/upload.ts b/src/types/upload.ts new file mode 100644 index 0000000..b9d3a47 --- /dev/null +++ b/src/types/upload.ts @@ -0,0 +1,64 @@ + + +export interface UploadOptions { + /**文件 */ + file: File + /**成功回调 */ + onSuccess?: (result: any) => void; + /**失败回调 */ + onError?: (error: any) => void; + /**上传配置 */ + options?: UploadConfig; +} + +export interface File { + /**文件路径 */ + path: string; + /**文件大小 */ + size: number; +} + +export interface UploadConfig { + /**分片大小,单位字节 */ + chunkSize?: number; + /**并发上传限制 */ + concurrentLimit?: number; +} + +export interface UploadData { + /**上传编号 */ + uploadId: string; + /**文件在云端保存路径 */ + saveFilePath: string; + /**上传文件的大小 */ + fileSize: number; + /**分片数量 */ + chunkCount: number; + /**上传文件的路径 */ + filePath: string; +} + +export interface PartETag { + partNumber: number; + ETag: string; +} + + +export interface ChunkTask { + index: number; + start: number; + end: number; +} + +/** + * 上传进度信息接口 + */ +export interface ProgressInfo { + /** 已完成的分片数量 */ + completedChunks: number; + /** 当前显示的上传进度(整数,如 0, 10, 20...) */ + uploadProgress: number; + /** 总分片数量 */ + chunkCount: number; +} + diff --git a/src/utils/ChunkUpload.ts b/src/utils/ChunkUpload.ts new file mode 100644 index 0000000..80932c5 --- /dev/null +++ b/src/utils/ChunkUpload.ts @@ -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; + + /** + * 构造函数 - 初始化分片上传器 + * 设置默认分片大小为15MB,并发限制为2个分片 + */ + 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 返回Promise,成功时resolve(true),失败时resolve(false) + */ + async upload(params: UploadOptions): Promise { + 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 复制后的文件完整路径 + */ + copyFileToSandbox(srcFilePath: string): Promise { + 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 上传数据对象,包含 uploadId、saveFilePath、fileSize 等 + * @param chunkSize 单个分片的大小(字节) + * @param concurrentLimit 并发上传的分片数量限制 + * @param localFilePath APP端沙盒中的本地文件路径 + * @param progressInfo 进度跟踪对象,包含completedChunks、uploadProgress、chunkCount等属性 + * + * @returns Promise 返回所有分片的ETag信息数组 + * + * @throws {Error} 当任何分片上传失败时抛出错误 + * + */ + async uploadChunksWithTaskQueue( + uploadData: UploadData, + chunkSize: number, + concurrentLimit: number, + localFilePath: string, + progressInfo: ProgressInfo + ): Promise { + const { chunkCount, fileSize, uploadId, saveFilePath } = uploadData; + const taskQueue = new TaskQueue(concurrentLimit); + const partETags: PartETag[] = []; + + // 创建所有分片上传任务 + const uploadPromises: Promise[] = []; + + 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 上传响应结果 + */ + 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 删除是否成功 + */ + deleteLocalFile(filePath: string): Promise { + 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 创建的临时文件完整路径 + */ + createAndWriteTempFile(dirPath: number, fileName: String, data: ArrayBuffer | string): Promise { + 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 删除是否成功 + */ + deleteTempFile(dirPath: number, fileName: string): Promise { + 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 Base64编码的分片数据 + */ + readAppFileChunk(filePath: string, start: number, length: number): Promise { + 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 返回所有分片的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 返回分片的ETag信息 + */ + private async _uploadSingleChunk( + fileManager: UniApp.FileSystemManager, + uploadId: string, + saveFilePath: string, + filePath: string, + chunkIndex: number, + fileSize: number, + progressInfo: ProgressInfo + ): Promise { + 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 - 操作完成的Promise + * @throws {Error} 当文件读取或写入失败时抛出错误 + */ + async _processChunk(fileManager: UniApp.FileSystemManager, filePath: string, tempChunkPath: string, start: number, length: number) { + const readRes = await new Promise((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 进度信息对象,包含completedChunks、uploadProgress、chunkCount等属性 + */ + 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(); \ No newline at end of file diff --git a/src/utils/TaskQueue.ts b/src/utils/TaskQueue.ts new file mode 100644 index 0000000..3178099 --- /dev/null +++ b/src/utils/TaskQueue.ts @@ -0,0 +1,246 @@ +import { nextTick } from "vue"; + +export type TaskFn = (signal?: AbortSignal) => Promise; +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 { + id?: string; + priority: number; + fn: TaskFn; + 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[] = []; + + /** 出错策略 */ + 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(fn: TaskFn, opts: AddTaskOptions = {}): Promise { + return new Promise((resolve, reject) => { + if (this.aborted) { + reject(new AbortError(this.abortReason || 'Queue aborted')); + return; + } + + const item: QueueItem = { + 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); + if (this.autoStart && !this.paused) this.runNext(); + }); + } + + /** 批量添加 */ + public addAll(fns: Array>, opts?: AddTaskOptions): Promise[] { + return fns.map((fn) => this.add(fn, opts)); + } + + /** 等待队列空(无排队任务) */ + public onEmpty(): Promise { + if (this.size === 0) return Promise.resolve(); + return new Promise((resolve) => this.emptyWaiters.push(resolve)); + } + + /** 等待完全空闲(无排队、无运行中) => 等待所有任务完成 */ + public waitAll(): Promise { + 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) { + 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): Promise { + 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; \ No newline at end of file