238 lines
6.1 KiB
TypeScript
238 lines
6.1 KiB
TypeScript
// 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";
|
|
|
|
interface FileUploaderProps {
|
|
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;
|
|
}
|
|
|
|
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;
|