import { useState, useCallback, useRef, memo } from 'react' import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline' import * as tus from 'tus-js-client' import { motion, AnimatePresence } from 'framer-motion' import { toast } from 'react-hot-toast' interface FileUploaderProps { endpoint?: string onSuccess?: (url: string) => void onError?: (error: Error) => void maxSize?: number allowedTypes?: string[] placeholder?: string } const FileItem = memo(({ file, progress, onRemove }: { file: File progress?: number onRemove: (name: string) => void }) => ( {file.name} onRemove(file.name)} className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full" aria-label={`Remove ${file.name}`} > {progress !== undefined && ( {progress}% )} )) export default function FileUploader({ endpoint='', onSuccess, onError, maxSize = 100, placeholder = '点击或拖拽文件到这里上传', allowedTypes = ['*/*'] }: FileUploaderProps) { const [isDragging, setIsDragging] = useState(false) const [files, setFiles] = useState([]) const [progress, setProgress] = useState<{ [key: string]: number }>({}) const fileInputRef = useRef(null) const handleError = useCallback((error: Error) => { toast.error(error.message) onError?.(error) }, [onError]) const handleDrag = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() if (e.type === 'dragenter' || e.type === 'dragover') { setIsDragging(true) } else if (e.type === 'dragleave') { setIsDragging(false) } }, []) const validateFile = useCallback((file: File) => { if (file.size > maxSize * 1024 * 1024) { throw new Error(`文件大小不能超过 ${maxSize}MB`) } if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) { throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`) } }, [maxSize, allowedTypes]) const uploadFile = async (file: File) => { try { validateFile(file) const upload = new tus.Upload(file, { endpoint, retryDelays: [0, 3000, 5000, 10000, 20000], metadata: { filename: file.name, filetype: file.type }, onError: handleError, onProgress: (bytesUploaded, bytesTotal) => { const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2) setProgress(prev => ({ ...prev, [file.name]: parseFloat(percentage) })) }, onSuccess: () => { onSuccess?.(upload.url || '') setProgress(prev => { const newProgress = { ...prev } delete newProgress[file.name] return newProgress }) } }) upload.start() } catch (error) { handleError(error as Error) } } const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) const droppedFiles = Array.from(e.dataTransfer.files) setFiles(prev => [...prev, ...droppedFiles]) droppedFiles.forEach(uploadFile) }, []) const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files) { const selectedFiles = Array.from(e.target.files) setFiles(prev => [...prev, ...selectedFiles]) selectedFiles.forEach(uploadFile) } } const removeFile = (fileName: string) => { setFiles(prev => prev.filter(file => file.name !== fileName)) setProgress(prev => { const newProgress = { ...prev } delete newProgress[fileName] return newProgress }) } return ( fileInputRef.current?.click()} aria-label="文件上传区域" > {placeholder} 支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB {files.map(file => ( ))} ) }
{file.name}
{placeholder}
支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB