| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471 |
- 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 [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;
- })
- );
-
-
- // 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;
- })
- );
-
-
- // toast.error(`文件 "${file.name}" 上传失败: ${error.message}`);
- onUploadError?.(error, file);
- }, [onUploadError]);
- // 自定义上传逻辑
- const handleUpload = async (file: File) => {
- const uid = Date.now().toString() + Math.random().toString(36).substring(2, 11);
-
- // 添加到文件列表
- setFileList(prev => [
- ...prev,
- {
- uid,
- name: file.name,
- size: file.size,
- type: file.type,
- status: 'uploading',
- percent: 0,
- }
- ]);
-
-
- 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;
|