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

✨ feat(admin): 新增图片选择器组件和优化文件上传

- 新增 ImageSelector 组件支持图片选择、预览和多选功能
- 重构 MinioUploader 组件支持卡片模式和多种尺寸配置
- 集成 shadcn/ui 组件库替换 antd 上传组件
- 添加拖拽上传、进度显示和错误处理功能
- 优化文件类型图标和状态展示
- 修复文件列表无限循环问题
yourname 7 месяцев назад
Родитель
Сommit
216b54217e

+ 388 - 0
src/client/admin/components/ImageSelector.tsx

@@ -0,0 +1,388 @@
+import React, { useState, useEffect, useRef } 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/admin/components/MinioUploader';
+import { Check, Upload, Eye, X, 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]
+
+interface ImageSelectorProps {
+  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;
+  selectedFiles?: number[];
+  onMultipleSelect?: (fileIds: number[]) => void;
+}
+
+export const ImageSelector: React.FC<ImageSelectorProps> = ({
+  value,
+  onChange,
+  accept = 'image/*',
+  maxSize = 5,
+  uploadPath = '/images',
+  uploadButtonText = '上传图片',
+  previewSize = 'medium',
+  showPreview = true,
+  placeholder = '选择图片',
+  title = '选择图片',
+  description = '上传新图片或从已有图片中选择',
+  filterType = 'image',
+  allowMultiple = false,
+  selectedFiles = [],
+  onMultipleSelect,
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
+  const [localSelectedFiles, setLocalSelectedFiles] = useState<number[]>(selectedFiles);
+  const prevSelectedFilesRef = useRef(selectedFiles);
+
+  // 获取当前选中的文件详情
+  const { data: currentFile } = useQuery({
+    queryKey: ['file-detail', value],
+    queryFn: async () => {
+      if (!value) return null;
+      const response = await fileClient[':id']['$get']({ param: { id: value.toString() } });
+      if (response.status !== 200) throw new Error('获取文件详情失败');
+      return response.json();
+    },
+    enabled: !!value,
+  });
+
+  
+  // 当对话框打开时,设置当前选中的图片
+  useEffect(() => {
+    if (isOpen) {
+      if (allowMultiple) {
+        setLocalSelectedFiles(selectedFiles);
+      } else if (value && currentFile) {
+        setSelectedFile(currentFile);
+      }
+    }
+  }, [isOpen, value, currentFile, allowMultiple]);
+
+
+  // 修复无限循环问题:使用ref比较数组内容
+  useEffect(() => {
+    const areArraysEqual = (a: number[], b: number[]) => {
+      if (a.length !== b.length) return false;
+      const sortedA = [...a].sort();
+      const sortedB = [...b].sort();
+      return sortedA.every((val, index) => val === sortedB[index]);
+    };
+    
+    if (!areArraysEqual(prevSelectedFilesRef.current, selectedFiles)) {
+      setLocalSelectedFiles(selectedFiles);
+      prevSelectedFilesRef.current = selectedFiles;
+    }
+  }, [selectedFiles]);
+
+  // 获取图片列表
+  const { data: filesData, isLoading, refetch } = useQuery({
+    queryKey: ['images-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 images = filesData?.data?.filter((f: any) => {
+    if (filterType === 'all') return true;
+    if (filterType === 'image') return f?.type?.startsWith('image/');
+    return f?.type?.includes(filterType);
+  }) || [];
+
+  const handleSelectImage = (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 (onMultipleSelect) {
+        onMultipleSelect(localSelectedFiles);
+      } else 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);
+    setLocalSelectedFiles(selectedFiles);
+  };
+
+  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 handleRemoveImage = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    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-center space-x-4">
+            <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)}
+              >
+                {currentFile ? (
+                  <img 
+                    src={currentFile.fullUrl} 
+                    alt={currentFile.name} 
+                    className="w-full h-full object-cover"
+                  />
+                ) : (
+                  <div className="flex flex-col items-center justify-center text-gray-400">
+                    <ImageIcon className="h-8 w-8 mb-1" />
+                    <span className="text-xs">{placeholder}</span>
+                  </div>
+                )}
+              </div>
+              
+              {currentFile && (
+                <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={handleRemoveImage}
+                >
+                  <X className="h-3 w-3" />
+                </button>
+              )}
+            </div>
+            
+            <div className="space-y-2">
+              <Button 
+                type="button" 
+                variant="outline" 
+                onClick={() => setIsOpen(true)}
+                className="text-sm"
+              >
+                {currentFile ? '更换图片' : placeholder}
+              </Button>
+              {currentFile && (
+                <p className="text-xs text-muted-foreground truncate w-40 sm:w-64">
+                  当前: {currentFile.name}
+                </p>
+              )}
+            </div>
+          </div>
+        )}
+
+        {!showPreview && (
+          <Button 
+            type="button" 
+            variant="outline" 
+            onClick={() => setIsOpen(true)}
+            className="w-full"
+          >
+            {currentFile ? '更换图片' : 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-24 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>
+
+                  {/* 现有图片列表 */}
+                  {images.map((file) => (
+                    <div
+                      key={file.id}
+                      className={cn(
+                        "relative cursor-pointer transition-all duration-200",
+                        "hover:scale-105"
+                      )}
+                      onClick={() => handleSelectImage(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"
+                        )}
+                      >
+                        <img
+                          src={file.fullUrl}
+                          alt={file.name}
+                          className="w-full h-full object-cover"
+                        />
+                        
+                        {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>
+                  ))}
+                  
+                  {/* 空状态 - 当没有图片时显示 */}
+                  {images.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 ImageSelector;

+ 326 - 102
src/client/admin/components/MinioUploader.tsx

@@ -1,11 +1,10 @@
 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 { RcFile } from 'rc-upload/lib/interface';
-import type { UploadFileStatus } from 'antd/es/upload/interface';
-import type { UploadRequestOption } from 'rc-upload/lib/interface';
+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 {
@@ -25,8 +24,29 @@ interface MinioUploaderProps {
   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 = '/',
@@ -36,11 +56,58 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
   onUploadSuccess,
   onUploadError,
   buttonText = '点击或拖拽上传文件',
-  tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
+  tipText = '支持单文件或多文件上传,单个文件大小不超过500MB',
+  uploadMode = 'dragdrop',
+  showUploadList = true,
+  uploadListTitle = '上传进度',
+  size = 'default',
+  displayMode = 'full'
 }) => {
-  const { message: antdMessage } = App.useApp();
   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) => {
@@ -49,7 +116,7 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
         if (item.uid === uid) {
           return {
             ...item,
-            status: event.stage === 'error' ? ('error' as UploadFileStatus) : ('uploading' as UploadFileStatus),
+            status: event.stage === 'error' ? 'error' : 'uploading',
             percent: event.progress,
             error: event.stage === 'error' ? event.message : undefined
           };
@@ -66,9 +133,8 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
         if (item.uid === uid) {
           return {
             ...item,
-            status: 'success' as UploadFileStatus,
+            status: 'success',
             percent: 100,
-            response: { fileKey: result.fileKey },
             url: result.fileUrl,
           };
         }
@@ -82,9 +148,9 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
       return newSet;
     });
     
-    antdMessage.success(`文件 "${file.name}" 上传成功`);
+    // toast.success(`文件 "${file.name}" 上传成功`);
     onUploadSuccess?.(result.fileKey, result.fileUrl, file);
-  }, [antdMessage, onUploadSuccess]);
+  }, [onUploadSuccess]);
 
   // 处理上传失败
   const handleError = useCallback((uid: string, error: Error, file: File) => {
@@ -93,7 +159,7 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
         if (item.uid === uid) {
           return {
             ...item,
-            status: 'error' as UploadFileStatus,
+            status: 'error',
             percent: 0,
             error: error.message || '上传失败'
           };
@@ -108,27 +174,23 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
       return newSet;
     });
     
-    antdMessage.error(`文件 "${file.name}" 上传失败: ${error.message}`);
+    // toast.error(`文件 "${file.name}" 上传失败: ${error.message}`);
     onUploadError?.(error, file);
-  }, [antdMessage, onUploadError]);
+  }, [onUploadError]);
 
   // 自定义上传逻辑
