211 lines
7.8 KiB
TypeScript
211 lines
7.8 KiB
TypeScript
![]() |
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
|
||
|
}) => (
|
||
|
<motion.div
|
||
|
initial={{ opacity: 0, y: 20 }}
|
||
|
animate={{ opacity: 1, y: 0 }}
|
||
|
exit={{ opacity: 0, x: -20 }}
|
||
|
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200"
|
||
|
>
|
||
|
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||
|
<div className="ml-3 flex-1">
|
||
|
<div className="flex items-center justify-between">
|
||
|
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
|
||
|
<button
|
||
|
onClick={() => 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}`}
|
||
|
>
|
||
|
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||
|
</button>
|
||
|
</div>
|
||
|
{progress !== undefined && (
|
||
|
<div className="mt-2">
|
||
|
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||
|
<motion.div
|
||
|
className="bg-blue-500 h-1.5 rounded-full"
|
||
|
initial={{ width: 0 }}
|
||
|
animate={{ width: `${progress}%` }}
|
||
|
transition={{ duration: 0.3 }}
|
||
|
/>
|
||
|
</div>
|
||
|
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
|
||
|
</div>
|
||
|
)}
|
||
|
</div>
|
||
|
</motion.div>
|
||
|
))
|
||
|
|
||
|
export default function FileUploader({
|
||
|
endpoint='',
|
||
|
onSuccess,
|
||
|
onError,
|
||
|
maxSize = 100,
|
||
|
placeholder = '点击或拖拽文件到这里上传',
|
||
|
allowedTypes = ['*/*']
|
||
|
}: FileUploaderProps) {
|
||
|
const [isDragging, setIsDragging] = useState(false)
|
||
|
const [files, setFiles] = useState<File[]>([])
|
||
|
const [progress, setProgress] = useState<{ [key: string]: number }>({})
|
||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||
|
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 (
|
||
|
<div className="w-full space-y-4">
|
||
|
<motion.div
|
||
|
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
|
||
|
${isDragging
|
||
|
? 'border-blue-500 bg-blue-50/50 ring-4 ring-blue-100'
|
||
|
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
|
||
|
}`}
|
||
|
onDragEnter={handleDrag}
|
||
|
onDragLeave={handleDrag}
|
||
|
onDragOver={handleDrag}
|
||
|
onDrop={handleDrop}
|
||
|
role="button"
|
||
|
tabIndex={0}
|
||
|
onClick={() => fileInputRef.current?.click()}
|
||
|
aria-label="文件上传区域"
|
||
|
>
|
||
|
<input
|
||
|
type="file"
|
||
|
ref={fileInputRef}
|
||
|
className="hidden"
|
||
|
multiple
|
||
|
onChange={handleFileSelect}
|
||
|
accept={allowedTypes.join(',')}
|
||
|
/>
|
||
|
|
||
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||
|
<motion.div
|
||
|
animate={{ y: isDragging ? -10 : 0 }}
|
||
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||
|
>
|
||
|
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
|
||
|
</motion.div>
|
||
|
<div className="text-center">
|
||
|
<p className="text-gray-500">{placeholder}</p>
|
||
|
</div>
|
||
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||
|
<ExclamationCircleIcon className="w-4 h-4" />
|
||
|
支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB
|
||
|
</p>
|
||
|
</div>
|
||
|
</motion.div>
|
||
|
|
||
|
<AnimatePresence>
|
||
|
<div className="space-y-3">
|
||
|
{files.map(file => (
|
||
|
<FileItem
|
||
|
key={file.name}
|
||
|
file={file}
|
||
|
progress={progress[file.name]}
|
||
|
onRemove={removeFile}
|
||
|
/>
|
||
|
))}
|
||
|
</div>
|
||
|
</AnimatePresence>
|
||
|
</div>
|
||
|
)
|
||
|
}
|