training_data/apps/web/src/components/common/uploader/FileUploader.tsx

238 lines
6.1 KiB
TypeScript
Raw Normal View History

2025-01-27 22:43:31 +08:00
// FileUploader.tsx
import React, { useRef, memo, useState } from "react";
import {
CloudArrowUpIcon,
XMarkIcon,
DocumentIcon,
ExclamationCircleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "react-hot-toast";
import { useTusUpload } from "@web/src/hooks/useTusUpload";
2025-01-21 19:48:54 +08:00
interface FileUploaderProps {
2025-01-27 22:43:31 +08:00
endpoint?: string;
onSuccess?: (url: string) => void;
onError?: (error: Error) => void;
maxSize?: number;
allowedTypes?: string[];
placeholder?: string;
}
interface FileItemProps {
file: File;
progress?: number;
onRemove: (name: string) => void;
isUploaded: boolean;
2025-01-21 19:48:54 +08:00
}
2025-01-27 22:43:31 +08:00
const FileItem: React.FC<FileItemProps> = memo(
({ file, progress, onRemove, isUploaded }) => (
<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>
{!isUploaded && 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>
)}
{isUploaded && (
<div className="mt-2 flex items-center text-green-500">
<CheckCircleIcon className="w-4 h-4 mr-1" />
<span className="text-xs"></span>
</div>
)}
</div>
</motion.div>
)
);
const FileUploader: React.FC<FileUploaderProps> = ({
endpoint = "",
onSuccess,
onError,
maxSize = 100,
placeholder = "点击或拖拽文件到这里上传",
allowedTypes = ["*/*"],
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<
Array<{ file: File; isUploaded: boolean }>
>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { progress, isUploading, uploadError, handleFileUpload } =
useTusUpload();
const handleError = (error: Error) => {
toast.error(error.message);
onError?.(error);
};
const handleDrag = (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 = (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(", ")}`
);
}
};
const uploadFile = (file: File) => {
try {
validateFile(file);
handleFileUpload(
file,
(upload) => {
onSuccess?.(upload.url || "");
setFiles((prev) =>
prev.map((f) =>
f.file.name === file.name
? { ...f, isUploaded: true }
: f
)
);
},
handleError
);
} catch (error) {
handleError(error as Error);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [
...prev,
...droppedFiles.map((file) => ({ file, isUploaded: false })),
]);
droppedFiles.forEach(uploadFile);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
setFiles((prev) => [
...prev,
...selectedFiles.map((file) => ({ file, isUploaded: false })),
]);
selectedFiles.forEach(uploadFile);
}
};
const removeFile = (fileName: string) => {
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div className="w-full space-y-4">
<div
onClick={handleClick}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`
relative flex flex-col items-center justify-center w-full h-32
border-2 border-dashed rounded-lg cursor-pointer
transition-colors duration-200 ease-in-out
${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500"
}
`}>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(",")}
className="hidden"
/>
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
<p className="text-blue-500 font-medium">
</p>
</div>
)}
</div>
<AnimatePresence>
<div className="space-y-3">
{files.map(({ file, isUploaded }) => (
<FileItem
key={file.name}
file={file}
progress={isUploaded ? 100 : progress}
onRemove={removeFile}
isUploaded={isUploaded}
/>
))}
</div>
</AnimatePresence>
{uploadError && (
<div className="flex items-center text-red-500 text-sm">
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
<span>{uploadError}</span>
</div>
)}
</div>
);
};
export default FileUploader;