-  const customRequest = async (options: UploadRequestOption) => {
-    const { file, onSuccess, onError } = options;
-    const rcFile = file as RcFile;
-    const uid = rcFile.uid;
+  const handleUpload = async (file: File) => {
+    const uid = Date.now().toString() + Math.random().toString(36).substr(2, 9);
     
     // 添加到文件列表
     setFileList(prev => [
-      ...prev.filter(item => item.uid !== uid),
+      ...prev,
       {
         uid,
-        name: rcFile.name,
-        size: rcFile.size,
-        type: rcFile.type,
-        lastModified: rcFile.lastModified,
-        lastModifiedDate: new Date(rcFile.lastModified),
-        status: 'uploading' as UploadFileStatus,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        status: 'uploading',
         percent: 0,
       }
     ]);
@@ -137,38 +199,67 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
     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,
-        options.file as unknown as File,
-        rcFile.name,
+        file,
+        file.name,
         {
           onProgress: (event) => handleProgress(uid, event),
           signal: new AbortController().signal
         }
       );
       
-      handleComplete(uid, result, rcFile as unknown as File);
-      onSuccess?.({}, rcFile);
+      handleComplete(uid, result, file);
     } catch (error) {
-      handleError(uid, error instanceof Error ? error : new Error('未知错误'), rcFile as unknown as File);
-      onError?.(error instanceof Error ? error : new Error('未知错误'));
+      handleError(uid, error instanceof Error ? error : new Error('未知错误'), file);
     }
   };
 
