import type { InferResponseType } from 'hono/client'; import { fileClient } from "../api"; import { isWeapp, isH5 } from './platform'; import Taro from '@tarojs/taro'; // 平台检测 - 使用统一的 platform.ts const isMiniProgram = isWeapp(); const isBrowser = isH5(); if (isMiniProgram) { try { Taro = require('@tarojs/taro'); } catch (e) { // 忽略错误,回退到原生小程序 } } 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?: AbortSignal | { aborted: boolean }; } export interface UploadResult { fileUrl: string; fileKey: string; bucketName: string; fileId: number; } 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 // ==================== H5 实现(保留原有代码) ==================== export class MinIOXHRMultipartUploader { /** * 使用XHR分段上传文件到MinIO(H5环境) */ static async upload( policy: MinioMultipartUploadPolicy, file: File | Blob, key: string, callbacks?: MinioProgressCallbacks ): Promise { const partSize = PART_SIZE; const totalSize = file.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++) { const start = i * partSize; const end = Math.min(start + partSize, totalSize); const partBlob = file.slice(start, end); const partNumber = i + 1; try { const etag = await this.uploadPart( policy.partUrls[i], partBlob, callbacks, { partNumber, totalParts, partSize: partBlob.size, 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, fileId: result.file.id }; } catch (error) { callbacks?.onError?.(error instanceof Error ? error : new Error(String(error))); throw error; } } // 上传单个片段 private static uploadPart( uploadUrl: string, partBlob: Blob, callbacks?: MinioProgressCallbacks, progressDetails?: UploadProgressDetails ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.onprogress = (event) => { if (event.lengthComputable && callbacks?.onProgress) { const partProgress = Math.round((event.loaded / event.total) * 100); callbacks.onProgress({ stage: 'uploading', message: `上传文件片段 ${progressDetails?.partNumber}/${progressDetails?.totalParts} (${partProgress}%)`, progress: Math.round(( (progressDetails?.partNumber ? (progressDetails.partNumber - 1) * (progressDetails.partSize || 0) : 0) + event.loaded ) / (progressDetails?.totalSize || 1) * 100), details: { ...progressDetails, loaded: event.loaded, total: event.total }, timestamp: Date.now() }); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { const etag = xhr.getResponseHeader('ETag')?.replace(/"/g, '') || ''; resolve(etag); } else { reject(new Error(`上传片段失败: ${xhr.status} ${xhr.statusText}`)); } }; xhr.onerror = () => reject(new Error('上传片段失败')); xhr.open('PUT', uploadUrl); xhr.send(partBlob); if (callbacks?.signal) { if ('addEventListener' in callbacks.signal) { callbacks.signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('上传已取消')); }); } } }); } // 完成分段上传 private static async completeMultipartUpload( policy: MinioMultipartUploadPolicy, key: string, uploadedParts: UploadPart[] ): Promise<{ fileId: number }> { 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}`); } return response.json(); } } export class MinIOXHRUploader { /** * 使用XHR上传文件到MinIO(H5环境) */ static upload( policy: MinioUploadPolicy, file: File | Blob, key: string, callbacks?: MinioProgressCallbacks ): Promise { const formData = new FormData(); // 添加 MinIO 需要的字段 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', file); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // 上传进度处理 if (callbacks?.onProgress) { xhr.upload.onprogress = (event) => { if (event.lengthComputable) { callbacks.onProgress?.({ stage: 'uploading', message: '正在上传文件...', progress: Math.round((event.loaded * 100) / event.total), details: { loaded: event.loaded, total: event.total }, timestamp: Date.now() }); } }; } // 完成处理 xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { if (callbacks?.onProgress) { callbacks.onProgress({ stage: 'complete', message: '文件上传完成', progress: 100, timestamp: Date.now() }); } callbacks?.onComplete?.(); resolve({ fileUrl: `${policy.uploadPolicy.host}/${key}`, fileKey: key, bucketName: policy.uploadPolicy.bucket, fileId: result.file.id }); } else { const error = new Error(`上传失败: ${xhr.status} ${xhr.statusText}`); callbacks?.onError?.(error); reject(error); } }; // 错误处理 xhr.onerror = () => { const error = new Error('上传失败'); if (callbacks?.onProgress) { callbacks.onProgress({ stage: 'error', message: '文件上传失败', progress: 0, timestamp: Date.now() }); } callbacks?.onError?.(error); reject(error); }; // 根据当前页面协议和 host 配置决定最终的上传地址 const currentProtocol = typeof window !== 'undefined' ? window.location.protocol : 'https:'; const host = policy.uploadPolicy.host?.startsWith('http') ? policy.uploadPolicy.host : `${currentProtocol}//${policy.uploadPolicy.host}`; xhr.open('POST', host); xhr.send(formData); // 处理取消 if (callbacks?.signal) { if ('addEventListener' in callbacks.signal) { callbacks.signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('上传已取消')); }); } } }); } } // ==================== 小程序实现 ==================== export class TaroMinIOMultipartUploader { /** * 使用 Taro 分段上传文件到 MinIO(小程序环境) */ static async upload( policy: MinioMultipartUploadPolicy, filePath: string, key: string, callbacks?: MinioProgressCallbacks ): Promise { const partSize = PART_SIZE; // 获取文件信息 const fileInfo = await getFileInfoPromise(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' in callbacks.signal && 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 { const result = 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, fileId: result.fileId }; } catch (error) { callbacks?.onError?.(error instanceof Error ? error : new Error(String(error))); throw error; } } // 读取文件片段 private static async readFileSlice(filePath: string, start: number, end: number): Promise { return new Promise((resolve, reject) => { try { const fs = Taro?.getFileSystemManager?.(); if (!fs) { reject(new Error('小程序文件系统不可用')); return; } const fileData = fs.readFileSync(filePath, undefined, start, 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) => { Taro?.request?.({ url: uploadUrl, method: 'PUT', data: partData, header: { 'Content-Type': 'application/octet-stream' }, success: (res: any) => { if (res.statusCode >= 200 && res.statusCode < 300) { const etag = res.header?.['ETag']?.replace(/"/g, '') || ''; resolve(etag); } else { reject(new Error(`上传片段失败: ${res.statusCode}`)); } }, fail: (error: any) => { reject(new Error(`上传片段失败: ${error.errMsg}`)); } }) || reject(new Error('小程序环境不可用')); }); } // 完成分段上传 private static async completeMultipartUpload( policy: MinioMultipartUploadPolicy, key: string, uploadedParts: UploadPart[] ): Promise<{ fileId: number }> { 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}`); } return await response.json(); } } export class TaroMinIOUploader { /** * 使用 Taro 上传文件到 MinIO(小程序环境) */ static async upload( policy: MinioUploadPolicy, filePath: string, key: string, callbacks?: MinioProgressCallbacks ): Promise { // 获取文件信息 const fileInfo = await getFileInfoPromise(filePath); const totalSize = fileInfo.size; callbacks?.onProgress?.({ stage: 'uploading', message: '准备上传文件...', progress: 0, details: { loaded: 0, total: totalSize }, timestamp: Date.now() }); return new Promise((resolve, reject) => { // 准备表单数据 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; } }); formData['key'] = key; try { const fileSystemManager = Taro?.getFileSystemManager?.(); if (!fileSystemManager) { throw new Error('小程序环境不可用'); } const fileData = fileSystemManager.readFileSync(filePath, 'binary'); const formDataObj = new FormData(); Object.entries(formData).forEach(([k, value]) => { formDataObj.append(k, value); }); formDataObj.append('file', new Blob([fileData])); 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, fileId: policy.file.id }); } else { reject(new Error(`上传失败: ${res.statusCode}`)); } }, fail: (error: any) => { reject(new Error(`上传失败: ${error.errMsg}`)); } }); } catch (error: any) { reject(new Error(`读取文件失败: ${error.message || error.errMsg}`)); } }); } } // ==================== 统一 API ==================== /** * 根据运行环境自动选择合适的上传器 */ export class UniversalMinIOMultipartUploader { static async upload( policy: MinioMultipartUploadPolicy, file: File | Blob | string, key: string, callbacks?: MinioProgressCallbacks ): Promise { if (isBrowser && (file instanceof File || file instanceof Blob)) { return MinIOXHRMultipartUploader.upload(policy, file, key, callbacks); } else if (isMiniProgram && typeof file === 'string') { return TaroMinIOMultipartUploader.upload(policy, file, key, callbacks); } else { throw new Error('不支持的运行环境或文件类型'); } } } export class UniversalMinIOUploader { static async upload( policy: MinioUploadPolicy, file: File | Blob | string, key: string, callbacks?: MinioProgressCallbacks ): Promise { if (isBrowser && (file instanceof File || file instanceof Blob)) { return MinIOXHRUploader.upload(policy, file, key, callbacks); } else if (isMiniProgram && typeof file === 'string') { return TaroMinIOUploader.upload(policy, file, key, callbacks); } else { throw new Error('不支持的运行环境或文件类型'); } } } // ==================== 通用函数 ==================== 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, file: File | Blob | string, fileKey: string, callbacks?: MinioProgressCallbacks ): Promise { if(uploadPath === '/') uploadPath = ''; else{ if(!uploadPath.endsWith('/')) uploadPath = `${uploadPath}/` if(uploadPath.startsWith('/')) uploadPath = uploadPath.replace(/^\//, ''); } let fileSize: number; let fileType: string | undefined; let fileName: string; if (isBrowser && (file instanceof File || file instanceof Blob)) { fileSize = file.size; fileType = (file as File).type || undefined; fileName = (file as File).name || fileKey; } else if (isMiniProgram && typeof file === 'string') { try { const fileInfo = await getFileInfoPromise(file); fileSize = fileInfo.size; fileType = undefined; fileName = fileKey; } catch { fileSize = 0; fileType = undefined; fileName = fileKey; } } else { throw new Error('不支持的文件类型'); } if (fileSize > PART_SIZE) { if (isBrowser && !(file instanceof File)) { throw new Error('不支持的文件类型,无法获取文件名'); } const policy = await getMultipartUploadPolicy( fileSize, `${uploadPath}${fileKey}`, fileType, fileName ); if (isBrowser) { return MinIOXHRMultipartUploader.upload(policy, file as File | Blob, policy.key, callbacks); } else { return TaroMinIOMultipartUploader.upload(policy, file as string, policy.key, callbacks); } } else { if (isBrowser && !(file instanceof File)) { throw new Error('不支持的文件类型,无法获取文件名'); } const policy = await getUploadPolicy(`${uploadPath}${fileKey}`, fileName, fileType, fileSize); if (isBrowser) { return MinIOXHRUploader.upload(policy, file as File | Blob, policy.uploadPolicy.key, callbacks); } else { return TaroMinIOUploader.upload(policy, file as string, policy.uploadPolicy.key, callbacks); } } } // ==================== 小程序工具函数 ==================== /** * Promise封装的getFileInfo函数 */ async function getFileInfoPromise(filePath: string): Promise<{ size: number }> { return new Promise((resolve, reject) => { const fs = Taro?.getFileSystemManager?.(); if (!fs) { reject(new Error('小程序文件系统不可用')); return; } fs.getFileInfo({ filePath, success: (res) => { resolve({ size: res.size }); }, fail: (error) => { reject(new Error(`获取文件信息失败: ${error.errMsg}`)); } }); }); } // 新增:自动适应运行环境的文件选择并上传函数 /** * 自动适应运行环境:选择文件并上传到 MinIO * 小程序:使用 Taro.chooseImage * H5:使用 input[type="file"] */ export async function uploadFromSelect( uploadPath: string = '', options: { sourceType?: ('album' | 'camera')[], count?: number, accept?: string, maxSize?: number, } = {}, callbacks?: MinioProgressCallbacks ): Promise { const { sourceType = ['album', 'camera'], count = 1, accept = '*', maxSize = 10 * 1024 * 1024 } = options; if (isMiniProgram) { return new Promise((resolve, reject) => { if (typeof Taro === 'undefined') { reject(new Error('Taro 环境未找到')); return; } Taro.chooseImage({ count, sourceType: sourceType as any, // 确保类型兼容 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 }); }); } else if (isBrowser) { return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = accept; input.multiple = count > 1; input.onchange = async (event) => { const files = (event.target as HTMLInputElement).files; if (!files || files.length === 0) { reject(new Error('未选择文件')); return; } const file = files[0]; if (file.size > maxSize) { reject(new Error(`文件大小超过限制: ${maxSize / 1024 / 1024}MB`)); return; } const fileName = file.name || 'unnamed-file'; try { const result = await uploadMinIOWithPolicy(uploadPath, file, fileName, callbacks); resolve(result); } catch (error) { reject(error); } }; input.click(); }); } else { throw new Error('不支持的运行环境'); } } // 默认导出 export default { MinIOXHRMultipartUploader, MinIOXHRUploader, TaroMinIOMultipartUploader, TaroMinIOUploader, UniversalMinIOMultipartUploader, UniversalMinIOUploader, getUploadPolicy, getMultipartUploadPolicy, uploadMinIOWithPolicy, uploadFromSelect };