|
|
@@ -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;
|