-  // 处理文件移除
-  const handleRemove = (uid: string) => {
-    setFileList(prev => prev.filter(item => item.uid !== uid));
+  // 处理文件选择
+  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 beforeUpload = (file: File) => {
-    const fileSizeMB = file.size / (1024 * 1024);
-    if (fileSizeMB > maxSize!) {
-      message.error(`文件 "${file.name}" 大小超过 ${maxSize}MB 限制`);
-      return false;
+  // 处理拖拽
+  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);
     }
-    return true;
+  };
+
+  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));
   };
 
   // 渲染上传状态
@@ -176,85 +267,218 @@ const MinioUploader: React.FC<MinioUploaderProps> = ({
     switch (item.status) {
       case 'uploading':
         return (
-          <Space>
-            <SyncOutlined spin size={12} />
-            <span>{item.percent}%</span>
-          </Space>
+          <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 'done':
+      case 'success':
         return (
-          <Space>
-            <CheckCircleOutlined style={{ color: '#52c41a' }} size={12} />
-            <Tag color="success">上传成功</Tag>
-          </Space>
+          <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 (
-          <Space>
-            <CloseOutlined style={{ color: '#ff4d4f' }} size={12} />
-            <Tag color="error">{item.error || '上传失败'}</Tag>
-          </Space>
+          <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="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 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>
-      </Upload.Dragger>
+      ) : (
+        <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>
+      )}
 
       {/* 上传进度列表 */}
-      {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'}
-                    />
+      {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>
-                {item.status === 'uploading' && (
-                  <Progress 
-                    percent={item.percent}
-                    size="small" 
-                    status={item.percent === 100 ? 'success' : undefined}
-                  />
-                )}
-              </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;

+ 2 - 0
src/client/admin/index.tsx

@@ -10,6 +10,7 @@ import zhCN from 'antd/locale/zh_CN';
 
 import { AuthProvider } from './hooks/AuthProvider';
 import { router } from './routes';
+import { Toaster } from '@/client/components/ui/sonner';
 
 // 配置 dayjs 插件
 dayjs.extend(weekday);
@@ -47,6 +48,7 @@ const App = () => {
           </AuthProvider>
         </AntdApp>
       </ConfigProvider>
+      <Toaster />
     </QueryClientProvider>
   )
 };