瀏覽代碼

✨ feat(utils): 添加MinIO文件上传工具类

- 实现MinIOXHRMultipartUploader类,支持大文件分段上传
- 实现MinIOXHRUploader类,支持普通文件上传
- 添加上传进度回调机制,支持上传状态跟踪
- 实现自动根据文件大小选择上传策略的uploadMinIOWithPolicy方法
- 定义上传相关接口类型,包括进度事件、回调函数和上传结果等
- 设置默认分段大小为5MB,优化大文件上传体验
yourname 4 月之前
父節點
當前提交
35058dbe10
共有 1 個文件被更改,包括 385 次插入0 次删除
  1. 385 0
      mini/src/utils/minio.ts

+ 385 - 0
mini/src/utils/minio.ts

@@ -0,0 +1,385 @@
+import type { InferResponseType, InferRequestType } 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<typeof fileClient["multipart-policy"]['$post'],200>
+type MinioUploadPolicy = InferResponseType<typeof fileClient["upload-policy"]['$post'],200>
+
+
+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<UploadResult> {
+    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<string> {
+    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<void> {
+    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<UploadResult> {
+        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<MinioUploadPolicy> {
+  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<UploadResult> {
+  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);
+  }
+}