diff --git a/package.json b/package.json index 17f4887e6c3428cb7b96ed2c3d28439b9100553d..b9059f92319a8f19280a0ac52edbaf0fd09f598b 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 0000000000000000000000000000000000000000..a61257f93ce90b862859faa235344086429667ca --- /dev/null +++ b/src/api/system/chunkUpload/index.js @@ -0,0 +1,89 @@ +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, + chunkIndex: 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 0000000000000000000000000000000000000000..aafb1058dd82969175a83544101e296e8457b2c1 --- /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 0000000000000000000000000000000000000000..6d4a58b1df88a25d76c65d6ac5bb573b355e27bb --- /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 0e977d5bcb2f7d9ad7359a968d96ee366133953b..8248469f89a700b7709b937298ec646e50ec0e08 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 1e606a437a8f5b973f483f2c206adfe9bbac9d03..2e582d621f244382c522537376c3a324540d8fea 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 0000000000000000000000000000000000000000..cc468815aaa262796b9fdcf846d044492ca7048f --- /dev/null +++ b/src/pages_geek/pages/upload/index.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/types/upload.ts b/src/types/upload.ts new file mode 100644 index 0000000000000000000000000000000000000000..71e1adc68a1bbb3d96908d0278141ab5e6a3944a --- /dev/null +++ b/src/types/upload.ts @@ -0,0 +1,47 @@ + + +export interface UploadOptions { + /**文件 */ + file: File + /**成功回调 */ + onSuccess?: (result: any) => void; + /**失败回调 */ + onError?: (error: any) => void; +} + +export interface File { + /**文件路径 */ + path: string; + /**文件大小 */ + size: number; +} + +export interface UploadData { + /**上传编号 */ + uploadId: string; + /**文件在云端保存路径 */ + saveFilePath: string; + /**上传文件的名称 */ + uploadFileName: string; + /**上传文件的大小 */ + fileSize: number; + /**分片数量 */ + chunkCount: number; + /**上传文件的路径 */ + filePath: string; +} + +export interface PartETag { + partNumber: number; + ETag: string; +} + + +export interface ChunkTask { + index: number; + start: number; + end: number; +} + + + diff --git a/src/utils/ChunkUploaderApp.ts b/src/utils/ChunkUploaderApp.ts new file mode 100644 index 0000000000000000000000000000000000000000..47a390f3d0eab95d59d9aff0de6c59ed1f479b1d --- /dev/null +++ b/src/utils/ChunkUploaderApp.ts @@ -0,0 +1,479 @@ +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; + + + /** + * 构造函数 - 初始化分片上传器 + * 设置默认分片大小为15MB,并发限制为2个分片 + */ + 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 返回上传是否成功 + */ + async upload(options: UploadOptions): Promise { + 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 上传结果数组 + */ + async uploadChunksInBatches(tasks: ChunkTask[], batchSize: number, uploadId: string, filePath: string, localFilePath: string, partETags: PartETag[], progressInfo: any): Promise { + 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 上传响应结果 + */ + 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 分片任务对象,包含index、start、end等信息 + * @param uploadId 上传ID + * @param filePath 服务器文件路径 + * @param localFilePath 本地文件路径 + * @param partETags 分片ETag数组,用于合并分片 + * @param progressInfo 进度信息对象,包含completedChunks、uploadProgress、chunkCount等 + * @returns Promise 上传响应结果 + */ + 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 复制后的文件完整路径 + */ + copyFileToSandbox(srcUrl: string): Promise { + 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 删除是否成功 + */ + deleteLocalFile(filePath: string): Promise { + 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 创建的临时文件完整路径 + */ + 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); }, + (err) => { 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); } + ); + }); + }; + + + +} + + +export default new AppChunkUploader() +export { AppChunkUploader } diff --git a/src/utils/ChunkUploaderWx.ts b/src/utils/ChunkUploaderWx.ts new file mode 100644 index 0000000000000000000000000000000000000000..175432acd2eef3d65b51d2be40e1942acd9ba35d --- /dev/null +++ b/src/utils/ChunkUploaderWx.ts @@ -0,0 +1,282 @@ +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 - 上传是否成功 + */ + async upload(options: UploadOptions): Promise { + 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 - 包含上传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 - 返回所有分片的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 - 操作完成的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: 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 - 合并操作完成的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(); \ No newline at end of file