import type { InferResponseType } from 'hono/client'; 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?: AbortSignal; } 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 MinIOXHRMultipartUploader { /** * 使用XHR分段上传文件到MinIO */ 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 }; } 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) { // 获取ETag(MinIO返回的标识) 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) { callbacks.signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('上传已取消')); }); } }); } // 完成分段上传 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 MinIOXHRUploader { /** * 使用XHR上传文件到MinIO */ static upload( policy: MinioUploadPolicy, file: File | Blob, key: string, callbacks?: MinioProgressCallbacks ): Promise { const formData = new FormData(); // 添加 MinIO 需要的字段 Object.entries(policy.uploadPolicy).forEach(([k, value]) => { // 排除 policy 中的 key、host、prefix、ossType 字段 if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') { formData.append(k, value); } }); // 添加 自定义 key 字段 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 }); } 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) { callbacks.signal.addEventListener('abort', () => { xhr.abort(); reject(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, fileKey: string, callbacks?: MinioProgressCallbacks ): Promise { if(uploadPath === '/') uploadPath = ''; else{ if(!uploadPath.endsWith('/')) uploadPath = `${uploadPath}/` // 去掉开头的 / if(uploadPath.startsWith('/')) uploadPath = uploadPath.replace(/^\//, ''); } if( file.size > PART_SIZE ){ if (!(file instanceof File)) { throw new Error('不支持的文件类型,无法获取文件名'); } const policy = await getMultipartUploadPolicy( file.size, `${uploadPath}${fileKey}`, file.type, file.name ); return MinIOXHRMultipartUploader.upload( policy, file, policy.key, callbacks ); }else{ if (!(file instanceof File)) { throw new Error('不支持的文件类型,无法获取文件名'); } const policy = await getUploadPolicy(`${uploadPath}${fileKey}`, file.name, file.type, file.size); return MinIOXHRUploader.upload(policy, file, policy.uploadPolicy.key, callbacks); } }