import type { InferResponseType } from 'hono/client'; import Taro from '@tarojs/taro'; import { fileClient } from "../api"; export interface MinioProgressEvent { stage: 'uploading' | 'complete' | 'error'; message: string; progress: number; details?: { loaded: number; total: number; }; timestamp: number; } export interface MinioProgressCallbacks { onProgress?: (event: MinioProgressEvent) => void; onComplete?: () => void; onError?: (error: Error) => void; signal?: { aborted: boolean }; } export interface UploadResult { fileUrl: string; fileKey: string; bucketName: string; } interface UploadPart { ETag: string; PartNumber: number; } interface UploadProgressDetails { partNumber: number; totalParts: number; partSize: number; totalSize: number; partProgress?: number; } type MinioMultipartUploadPolicy = InferResponseType type MinioUploadPolicy = InferResponseType const PART_SIZE = 5 * 1024 * 1024; // 每部分5MB export class TaroMinIOMultipartUploader { /** * 使用 Taro.uploadFile 分段上传文件到 MinIO */ static async upload( policy: MinioMultipartUploadPolicy, filePath: string, key: string, callbacks?: MinioProgressCallbacks ): Promise { const partSize = PART_SIZE; // 获取文件信息 const fileInfo = await Taro.getFileSystemManager().getFileInfo({ filePath }); const totalSize = fileInfo.size; const totalParts = Math.ceil(totalSize / partSize); const uploadedParts: UploadPart[] = []; callbacks?.onProgress?.({ stage: 'uploading', message: '准备上传文件...', progress: 0, details: { loaded: 0, total: totalSize }, timestamp: Date.now() }); // 分段上传 for (let i = 0; i < totalParts; i++) { if (callbacks?.signal?.aborted) { throw new Error('上传已取消'); } const start = i * partSize; const end = Math.min(start + partSize, totalSize); const partNumber = i + 1; try { // 读取文件片段 const partData = await this.readFileSlice(filePath, start, end); const etag = await this.uploadPart( policy.partUrls[i], partData, callbacks, { partNumber, totalParts, partSize: end - start, totalSize } ); uploadedParts.push({ ETag: etag, PartNumber: partNumber }); // 更新进度 const progress = Math.round((end / totalSize) * 100); callbacks?.onProgress?.({ stage: 'uploading', message: `上传文件片段 ${partNumber}/${totalParts}`, progress, details: { loaded: end, total: totalSize, }, timestamp: Date.now() }); } catch (error) { callbacks?.onError?.(error instanceof Error ? error : new Error(String(error))); throw error; } } // 完成上传 try { await this.completeMultipartUpload(policy, key, uploadedParts); callbacks?.onProgress?.({ stage: 'complete', message: '文件上传完成', progress: 100, timestamp: Date.now() }); callbacks?.onComplete?.(); return { fileUrl: `${policy.host}/${key}`, fileKey: key, bucketName: policy.bucket }; } catch (error) { callbacks?.onError?.(error instanceof Error ? error : new Error(String(error))); throw error; } } // 读取文件片段 private static readFileSlice(filePath: string, start: number, end: number): Promise { return new Promise((resolve, reject) => { const fs = Taro.getFileSystemManager(); try { const fileData = fs.readFileSync(filePath, undefined, { position: start, length: end - start }); resolve(fileData); } catch (error) { reject(error); } }); } // 上传单个片段 private static async uploadPart( uploadUrl: string, partData: ArrayBuffer, callbacks?: MinioProgressCallbacks, progressDetails?: UploadProgressDetails ): Promise { return new Promise((resolve, reject) => { const uploadTask = Taro.uploadFile({ url: uploadUrl, filePath: '', name: 'file', formData: {}, header: { 'Content-Type': 'application/octet-stream' }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { // 从响应头获取ETag const etag = res.header['ETag']?.replace(/"/g, '') || ''; resolve(etag); } else { reject(new Error(`上传片段失败: ${res.statusCode} ${res.errMsg}`)); } }, fail: (error) => { reject(new Error(`上传片段失败: ${error.errMsg}`)); } }); // 由于小程序 uploadFile 不直接支持 ArrayBuffer,我们需要使用 putFile // 改用 fetch API 上传二进制数据 this.uploadBinaryData(uploadUrl, partData) .then(resolve) .catch(reject); }); } // 上传二进制数据 private static async uploadBinaryData(uploadUrl: string, data: ArrayBuffer): Promise { return new Promise((resolve, reject) => { Taro.request({ url: uploadUrl, method: 'PUT', data: data, header: { 'Content-Type': 'application/octet-stream' }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { const etag = res.header['ETag']?.replace(/"/g, '') || ''; resolve(etag); } else { reject(new Error(`上传片段失败: ${res.statusCode}`)); } }, fail: (error) => { reject(new Error(`上传片段失败: ${error.errMsg}`)); } }); }); } // 完成分段上传 private static async completeMultipartUpload( policy: MinioMultipartUploadPolicy, key: string, uploadedParts: UploadPart[] ): Promise { const response = await fileClient["multipart-complete"].$post({ json: { bucket: policy.bucket, key, uploadId: policy.uploadId, parts: uploadedParts.map(part => ({ partNumber: part.PartNumber, etag: part.ETag })) } }); if (!response.ok) { throw new Error(`完成分段上传失败: ${response.status} ${response.statusText}`); } } } export class TaroMinIOUploader { /** * 使用 Taro.uploadFile 上传文件到 MinIO */ static async upload( policy: MinioUploadPolicy, filePath: string, key: string, callbacks?: MinioProgressCallbacks ): Promise { // 获取文件信息 const fileInfo = await Taro.getFileSystemManager().getFileInfo({ filePath }); const totalSize = fileInfo.size; callbacks?.onProgress?.({ stage: 'uploading', message: '准备上传文件...', progress: 0, details: { loaded: 0, total: totalSize }, timestamp: Date.now() }); // 准备表单数据 const formData: Record = {}; // 添加 MinIO 需要的字段 Object.entries(policy.uploadPolicy).forEach(([k, value]) => { if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') { formData[k] = value; } }); // 添加自定义 key 字段 formData['key'] = key; return new Promise((resolve, reject) => { // 使用 Taro 的文件系统读取文件 Taro.getFileSystemManager().readFile({ filePath, success: (fileData) => { // 构建 FormData const formDataObj = new FormData(); Object.entries(formData).forEach(([key, value]) => { formDataObj.append(key, value); }); formDataObj.append('file', new Blob([fileData.data])); // 使用 Taro.request 上传 Taro.request({ url: policy.uploadPolicy.host, method: 'POST', data: formDataObj, header: { 'Content-Type': 'multipart/form-data' }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { callbacks?.onProgress?.({ stage: 'complete', message: '文件上传完成', progress: 100, timestamp: Date.now() }); callbacks?.onComplete?.(); resolve({ fileUrl: `${policy.uploadPolicy.host}/${key}`, fileKey: key, bucketName: policy.uploadPolicy.bucket }); } else { const error = new Error(`上传失败: ${res.statusCode}`); callbacks?.onError?.(error); reject(error); } }, fail: (error) => { const err = new Error(`上传失败: ${error.errMsg}`); callbacks?.onError?.(err); reject(err); } }); }, fail: (error) => { const err = new Error(`读取文件失败: ${error.errMsg}`); callbacks?.onError?.(err); reject(err); } }); // 由于小程序限制,我们使用更简单的 uploadFile 方式 // 但先保存临时文件 this.uploadWithTempFile(policy, filePath, key, callbacks) .then(resolve) .catch(reject); }); } private static async uploadWithTempFile( policy: MinioUploadPolicy, filePath: string, key: string, callbacks?: MinioProgressCallbacks ): Promise { return new Promise((resolve, reject) => { // 由于小程序的 uploadFile 只支持文件路径,我们需要构建完整的 FormData // 这里使用 request 方式上传 Taro.getFileSystemManager().readFile({ filePath, success: (fileData) => { const formData = new FormData(); // 添加所有必需的字段 Object.entries(policy.uploadPolicy).forEach(([k, value]) => { if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') { formData.append(k, value); } }); formData.append('key', key); formData.append('file', new Blob([fileData.data])); Taro.request({ url: policy.uploadPolicy.host, method: 'POST', data: formData, header: { 'Content-Type': 'multipart/form-data' }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { callbacks?.onProgress?.({ stage: 'complete', message: '文件上传完成', progress: 100, timestamp: Date.now() }); callbacks?.onComplete?.(); resolve({ fileUrl: `${policy.uploadPolicy.host}/${key}`, fileKey: key, bucketName: policy.uploadPolicy.bucket }); } else { reject(new Error(`上传失败: ${res.statusCode}`)); } }, fail: (error) => { reject(new Error(`上传失败: ${error.errMsg}`)); } }); }, fail: (error) => { reject(new Error(`读取文件失败: ${error.errMsg}`)); } }); }); } } export async function getUploadPolicy(key: string, fileName: string, fileType?: string, fileSize?: number): Promise { const policyResponse = await fileClient["upload-policy"].$post({ json: { path: key, name: fileName, type: fileType, size: fileSize } }); if (!policyResponse.ok) { throw new Error('获取上传策略失败'); } return policyResponse.json(); } export async function getMultipartUploadPolicy(totalSize: number, fileKey: string, fileType?: string, fileName: string = 'unnamed-file') { const policyResponse = await fileClient["multipart-policy"].$post({ json: { totalSize, partSize: PART_SIZE, fileKey, type: fileType, name: fileName } }); if (!policyResponse.ok) { throw new Error('获取分段上传策略失败'); } return await policyResponse.json(); } export async function uploadMinIOWithPolicy( uploadPath: string, filePath: string, fileKey: string, callbacks?: MinioProgressCallbacks ): Promise { if(uploadPath === '/') uploadPath = ''; else{ if(!uploadPath.endsWith('/')) uploadPath = `${uploadPath}/` // 去掉开头的 / if(uploadPath.startsWith('/')) uploadPath = uploadPath.replace(/^\//, ''); } // 获取文件信息 const fileInfo = await Taro.getFileSystemManager().getFileInfo({ filePath }); const fileSize = fileInfo.size; if(fileSize > PART_SIZE) { const policy = await getMultipartUploadPolicy( fileSize, `${uploadPath}${fileKey}`, undefined, fileKey ); return TaroMinIOMultipartUploader.upload( policy, filePath, policy.key, callbacks ); } else { const policy = await getUploadPolicy(`${uploadPath}${fileKey}`, fileKey, undefined, fileSize); return TaroMinIOUploader.upload(policy, filePath, policy.uploadPolicy.key, callbacks); } } // 新增:小程序专用的上传函数 export async function uploadMinIOWithTaroFile( uploadPath: string, tempFilePath: string, fileName: string, callbacks?: MinioProgressCallbacks ): Promise { const fileKey = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${fileName}`; return uploadMinIOWithPolicy(uploadPath, tempFilePath, fileKey, callbacks); } // 新增:从 chooseImage 或 chooseVideo 获取文件 export async function uploadFromChoose( sourceType: ('album' | 'camera')[] = ['album', 'camera'], uploadPath: string = '', callbacks?: MinioProgressCallbacks ): Promise { return new Promise((resolve, reject) => { Taro.chooseImage({ count: 1, sourceType, success: async (res) => { const tempFilePath = res.tempFilePaths[0]; const fileName = res.tempFiles[0]?.name || tempFilePath.split('/').pop() || 'unnamed-file'; try { const result = await uploadMinIOWithPolicy(uploadPath, tempFilePath, fileName, callbacks); resolve(result); } catch (error) { reject(error); } }, fail: reject }); }); }