Parcourir la source

✨ feat(mobile): 新增文件选择器和MinIO上传器组件

- 添加FileSelector组件支持单选和多选文件选择模式
- 实现文件预览、过滤、上传和选择功能
- 集成MinioUploader组件提供拖拽上传和进度显示
- 支持图片预览、文件类型图标显示和批量操作
- 提供完整的对话框界面和响应式设计

✨ feat(upload): 实现MinIO文件上传组件

- 创建MinioUploader组件支持拖拽和传统上传模式
- 实现上传进度跟踪、状态管理和错误处理
- 支持多种尺寸模式和显示配置
- 提供文件列表管理和移除功能
- 集成文件大小验证和类型过滤
yourname il y a 6 mois
Parent
commit
1e7bfeb06e

+ 516 - 0
src/client/mobile/components/FileSelector.tsx

@@ -0,0 +1,516 @@
+import React, { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/client/components/ui/button';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
+import { Card, CardContent } from '@/client/components/ui/card';
+import { toast } from 'sonner';
+import { fileClient } from '@/client/api';
+import MinioUploader from '@/client/mobile/components/MinioUploader';
+import { Check, Upload, Eye, X, File as FileIcon, Image as ImageIcon } from 'lucide-react';
+import { cn } from '@/client/lib/utils';
+import type { InferResponseType } from 'hono/client';
+
+type FileType = InferResponseType<typeof fileClient.$get, 200>['data'][0]
+
+export interface FileSelectorProps {
+  value?: number | null | number[];
+  onChange?: (fileId: number | null | number[]) => void;
+  accept?: string;
+  maxSize?: number;
+  uploadPath?: string;
+  uploadButtonText?: string;
+  previewSize?: 'small' | 'medium' | 'large';
+  showPreview?: boolean;
+  placeholder?: string;
+  title?: string;
+  description?: string;
+  filterType?: 'image' | 'all' | string;
+  allowMultiple?: boolean;
+}
+
+export const FileSelector: React.FC<FileSelectorProps> = ({
+  value,
+  onChange,
+  accept = '*/*',
+  maxSize = 10,
+  uploadPath = '/files',
+  uploadButtonText = '上传文件',
+  previewSize = 'medium',
+  showPreview = true,
+  placeholder = '选择文件',
+  title = '选择文件',
+  description = '上传新文件或从已有文件中选择',
+  filterType = 'all',
+  allowMultiple = false,
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
+  const [localSelectedFiles, setLocalSelectedFiles] = useState<number[]>([]);
+
+  // 获取当前选中的文件详情 - 支持单值和数组
+  const { data: currentFiles } = useQuery<FileType[]>({
+    queryKey: ['file-details', value, allowMultiple],
+    queryFn: async (): Promise<FileType[]> => {
+      if (!value) return [];
+      
+      // 处理多选模式下的数组值
+      if (allowMultiple && Array.isArray(value)) {
+        if (value.length === 0) return [];
+        
+        // 批量获取多个文件详情
+        const filePromises = value.map(async (fileId) => {
+          try {
+            const response = await fileClient[':id']['$get']({ param: { id: fileId.toString() } });
+            if (response.status === 200) {
+              return response.json();
+            }
+            return null;
+          } catch (error) {
+            console.error(`获取文件 ${fileId} 详情失败:`, error);
+            return null;
+          }
+        });
+        
+        const files = await Promise.all(filePromises);
+        return files.filter(file => file !== null);
+      }
+      
+      // 处理单选模式下的单值
+      if (!Array.isArray(value)) {
+        const response = await fileClient[':id']['$get']({ param: { id: value.toString() } });
+        if (response.status !== 200) throw new Error('获取文件详情失败');
+        return [await response.json()];
+      }
+      
+      return [];
+    },
+    enabled: !!value,
+  });
+
+  // 当对话框打开时,设置当前选中的文件
+  useEffect(() => {
+    if (isOpen) {
+      if (allowMultiple) {
+        // 在多选模式下,使用 value 数组初始化本地选择
+        const initialSelection = Array.isArray(value) ? value : [];
+        setLocalSelectedFiles(initialSelection);
+      } else if (value && currentFiles && currentFiles.length > 0) {
+        setSelectedFile(currentFiles[0]);
+      }
+    }
+  }, [isOpen, value, currentFiles, allowMultiple]);
+
+  // 获取文件列表
+  const { data: filesData, isLoading, refetch } = useQuery({
+    queryKey: ['files-for-selection', filterType] as const,
+    queryFn: async () => {
+      const response = await fileClient.$get({
+        query: {
+          page: 1,
+          pageSize: 50,
+          ...(filterType !== 'all' && { keyword: filterType })
+        }
+      });
+      if (response.status !== 200) throw new Error('获取文件列表失败');
+      return response.json();
+    },
+    enabled: isOpen,
+  });
+
+  const files = filesData?.data?.filter((f) => {
+    if (filterType === 'all') return true;
+    if (filterType === 'image') return f?.type?.startsWith('image/');
+    return f?.type?.includes(filterType);
+  }) || [];
+
+  const handleSelectFile = (file: FileType) => {
+    if (allowMultiple) {
+      setLocalSelectedFiles(prev => {
+        const newSelection = prev.includes(file.id)
+          ? prev.filter(id => id !== file.id)
+          : [...prev, file.id];
+        return newSelection;
+      });
+    } else {
+      setSelectedFile(prevSelected => {
+        if (prevSelected?.id === file.id) {
+          return null;
+        }
+        return file;
+      });
+    }
+  };
+
+  const handleConfirm = () => {
+    if (allowMultiple) {
+      if (onChange) {
+        onChange(localSelectedFiles);
+      }
+      setIsOpen(false);
+      return;
+    }
+
+    if (!selectedFile) {
+      toast.warning('请选择一个文件');
+      return;
+    }
+    if (onChange) {
+      onChange(selectedFile.id);
+    }
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  const handleCancel = () => {
+    setIsOpen(false);
+    setSelectedFile(null);
+    // 取消时重置为初始的 value 值
+    const initialSelection = allowMultiple && Array.isArray(value) ? value : [];
+    setLocalSelectedFiles(initialSelection);
+  };
+
+  const handleUploadSuccess = () => {
+    toast.success('文件上传成功!请从列表中选择新上传的文件');
+    refetch();
+  };
+
+  const getPreviewSize = () => {
+    switch (previewSize) {
+      case 'small':
+        return 'h-16 w-16';
+      case 'medium':
+        return 'h-24 w-24';
+      case 'large':
+        return 'h-32 w-32';
+      default:
+        return 'h-24 w-24';
+    }
+  };
+
+  const getFileIcon = (fileType: string) => {
+    if (fileType.startsWith('image/')) {
+      return <ImageIcon className="h-8 w-8 text-gray-400" />;
+    }
+    if (fileType.startsWith('video/')) {
+      return <FileIcon className="h-8 w-8 text-blue-500" />;
+    }
+    if (fileType.startsWith('audio/')) {
+      return <FileIcon className="h-8 w-8 text-green-500" />;
+    }
+    if (fileType.includes('pdf')) {
+      return <FileIcon className="h-8 w-8 text-red-500" />;
+    }
+    if (fileType.includes('text')) {
+      return <FileIcon className="h-8 w-8 text-gray-600" />;
+    }
+    return <FileIcon className="h-8 w-8 text-gray-400" />;
+  };
+
+  const handleRemoveFile = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (allowMultiple && Array.isArray(value)) {
+      // 在多选模式下,移除所有选中文件
+      onChange?.([]);
+    } else {
+      // 在单选模式下,设置为null
+      onChange?.(null);
+    }
+  };
+
+  const isSelected = (fileId: number) => {
+    if (allowMultiple) {
+      return localSelectedFiles.includes(fileId);
+    }
+    return selectedFile?.id === fileId;
+  };
+
+  return (
+    <>
+      <div className="space-y-4">
+        {showPreview && (
+          <div className="flex items-start space-x-4">
+            {/* 预览区域 */}
+            <div className="flex flex-wrap gap-2">
+              {allowMultiple && Array.isArray(currentFiles) && currentFiles.length > 0 ? (
+                // 多选模式下的预览
+                currentFiles.map((file) => (
+                  <div key={file.id} className="relative group">
+                    <div
+                      className={cn(
+                        getPreviewSize(),
+                        "border-2 border-dashed cursor-pointer hover:border-primary transition-colors rounded-lg overflow-hidden flex items-center justify-center bg-gray-100"
+                      )}
+                      onClick={() => setIsOpen(true)}
+                    >
+                      {file?.type ? (
+                        <div className="w-full h-full flex items-center justify-center">
+                          {file.type.startsWith('image/') ? (
+                            <img
+                              src={file.fullUrl}
+                              alt={file.name}
+                              className="w-full h-full object-cover"
+                            />
+                          ) : (
+                            <div className="flex flex-col items-center justify-center text-gray-400">
+                              {getFileIcon(file.type)}
+                              <span className="text-xs mt-1 text-center px-1 truncate max-w-full">
+                                {file.name}
+                              </span>
+                            </div>
+                          )}
+                        </div>
+                      ) : (
+                        <div className="flex flex-col items-center justify-center text-gray-400">
+                          <FileIcon className="h-8 w-8 mb-1" />
+                          <span className="text-xs">{placeholder}</span>
+                        </div>
+                      )}
+                    </div>
+                    
+                    <button
+                      type="button"
+                      className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        if (allowMultiple && Array.isArray(value)) {
+                          const newValue = value.filter(id => id !== file.id);
+                          onChange?.(newValue);
+                        }
+                      }}
+                    >
+                      <X className="h-3 w-3" />
+                    </button>
+                  </div>
+                ))
+              ) : !allowMultiple && currentFiles && currentFiles.length > 0 ? (
+                // 单选模式下的预览
+                <div className="relative group">
+                  <div
+                    className={cn(
+                      getPreviewSize(),
+                      "border-2 border-dashed cursor-pointer hover:border-primary transition-colors rounded-lg overflow-hidden flex items-center justify-center bg-gray-100"
+                    )}
+                    onClick={() => setIsOpen(true)}
+                  >
+                    {currentFiles[0]?.type ? (
+                      <div className="w-full h-full flex items-center justify-center">
+                        {currentFiles[0].type.startsWith('image/') ? (
+                          <img
+                            src={currentFiles[0].fullUrl}
+                            alt={currentFiles[0].name}
+                            className="w-full h-full object-cover"
+                          />
+                        ) : (
+                          <div className="flex flex-col items-center justify-center text-gray-400">
+                            {getFileIcon(currentFiles[0].type)}
+                            <span className="text-xs mt-1 text-center">{currentFiles[0].name}</span>
+                          </div>
+                        )}
+                      </div>
+                    ) : (
+                      <div className="flex flex-col items-center justify-center text-gray-400">
+                        <FileIcon className="h-8 w-8 mb-1" />
+                        <span className="text-xs">{placeholder}</span>
+                      </div>
+                    )}
+                  </div>
+                  
+                  {currentFiles[0] && (
+                    <button
+                      type="button"
+                      className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
+                      onClick={handleRemoveFile}
+                    >
+                      <X className="h-3 w-3" />
+                    </button>
+                  )}
+                </div>
+              ) : (
+                // 没有选中文件时的占位符
+                <div
+                  className={cn(
+                    getPreviewSize(),
+                    "border-2 border-dashed cursor-pointer hover:border-primary transition-colors rounded-lg overflow-hidden flex items-center justify-center bg-gray-100"
+                  )}
+                  onClick={() => setIsOpen(true)}
+                >
+                  <div className="flex flex-col items-center justify-center text-gray-400">
+                    <FileIcon className="h-8 w-8 mb-1" />
+                    <span className="text-xs">{placeholder}</span>
+                  </div>
+                </div>
+              )}
+            </div>
+            
+            <div className="space-y-2">
+              <Button
+                type="button"
+                variant="outline"
+                onClick={() => setIsOpen(true)}
+                className="text-sm"
+              >
+                {((allowMultiple && currentFiles && currentFiles.length > 0) ||
+                  (!allowMultiple && currentFiles && currentFiles.length > 0)) ? '更换文件' : placeholder}
+              </Button>
+              {!allowMultiple && currentFiles && currentFiles.length > 0 && (
+                <p className="text-xs text-muted-foreground truncate w-40 sm:w-64">
+                  当前: {currentFiles[0].name}
+                </p>
+              )}
+              {allowMultiple && currentFiles && currentFiles.length > 0 && (
+                <p className="text-xs text-muted-foreground">
+                  已选择 {currentFiles.length} 个文件
+                </p>
+              )}
+            </div>
+          </div>
+        )}
+
+        {!showPreview && (
+          <Button 
+            type="button" 
+            variant="outline" 
+            onClick={() => setIsOpen(true)}
+            className="w-full"
+          >
+            {currentFiles ? '更换文件' : placeholder}
+          </Button>
+        )}
+      </div>
+
+      <Dialog open={isOpen} onOpenChange={setIsOpen}>
+        <DialogContent className="max-w-4xl max-h-[90vh]">
+          <DialogHeader>
+            <DialogTitle>{title}</DialogTitle>
+            <DialogDescription>
+              {description}
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            {/* 文件列表 */}
+            <div className="space-y-2 max-h-96 overflow-y-auto p-1">
+              {isLoading ? (
+                <Card>
+                  <CardContent className="text-center py-8">
+                    <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+                    <p className="text-gray-500 mt-2">加载中...</p>
+                  </CardContent>
+                </Card>
+              ) : (
+                <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
+                  {/* 上传区域 - 作为第一项 */}
+                  <div className="relative cursor-pointer transition-all duration-200">
+                    <div className="rounded-lg border-2 border-dashed border-gray-300 hover:border-primary transition-colors hover:scale-105">
+                      <div className="p-2 h-20 flex items-center justify-center">
+                        <MinioUploader
+                          uploadPath={uploadPath}
+                          accept={accept}
+                          maxSize={maxSize}
+                          onUploadSuccess={handleUploadSuccess}
+                          buttonText="上传"
+                          size="minimal"
+                          displayMode="card"
+                          showUploadList={false}
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-center mt-1 text-muted-foreground">
+                      上传新文件
+                    </p>
+                  </div>
+
+                  {/* 现有文件列表 */}
+                  {files.map((file) => (
+                    <div
+                      key={file.id}
+                      className={cn(
+                        "relative cursor-pointer transition-all duration-200",
+                        "hover:scale-105"
+                      )}
+                      onClick={() => handleSelectFile(file)}
+                    >
+                      <div
+                        className={cn(
+                          "relative rounded-lg overflow-hidden border-2 aspect-square",
+                          isSelected(file.id)
+                            ? "border-primary ring-2 ring-primary ring-offset-2"
+                            : "border-gray-200 hover:border-primary"
+                        )}
+                      >
+                        {file?.type?.startsWith('image/') ? (
+                          <img
+                            src={file.fullUrl}
+                            alt={file.name}
+                            className="w-full h-full object-cover"
+                          />
+                        ) : (
+                          <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 p-2">
+                            {file.type && getFileIcon(file.type)}
+                            <p className="text-xs text-center mt-1 truncate max-w-full">
+                              {file.name}
+                            </p>
+                          </div>
+                        )}
+                        
+                        {isSelected(file.id) && (
+                          <div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
+                            <Check className="h-6 w-6 text-white bg-primary rounded-full p-1" />
+                          </div>
+                        )}
+                        
+                        <div className="absolute top-1 right-1">
+                          <Eye
+                            className="h-4 w-4 text-white bg-black/50 rounded-full p-0.5 cursor-pointer hover:bg-black/70"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              window.open(file.fullUrl, '_blank');
+                            }}
+                          />
+                        </div>
+                      </div>
+                      
+                      <p className="text-xs text-center mt-1 truncate">
+                        {file.name}
+                      </p>
+                    </div>
+                  ))}
+                  
+                  {/* 空状态 - 当没有文件时显示 */}
+                  {files.length === 0 && (
+                    <div className="col-span-full">
+                      <Card>
+                        <CardContent className="text-center py-8">
+                          <div className="flex flex-col items-center">
+                            <Upload className="h-12 w-12 text-gray-400 mb-4" />
+                            <p className="text-gray-600">暂无文件</p>
+                            <p className="text-sm text-gray-500 mt-2">请上传文件</p>
+                          </div>
+                        </CardContent>
+                      </Card>
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
+          </div>
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={handleCancel}>
+              取消
+            </Button>
+            <Button
+              type="button"
+              onClick={handleConfirm}
+              disabled={allowMultiple ? localSelectedFiles.length === 0 : !selectedFile}
+            >
+              {allowMultiple ? `确认选择 (${localSelectedFiles.length})` : '确认选择'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+};
+
+export default FileSelector;

+ 484 - 0
src/client/mobile/components/MinioUploader.tsx

@@ -0,0 +1,484 @@
+import React, { useState, useCallback } from 'react';
+import { Button } from '@/client/components/ui/button';
+import { Card, CardContent } from '@/client/components/ui/card';
+import { Progress } from '@/client/components/ui/progress';
+import { Badge } from '@/client/components/ui/badge';
+import { toast } from 'sonner';
+import { Upload, X, CheckCircle, Loader2, FileText } from 'lucide-react';
+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;
+  /** 上传模式:拖放模式或传统模式 */
+  uploadMode?: 'dragdrop' | 'traditional';
+  /** 是否显示已上传文件列表 */
+  showUploadList?: boolean;
+  /** 已上传文件列表标题 */
+  uploadListTitle?: string;
+  /** 组件尺寸模式 */
+  size?: 'default' | 'compact' | 'minimal';
+  /** 显示模式:卡片模式或完整模式 */
+  displayMode?: 'full' | 'card';
+}
+
+// 定义上传文件状态
+interface UploadFile {
+  uid: string;
+  name: string;
+  size: number;
+  type?: string;
+  status: 'uploading' | 'success' | 'error';
+  percent: number;
+  error?: string;
+  url?: string;
+}
+
+const MinioUploader: React.FC<MinioUploaderProps> = ({
+  uploadPath = '/',
+  accept,
+  maxSize = 500, // 默认最大500MB
+  multiple = false,
+  onUploadSuccess,
+  onUploadError,
+  buttonText = '点击或拖拽上传文件',
+  tipText = '支持单文件或多文件上传,单个文件大小不超过500MB',
+  uploadMode = 'dragdrop',
+  showUploadList = true,
+  uploadListTitle = '上传进度',
+  size = 'default',
+  displayMode = 'full'
+}) => {
+  const [fileList, setFileList] = useState<UploadFile[]>([]);
+  const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
+  const [dragActive, setDragActive] = useState(false);
+
+  // 根据尺寸模式获取样式配置
+  const getSizeConfig = () => {
+    switch (size) {
+      case 'minimal':
+        return {
+          container: 'p-3',
+          icon: 'h-8 w-8',
+          title: 'text-sm',
+          description: 'text-xs',
+          button: 'h-8 px-3 text-xs',
+          spacing: 'space-y-2',
+          fileList: 'space-y-2',
+          cardPadding: 'p-3',
+          progressHeight: 'h-1'
+        };
+      case 'compact':
+        return {
+          container: 'p-4',
+          icon: 'h-10 w-10',
+          title: 'text-base',
+          description: 'text-sm',
+          button: 'h-9 px-4 text-sm',
+          spacing: 'space-y-3',
+          fileList: 'space-y-3',
+          cardPadding: 'p-4',
+          progressHeight: 'h-2'
+        };
+      default:
+        return {
+          container: 'p-6',
+          icon: 'h-12 w-12',
+          title: 'text-lg',
+          description: 'text-sm',
+          button: 'h-10 px-4',
+          spacing: 'space-y-4',
+          fileList: 'space-y-4',
+          cardPadding: 'p-6',
+          progressHeight: 'h-2'
+        };
+    }
+  };
+
+  // 处理上传进度
+  const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: event.stage === 'error' ? 'error' : 'uploading',
+            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',
+            percent: 100,
+            url: result.fileUrl,
+          };
+        }
+        return item;
+      })
+    );
+    
+    setUploadingFiles(prev => {
+      const newSet = new Set(prev);
+      newSet.delete(uid);
+      return newSet;
+    });
+    
+    // toast.success(`文件 "${file.name}" 上传成功`);
+    onUploadSuccess?.(result.fileKey, result.fileUrl, file);
+  }, [onUploadSuccess]);
+
+  // 处理上传失败
+  const handleError = useCallback((uid: string, error: Error, file: File) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: 'error',
+            percent: 0,
+            error: error.message || '上传失败'
+          };
+        }
+        return item;
+      })
+    );
+    
+    setUploadingFiles(prev => {
+      const newSet = new Set(prev);
+      newSet.delete(uid);
+      return newSet;
+    });
+    
+    // toast.error(`文件 "${file.name}" 上传失败: ${error.message}`);
+    onUploadError?.(error, file);
+  }, [onUploadError]);
+
+  // 自定义上传逻辑
+  const handleUpload = async (file: File) => {
+    const uid = Date.now().toString() + Math.random().toString(36).substr(2, 9);
+    
+    // 添加到文件列表
+    setFileList(prev => [
+      ...prev,
+      {
+        uid,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        status: 'uploading',
+        percent: 0,
+      }
+    ]);
+    
+    // 添加到上传中集合
+    setUploadingFiles(prev => new Set(prev).add(uid));
+    
+    try {
+      // 验证文件大小
+      const fileSizeMB = file.size / (1024 * 1024);
+      if (fileSizeMB > maxSize) {
+        throw new Error(`文件大小超过 ${maxSize}MB 限制`);
+      }
+      
+      // 调用minio上传方法
+      const result = await uploadMinIOWithPolicy(
+        uploadPath,
+        file,
+        file.name,
+        {
+          onProgress: (event) => handleProgress(uid, event),
+          signal: new AbortController().signal
+        }
+      );
+      
+      handleComplete(uid, result, file);
+    } catch (error) {
+      handleError(uid, error instanceof Error ? error : new Error('未知错误'), file);
+    }
+  };
+
+  // 处理文件选择
+  const handleFileSelect = (files: FileList) => {
+    if (!files || files.length === 0) return;
+
+    const fileArray = Array.from(files);
+    
+    if (!multiple && fileArray.length > 1) {
+      toast.error('不支持多文件上传');
+      return;
+    }
+
+    fileArray.forEach(file => handleUpload(file));
+  };
+
+  // 处理拖拽
+  const handleDrag = (e: React.DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    
+    if (e.type === 'dragenter' || e.type === 'dragover') {
+      setDragActive(true);
+    } else if (e.type === 'dragleave') {
+      setDragActive(false);
+    }
+  };
+
+  const handleDrop = (e: React.DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    setDragActive(false);
+    
+    const files = e.dataTransfer.files;
+    handleFileSelect(files);
+  };
+
+  // 处理文件移除
+  const handleRemove = (uid: string) => {
+    setFileList(prev => prev.filter(item => item.uid !== uid));
+  };
+
+  // 渲染上传状态
+  const renderUploadStatus = (item: UploadFile) => {
+    switch (item.status) {
+      case 'uploading':
+        return (
+          <div className="flex items-center gap-2">
+            <Loader2 className="h-4 w-4 animate-spin" />
+            <span className="text-sm">{Math.round(item.percent)}%</span>
+          </div>
+        );
+      case 'success':
+        return (
+          <div className="flex items-center gap-2">
+            <CheckCircle className="h-4 w-4 text-green-500" />
+            <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
+              上传成功
+            </Badge>
+          </div>
+        );
+      case 'error':
+        return (
+          <div className="flex items-center gap-2">
+            <div className="h-4 w-4 text-red-500">×</div>
+            <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
+              {item.error || '上传失败'}
+            </Badge>
+          </div>
+        );
+      default:
+        return null;
+    }
+  };
+
+  // 渲染文件图标
+  const renderFileIcon = (type?: string, iconSize: 'small' | 'normal' = 'normal') => {
+    const sizeClass = iconSize === 'small' ? 'h-4 w-4' : 'h-8 w-8';
+    
+    if (type?.startsWith('image/')) {
+      return <FileText className={`${sizeClass} text-blue-500`} />;
+    } else if (type?.startsWith('video/')) {
+      return <FileText className={`${sizeClass} text-red-500`} />;
+    } else if (type?.startsWith('audio/')) {
+      return <FileText className={`${sizeClass} text-purple-500`} />;
+    } else if (type?.includes('pdf')) {
+      return <FileText className={`${sizeClass} text-red-500`} />;
+    } else if (type?.includes('word')) {
+      return <FileText className={`${sizeClass} text-blue-600`} />;
+    } else if (type?.includes('excel') || type?.includes('sheet')) {
+      return <FileText className={`${sizeClass} text-green-500`} />;
+    } else {
+      return <FileText className={`${sizeClass} text-gray-500`} />;
+    }
+  };
+
+  const sizeConfig = getSizeConfig();
+
+  // 卡片模式渲染
+  if (displayMode === 'card') {
+    return (
+      <div className="h-full flex items-center justify-center">
+        <button
+          type="button"
+          className={`flex flex-col items-center justify-center w-full h-full text-muted-foreground hover:text-primary transition-colors cursor-pointer
+            ${size === 'minimal' ? 'text-xs' : 'text-sm'}`}
+          onClick={() => {
+            const input = document.createElement('input');
+            input.type = 'file';
+            input.accept = accept || '';
+            input.multiple = multiple;
+            input.onchange = (e) => {
+              const files = (e.target as HTMLInputElement).files;
+              if (files) handleFileSelect(files);
+            };
+            input.click();
+          }}
+        >
+          <Upload className={`${size === 'minimal' ? 'h-6 w-6 mb-1' : 'h-8 w-8 mb-2'}`} />
+          <span>{buttonText}</span>
+        </button>
+      </div>
+    );
+  }
+
+  return (
+    <div className={sizeConfig.spacing}>
+      {/* 上传区域 - 根据模式显示不同界面 */}
+      {uploadMode === 'dragdrop' ? (
+        <div
+          className={`relative border-2 border-dashed rounded-lg transition-all ${
+            dragActive
+              ? 'border-primary bg-primary/5'
+              : 'border-gray-300 hover:border-primary/50'
+          } ${sizeConfig.container}`}
+          onDragEnter={handleDrag}
+          onDragLeave={handleDrag}
+          onDragOver={handleDrag}
+          onDrop={handleDrop}
+        >
+          <div className={`flex flex-col items-center justify-center ${sizeConfig.spacing}`}>
+            <Upload className={`${sizeConfig.icon} ${dragActive ? 'text-primary' : 'text-gray-400'}`} />
+            <div className="text-center">
+              <p className={`${sizeConfig.title} font-medium`}>{buttonText}</p>
+              {size !== 'minimal' && (
+                <p className={`${sizeConfig.description} text-gray-500 mt-1`}>{tipText}</p>
+              )}
+            </div>
+            <Button
+              type="button"
+              variant="outline"
+              size={size === 'minimal' ? 'sm' : size === 'compact' ? 'sm' : 'default'}
+              onClick={() => {
+                const input = document.createElement('input');
+                input.type = 'file';
+                input.accept = accept || '';
+                input.multiple = multiple;
+                input.onchange = (e) => {
+                  const files = (e.target as HTMLInputElement).files;
+                  if (files) handleFileSelect(files);
+                };
+                input.click();
+              }}
+            >
+              <Upload className="h-4 w-4 mr-2" />
+              选择文件
+            </Button>
+          </div>
+        </div>
+      ) : (
+        <Card>
+          <CardContent className={sizeConfig.cardPadding}>
+            <div className={`flex flex-col items-center justify-center ${sizeConfig.spacing}`}>
+              <Upload className={`${sizeConfig.icon} text-gray-400`} />
+              <div className="text-center">
+                <p className={`${sizeConfig.title} font-medium`}>{buttonText}</p>
+                {size !== 'minimal' && (
+                  <p className={`${sizeConfig.description} text-gray-500 mt-1`}>{tipText}</p>
+                )}
+              </div>
+              <Button
+                type="button"
+                variant="outline"
+                size={size === 'minimal' ? 'sm' : size === 'compact' ? 'sm' : 'default'}
+                onClick={() => {
+                  const input = document.createElement('input');
+                  input.type = 'file';
+                  input.accept = accept || '';
+                  input.multiple = multiple;
+                  input.onchange = (e) => {
+                    const files = (e.target as HTMLInputElement).files;
+                    if (files) handleFileSelect(files);
+                  };
+                  input.click();
+                }}
+              >
+                <Upload className="h-4 w-4 mr-2" />
+                选择文件
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* 上传进度列表 */}
+      {showUploadList && fileList.length > 0 && (
+        <Card>
+          <CardContent className={sizeConfig.cardPadding}>
+            <h3 className={`${sizeConfig.title} font-semibold mb-3`}>{uploadListTitle}</h3>
+            <div className={sizeConfig.fileList}>
+              {fileList.map(item => (
+                <div key={item.uid} className={`flex items-center space-x-3 p-3 border rounded-lg ${size === 'minimal' ? 'text-sm' : ''}`}>
+                  <div className="flex-shrink-0">
+                    {renderFileIcon(item.type, size === 'minimal' ? 'small' : 'normal')}
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <div className="flex justify-between items-center mb-1">
+                      <p className={`${size === 'minimal' ? 'text-xs' : 'text-sm'} font-medium truncate`}>{item.name}</p>
+                      <div className="flex items-center space-x-1">
+                        {renderUploadStatus(item)}
+                        <Button
+                          variant="ghost"
+                          size={size === 'minimal' ? 'icon' : 'sm'}
+                          onClick={() => handleRemove(item.uid)}
+                          disabled={item.status === 'uploading'}
+                          className={size === 'minimal' ? 'h-6 w-6' : ''}
+                        >
+                          <X className={size === 'minimal' ? 'h-3 w-3' : 'h-4 w-4'} />
+                        </Button>
+                      </div>
+                    </div>
+                    {item.status === 'uploading' && (
+                      <div className="space-y-1">
+                        <Progress value={item.percent} className={sizeConfig.progressHeight} />
+                        {size !== 'minimal' && (
+                          <p className={`${sizeConfig.description} text-gray-500`}>
+                            {Math.round(item.percent)}% - {formatFileSize(item.size * (item.percent / 100))} / {formatFileSize(item.size)}
+                          </p>
+                        )}
+                      </div>
+                    )}
+                  </div>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+    </div>
+  );
+};
+
+// 辅助函数:格式化文件大小
+const formatFileSize = (bytes: number): string => {
+  if (bytes === 0) return '0 Bytes';
+  const k = 1024;
+  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+};
+
+export default MinioUploader;