|
|
@@ -0,0 +1,879 @@
|
|
|
+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();
|
|
|
+
|
|
|
+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<typeof fileClient["multipart-policy"]['$post'], 200>
|
|
|
+type MinioUploadPolicy = InferResponseType<typeof fileClient["upload-policy"]['$post'], 200>
|
|
|
+
|
|
|
+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<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 {
|
|
|
+ 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 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) {
|
|
|
+ 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<UploadResult> {
|
|
|
+ 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: policy.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<UploadResult> {
|
|
|
+ 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<ArrayBuffer> {
|
|
|
+ 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);
|
|
|
+
|
|
|
+ // 确保返回 ArrayBuffer 类型
|
|
|
+ if (typeof fileData === 'string') {
|
|
|
+ // 将字符串转换为 ArrayBuffer
|
|
|
+ const encoder = new TextEncoder();
|
|
|
+ resolve(encoder.encode(fileData).buffer);
|
|
|
+ } else if (fileData instanceof ArrayBuffer) {
|
|
|
+ resolve(fileData);
|
|
|
+ } else {
|
|
|
+ // 处理其他可能的数据类型
|
|
|
+ reject(new Error('文件数据类型不支持'));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ reject(error);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 上传单个片段
|
|
|
+ private static async uploadPart(
|
|
|
+ uploadUrl: string,
|
|
|
+ partData: ArrayBuffer,
|
|
|
+ _callbacks?: MinioProgressCallbacks,
|
|
|
+ _progressDetails?: UploadProgressDetails
|
|
|
+ ): Promise<string> {
|
|
|
+ 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<UploadResult> {
|
|
|
+ // 获取文件信息
|
|
|
+ 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) => {
|
|
|
+ // 准备表单数据 - 使用对象形式,Taro.uploadFile会自动处理
|
|
|
+ const formData: Record<string, string> = {};
|
|
|
+
|
|
|
+ // 添加 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;
|
|
|
+
|
|
|
+ // 使用 Taro.uploadFile 替代 FormData
|
|
|
+ const uploadTask = Taro.uploadFile({
|
|
|
+ url: policy.uploadPolicy.host,
|
|
|
+ filePath: filePath,
|
|
|
+ name: 'file',
|
|
|
+ formData: 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,
|
|
|
+ fileId: policy.file.id
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ reject(new Error(`上传失败: ${res.statusCode}`));
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fail: (error) => {
|
|
|
+ reject(new Error(`上传失败: ${error.errMsg}`));
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听上传进度
|
|
|
+ uploadTask.progress((res) => {
|
|
|
+ if (res.totalBytesExpectedToSend > 0) {
|
|
|
+ const currentProgress = Math.round((res.totalBytesSent / res.totalBytesExpectedToSend) * 100);
|
|
|
+ callbacks?.onProgress?.({
|
|
|
+ stage: 'uploading',
|
|
|
+ message: `上传中 ${currentProgress}%`,
|
|
|
+ progress: currentProgress,
|
|
|
+ details: {
|
|
|
+ loaded: res.totalBytesSent,
|
|
|
+ total: res.totalBytesExpectedToSend
|
|
|
+ },
|
|
|
+ timestamp: Date.now()
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 支持取消上传
|
|
|
+ if (callbacks?.signal && 'aborted' in callbacks.signal) {
|
|
|
+ if (callbacks.signal.aborted) {
|
|
|
+ uploadTask.abort();
|
|
|
+ reject(new Error('上传已取消'));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 监听取消信号
|
|
|
+ const checkAbort = () => {
|
|
|
+ if (callbacks.signal?.aborted) {
|
|
|
+ uploadTask.abort();
|
|
|
+ reject(new Error('上传已取消'));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 定期检查取消状态
|
|
|
+ const abortInterval = setInterval(checkAbort, 100);
|
|
|
+
|
|
|
+ // 清理定时器
|
|
|
+ const cleanup = () => clearInterval(abortInterval);
|
|
|
+ uploadTask.onProgressUpdate = cleanup;
|
|
|
+ uploadTask.onHeadersReceived = cleanup;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 统一 API ====================
|
|
|
+/**
|
|
|
+ * 根据运行环境自动选择合适的上传器
|
|
|
+ */
|
|
|
+export class UniversalMinIOMultipartUploader {
|
|
|
+ static async upload(
|
|
|
+ policy: MinioMultipartUploadPolicy,
|
|
|
+ file: File | Blob | string,
|
|
|
+ key: string,
|
|
|
+ callbacks?: MinioProgressCallbacks
|
|
|
+ ): Promise<UploadResult> {
|
|
|
+ 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<UploadResult> {
|
|
|
+ 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<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 | string,
|
|
|
+ fileKey: string,
|
|
|
+ callbacks?: MinioProgressCallbacks
|
|
|
+): Promise<UploadResult> {
|
|
|
+ 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<UploadResult> {
|
|
|
+ const { sourceType = ['album', 'camera'], count = 1, accept = '*', maxSize = 10 * 1024 * 1024 } = options;
|
|
|
+
|
|
|
+ if (isMiniProgram) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+
|
|
|
+ Taro.chooseImage({
|
|
|
+ count,
|
|
|
+ sourceType: sourceType as any, // 确保类型兼容
|
|
|
+ success: async (res) => {
|
|
|
+ const tempFilePath = res.tempFilePaths[0];
|
|
|
+ const fileName = res.tempFiles[0]?.originalFileObj?.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
|
|
|
+};
|