Explorar el Código

✨ feat(admin): 新增头像选择器、Minio上传器及分页组件

- 新增AvatarSelector组件支持头像选择、预览和上传功能
- 新增MinioUploader组件支持多模式文件上传和进度显示
- 新增DataTablePagination组件支持分页导航
- 将ErrorPage和NotFoundPage从Ant Design迁移到shadcn/ui组件
- 优化ProtectedRoute加载状态使用Skeleton组件
yourname hace 6 meses
padre
commit
88e4569342

+ 319 - 0
src/client/admin/components/AvatarSelector.tsx

@@ -0,0 +1,319 @@
+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/admin/components/MinioUploader';
+import { Check, Upload, Eye, X } from 'lucide-react';
+import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
+import { cn } from '@/client/lib/utils';
+import type { InferResponseType } from 'hono/client';
+
+type FileType = InferResponseType<typeof fileClient.$get, 200>['data'][0]
+
+interface AvatarSelectorProps {
+  value?: number | null;
+  onChange: (fileId: number | null) => void;
+  accept?: string;
+  maxSize?: number;
+  uploadPath?: string;
+  uploadButtonText?: string;
+  previewSize?: 'small' | 'medium' | 'large';
+  showPreview?: boolean;
+  placeholder?: string;
+}
+
+const AvatarSelector: React.FC<AvatarSelectorProps> = ({
+  value,
+  onChange,
+  accept = 'image/*',
+  maxSize = 2,
+  uploadPath = '/avatars',
+  uploadButtonText = '上传头像',
+  previewSize = 'medium',
+  showPreview = true,
+  placeholder = '选择头像',
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
+
+  // 获取当前选中的文件详情
+  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 && value && currentFile) {
+      setSelectedFile(currentFile);
+    }
+  }, [isOpen, value, currentFile]);
+
+  // 获取头像列表
+  const { data: filesData, isLoading, refetch } = useQuery({
+    queryKey: ['avatars-for-selection'] as const,
+    queryFn: async () => {
+      const response = await fileClient.$get({
+        query: {
+          page: 1,
+          pageSize: 50,
+          keyword: 'image'
+        }
+      });
+      if (response.status !== 200) throw new Error('获取头像列表失败');
+      return response.json();
+    },
+    enabled: isOpen,
+  });
+
+  const avatars = filesData?.data?.filter((f: any) => f?.type?.startsWith('image/')) || [];
+
+  const handleSelectAvatar = (file: FileType) => {
+    setSelectedFile(prevSelected => {
+      // 如果点击的是已选中的头像,则取消选择
+      if (prevSelected?.id === file.id) {
+        return null;
+      }
+      // 否则选择新的头像
+      return file;
+    });
+  };
+
+  const handleConfirm = () => {
+    if (!selectedFile) {
+      toast.warning('请选择一个头像');
+      return;
+    }
+    onChange(selectedFile.id);
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  const handleCancel = () => {
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  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 handleRemoveAvatar = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    onChange(null as any);
+  };
+
+  return (
+    <>
+      <div className="space-y-4">
+        {showPreview && (
+          <div className="flex items-center space-x-4">
+            <div className="relative group">
+              <Avatar 
+                className={cn(
+                  getPreviewSize(),
+                  "border-2 border-dashed cursor-pointer hover:border-primary transition-colors"
+                )}
+                onClick={() => setIsOpen(true)}
+              >
+                {currentFile ? (
+                  <AvatarImage src={currentFile.fullUrl} alt={currentFile.name} />
+                ) : (
+                  <AvatarFallback className="text-sm">
+                    {placeholder.charAt(0).toUpperCase()}
+                  </AvatarFallback>
+                )}
+              </Avatar>
+              
+              {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={handleRemoveAvatar}
+                >
+                  <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-3xl max-h-[90vh]">
+          <DialogHeader>
+            <DialogTitle>选择头像</DialogTitle>
+            <DialogDescription>
+              上传新头像或从已有头像中选择
+            </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-4 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>
+
+                  {/* 现有头像列表 */}
+                  {avatars.map((file) => (
+                    <div
+                      key={file.id}
+                      className={cn(
+                        "relative cursor-pointer transition-all duration-200",
+                        "hover:scale-105"
+                      )}
+                      onClick={() => handleSelectAvatar(file)}
+                    >
+                      <div
+                        className={cn(
+                          "relative rounded-lg overflow-hidden border-2",
+                          selectedFile?.id === 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-24 object-cover"
+                        />
+                        
+                        {selectedFile?.id === 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>
+                  ))}
+                  
+                  {/* 空状态 - 当没有头像时显示 */}
+                  {avatars.length === 0 && (
+                    <div className="col-span-3">
+                      <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={!selectedFile}
+            >
+              确认选择
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+};
+
+export default AvatarSelector;

+ 124 - 0
src/client/admin/components/DataTablePagination.tsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import {
+  Pagination,
+  PaginationContent,
+  PaginationEllipsis,
+  PaginationItem,
+  PaginationLink,
+  PaginationNext,
+  PaginationPrevious,
+} from '@/client/components/ui/pagination';
+
+interface DataTablePaginationProps {
+  currentPage: number;
+  totalCount: number;
+  pageSize: number;
+  onPageChange: (page: number, pageSize: number) => void;
+}
+
+export const DataTablePagination: React.FC<DataTablePaginationProps> = ({
+  currentPage,
+  totalCount,
+  pageSize,
+  onPageChange,
+}) => {
+  const totalPages = Math.ceil(totalCount / pageSize);
+
+  const getPageNumbers = () => {
+    const pages = [];
+    
+    if (totalPages <= 7) {
+      // 如果总页数小于等于7,显示所有页码
+      for (let i = 1; i <= totalPages; i++) {
+        pages.push(i);
+      }
+    } else {
+      // 显示当前页附近的页码
+      const startPage = Math.max(1, currentPage - 2);
+      const endPage = Math.min(totalPages, currentPage + 2);
+      
+      // 始终显示第一页
+      pages.push(1);
+      
+      // 添加省略号和中间页码
+      if (startPage > 2) {
+        pages.push('...');
+      }
+      
+      for (let i = Math.max(2, startPage); i <= Math.min(totalPages - 1, endPage); i++) {
+        pages.push(i);
+      }
+      
+      if (endPage < totalPages - 1) {
+        pages.push('...');
+      }
+      
+      // 始终显示最后一页
+      pages.push(totalPages);
+    }
+    
+    return pages;
+  };
+
+  const pageNumbers = getPageNumbers();
+
+  return (
+    <div className="flex justify-between items-center mt-4">
+      <Pagination>
+        <PaginationContent>
+          <PaginationItem>
+            <PaginationPrevious
+              href="#"
+              onClick={(e) => {
+                e.preventDefault();
+                if (currentPage > 1) {
+                  onPageChange(currentPage - 1, pageSize);
+                }
+              }}
+              aria-disabled={currentPage <= 1}
+              className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""}
+            />
+          </PaginationItem>
+          
+          {pageNumbers.map((page, index) => {
+            if (page === '...') {
+              return (
+                <PaginationItem key={`ellipsis-${index}`}>
+                  <PaginationEllipsis />
+                </PaginationItem>
+              );
+            }
+            return (
+              <PaginationItem key={page}>
+                <PaginationLink
+                  href="#"
+                  isActive={page === currentPage}
+                  onClick={(e) => {
+                    e.preventDefault();
+                    onPageChange(page as number, pageSize);
+                  }}
+                >
+                  {page}
+                </PaginationLink>
+              </PaginationItem>
+            );
+          })}
+          
+          <PaginationItem>
+            <PaginationNext
+              href="#"
+              onClick={(e) => {
+                e.preventDefault();
+                if (currentPage < totalPages) {
+                  onPageChange(currentPage + 1, pageSize);
+                }
+              }}
+              aria-disabled={currentPage >= totalPages}
+              className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
+            />
+          </PaginationItem>
+        </PaginationContent>
+      </Pagination>
+    </div>
+  );
+};

+ 14 - 14
src/client/admin/components/ErrorPage.tsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import React from 'react';
 import { useRouteError, useNavigate } from 'react-router';
 import { useRouteError, useNavigate } from 'react-router';
-import { Alert, Button } from 'antd';
+import { Alert, AlertDescription, AlertTitle } from '@/client/components/ui/alert';
+import { Button } from '@/client/components/ui/button';
+import { AlertCircle } from 'lucide-react';
 
 
 export const ErrorPage = () => {
 export const ErrorPage = () => {
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -8,30 +10,28 @@ export const ErrorPage = () => {
   const errorMessage = error?.statusText || error?.message || '未知错误';
   const errorMessage = error?.statusText || error?.message || '未知错误';
   
   
   return (
   return (
-    <div className="flex flex-col items-center justify-center flex-grow p-4"
-    >
+    <div className="flex flex-col items-center justify-center flex-grow p-4">
       <div className="max-w-3xl w-full">
       <div className="max-w-3xl w-full">
         <h1 className="text-2xl font-bold mb-4">发生错误</h1>
         <h1 className="text-2xl font-bold mb-4">发生错误</h1>
-        <Alert 
-          type="error"
-          message={error?.message || '未知错误'}
-          description={
-            error?.stack ? (
-              <pre className="text-xs overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded">
+        <Alert variant="destructive" className="mb-4">
+          <AlertCircle className="h-4 w-4" />
+          <AlertTitle>{error?.message || '未知错误'}</AlertTitle>
+          <AlertDescription>
+            {error?.stack ? (
+              <pre className="text-xs overflow-auto p-2 bg-muted rounded mt-2">
                 {error.stack}
                 {error.stack}
               </pre>
               </pre>
-            ) : null
-          }
-          className="mb-4"
-        />
+            ) : null}
+          </AlertDescription>
+        </Alert>
         <div className="flex gap-4">
         <div className="flex gap-4">
           <Button 
           <Button 
-            type="primary" 
             onClick={() => navigate(0)}
             onClick={() => navigate(0)}
           >
           >
             重新加载
             重新加载
           </Button>
           </Button>
           <Button 
           <Button 
+            variant="outline"
             onClick={() => navigate('/admin')}
             onClick={() => navigate('/admin')}
           >
           >
             返回首页
             返回首页

+ 484 - 0
src/client/admin/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;

+ 2 - 3
src/client/admin/components/NotFoundPage.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 import { useNavigate } from 'react-router';
 import { useNavigate } from 'react-router';
-import { Button } from 'antd';
+import { Button } from '@/client/components/ui/button';
 
 
 export const NotFoundPage = () => {
 export const NotFoundPage = () => {
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -9,12 +9,11 @@ export const NotFoundPage = () => {
     <div className="flex flex-col items-center justify-center flex-grow p-4">
     <div className="flex flex-col items-center justify-center flex-grow p-4">
       <div className="max-w-3xl w-full">
       <div className="max-w-3xl w-full">
         <h1 className="text-2xl font-bold mb-4">404 - 页面未找到</h1>
         <h1 className="text-2xl font-bold mb-4">404 - 页面未找到</h1>
-        <p className="mb-6 text-gray-600 dark:text-gray-300">
+        <p className="mb-6 text-muted-foreground">
           您访问的页面不存在或已被移除
           您访问的页面不存在或已被移除
         </p>
         </p>
         <div className="flex gap-4">
         <div className="flex gap-4">
           <Button 
           <Button 
-            type="primary" 
             onClick={() => navigate('/admin')}
             onClick={() => navigate('/admin')}
           >
           >
             返回首页
             返回首页

+ 9 - 8
src/client/admin/components/ProtectedRoute.tsx

@@ -1,12 +1,7 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
-import { 
-  useNavigate,
-} from 'react-router';
+import { useNavigate } from 'react-router';
 import { useAuth } from '../hooks/AuthProvider';
 import { useAuth } from '../hooks/AuthProvider';
-
-
-
-
+import { Skeleton } from '@/client/components/ui/skeleton';
 
 
 export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
 export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
   const { isAuthenticated, isLoading } = useAuth();
   const { isAuthenticated, isLoading } = useAuth();
@@ -23,7 +18,13 @@ export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <div className="flex justify-center items-center h-screen">
       <div className="flex justify-center items-center h-screen">
-        <div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
+        <div className="space-y-2">
+          <Skeleton className="h-12 w-12 rounded-full" />
+          <div className="space-y-2">
+            <Skeleton className="h-4 w-[250px]" />
+            <Skeleton className="h-4 w-[200px]" />
+          </div>
+        </div>
       </div>
       </div>
     );
     );
   }
   }