Просмотр исходного кода

✨ feat(admin): add minio file upload component

- implement MinioUploader component with drag-and-drop functionality
- support file type filtering and size limitation (default 500MB)
- add upload progress display and success/error status feedback
- implement multiple file upload support with configurable option
- add callback functions for upload success and error events
- support custom button text and tip text configuration
- add file removal functionality with status-based disable control
yourname 8 месяцев назад
Родитель
Сommit
8d2cf93a42
1 измененных файлов с 262 добавлено и 0 удалено
  1. 262 0
      src/client/admin/components/MinioUploader.tsx

+ 262 - 0
src/client/admin/components/MinioUploader.tsx

@@ -0,0 +1,262 @@
+import React, { useState, useCallback } from 'react';
+import { Upload, Progress, message, Tag, Space, Typography, Button } from 'antd';
+import { UploadOutlined, CloseOutlined, CheckCircleOutlined, SyncOutlined } from '@ant-design/icons';
+import { App } from 'antd';
+import type { UploadFile , UploadProps} from 'antd';
+import type { UploadFileStatus } from 'antd/es/upload/interface';
+import { uploadMinIOWithPolicy, MinioProgressEvent } from '@/client/utils/minio';
+
+interface MinioUploaderProps {
+  /** 上传路径 */
+  uploadPath: string;
+  /** 允许的文件类型,如['image/*', '.pdf'] */
+  accept?: string;
+  /** 最大文件大小(MB) */
+  maxSize?: number;
+  /** 是否允许多文件上传 */
+  multiple?: boolean;
+  /** 上传成功回调 */
+  onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
+  /** 上传失败回调 */
+  onUploadError?: (error: Error, file: File) => void;
+  /** 自定义上传按钮文本 */
+  buttonText?: string;
+  /** 自定义提示文本 */
+  tipText?: string;
+}
+
+
+const MinioUploader: React.FC<MinioUploaderProps> = ({
+  uploadPath = '/',
+  accept,
+  maxSize = 500, // 默认最大500MB
+  multiple = false,
+  onUploadSuccess,
+  onUploadError,
+  buttonText = '点击或拖拽上传文件',
+  tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
+}) => {
+  const { message: antdMessage } = App.useApp();
+  const [fileList, setFileList] = useState<UploadFile[]>([]);
+  const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
+
+  // 处理上传进度
+  const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: event.stage === 'error' ? ('error' as UploadFileStatus) : ('uploading' as UploadFileStatus),
+            percent: event.progress,
+            error: event.stage === 'error' ? event.message : undefined
+          };
+        }
+        return item;
+      })
+    );
+  }, []);
+
+  // 处理上传成功
+  const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: 'success' as UploadFileStatus,
+            percent: 100,
+            response: { fileKey: result.fileKey },
+            url: result.fileUrl,
+          };
+        }
+        return item;
+      })
+    );
+    
+    setUploadingFiles(prev => {
+      const newSet = new Set(prev);
+      newSet.delete(uid);
+      return newSet;
+    });
+    
+    antdMessage.success(`文件 "${file.name}" 上传成功`);
+    onUploadSuccess?.(result.fileKey, result.fileUrl, file);
+  }, [antdMessage, onUploadSuccess]);
+
+  // 处理上传失败
+  const handleError = useCallback((uid: string, error: Error, file: File) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: 'error' as UploadFileStatus,
+            percent: 0,
+            error: error.message || '上传失败'
+          };
+        }
+        return item;
+      })
+    );
+    
+    setUploadingFiles(prev => {
+      const newSet = new Set(prev);
+      newSet.delete(uid);
+      return newSet;
+    });
+    
+    antdMessage.error(`文件 "${file.name}" 上传失败: ${error.message}`);
+    onUploadError?.(error, file);
+  }, [antdMessage, onUploadError]);
+
+  // 自定义上传逻辑
+  const customRequest = async (options: {
+    file: UploadFile,
+    onSuccess: () => void,
+    onError: (error: Error) => void
+  }) => {
+    const { file, onSuccess, onError } = options;
+    const uid = file.uid;
+    
+    // 添加到文件列表
+    setFileList(prev => [
+      ...prev.filter(item => item.uid !== uid),
+      {
+        uid,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        lastModified: file.lastModified,
+        lastModifiedDate: file.lastModifiedDate,
+        originFileObj: file.originFileObj,
+        status: 'uploading' as UploadFileStatus,
+        percent: 0,
+      }
+    ]);
+    
+    // 添加到上传中集合
+    setUploadingFiles(prev => new Set(prev).add(uid));
+    
+    try {
+      // 调用minio上传方法
+      const result = await uploadMinIOWithPolicy(
+        uploadPath,
+        file.originFileObj as File,
+        file.name,
+        {
+          onProgress: (event) => handleProgress(uid, event),
+          signal: new AbortController().signal
+        }
+      );
+      
+      handleComplete(uid, result, file.originFileObj as File);
+      onSuccess();
+    } catch (error) {
+      handleError(uid, error instanceof Error ? error : new Error('未知错误'), file.originFileObj as File);
+      onError(error instanceof Error ? error : new Error('未知错误'));
+    }
+  };
+
+  // 处理文件移除
+  const handleRemove = (uid: string) => {
+    setFileList(prev => prev.filter(item => item.uid !== uid));
+  };
+
+  // 验证文件大小
+  const beforeUpload = (file: File) => {
+    const fileSizeMB = file.size / (1024 * 1024);
+    if (fileSizeMB > maxSize!) {
+      message.error(`文件 "${file.name}" 大小超过 ${maxSize}MB 限制`);
+      return false;
+    }
+    return true;
+  };
+
+  // 渲染上传状态
+  const renderUploadStatus = (item: UploadFile) => {
+    switch (item.status) {
+      case 'uploading':
+        return (
+          <Space>
+            <SyncOutlined spin size={12} />
+            <span>{item.percent}%</span>
+          </Space>
+        );
+      case 'done':
+        return (
+          <Space>
+            <CheckCircleOutlined style={{ color: '#52c41a' }} size={12} />
+            <Tag color="success">上传成功</Tag>
+          </Space>
+        );
+      case 'error':
+        return (
+          <Space>
+            <CloseOutlined style={{ color: '#ff4d4f' }} size={12} />
+            <Tag color="error">{item.error || '上传失败'}</Tag>
+          </Space>
+        );
+      default:
+        return null;
+    }
+  };
+
+  return (
+    <div className="minio-uploader">
+      <Upload.Dragger
+        name="files"
+        accept={accept}
+        multiple={multiple}
+        customRequest={customRequest}
+        beforeUpload={beforeUpload}
+        showUploadList={false}
+        disabled={uploadingFiles.size > 0 && !multiple}
+      >
+        <div className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-md transition-all hover:border-primary">
+          <UploadOutlined style={{ fontSize: 24, color: '#1890ff' }} />
+          <Typography.Text className="mt-2">{buttonText}</Typography.Text>
+          <Typography.Text type="secondary" className="mt-1">
+            {tipText}
+          </Typography.Text>
+        </div>
+      </Upload.Dragger>
+
+      {/* 上传进度列表 */}
+      {fileList.length > 0 && (
+        <div className="mt-4 space-y-3">
+          {fileList.map(item => (
+            <div key={item.uid} className="flex items-center p-3 border rounded-md">
+              <div className="flex-1 min-w-0">
+                <div className="flex justify-between items-center mb-1">
+                  <Typography.Text ellipsis className="max-w-xs">
+                    {item.name}
+                  </Typography.Text>
+                  <div className="flex items-center space-x-2">
+                    {renderUploadStatus(item)}
+                    <Button
+                      type="text"
+                      size="small"
+                      icon={<CloseOutlined />}
+                      onClick={() => handleRemove(item.uid)}
+                      disabled={item.status === 'uploading'}
+                    />
+                  </div>
+                </div>
+                {item.status === 'uploading' && (
+                  <Progress 
+                    percent={item.percent}
+                    size="small" 
+                    status={item.percent === 100 ? 'success' : undefined}
+                  />
+                )}
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default MinioUploader;