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 @@
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+ {{ uploadText }}
+ {{ displayUploadDesc }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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