|
|
@@ -0,0 +1,376 @@
|
|
|
+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) {
|
|
|
+ const policyResponse = await fileClient["multipart-policy"].$post({
|
|
|
+ json: {
|
|
|
+ totalSize,
|
|
|
+ partSize: PART_SIZE,
|
|
|
+ fileKey,
|
|
|
+ type: fileType
|
|
|
+ }
|
|
|
+ });
|
|
|
+ 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 ){
|
|
|
+ const policy = await getMultipartUploadPolicy(file.size, `${uploadPath}${fileKey}`, file instanceof File ? file.type : undefined)
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+}
|