'use client'; import React, { useState, useRef, useCallback, useEffect } from 'react'; import { UploadMetadata } from '@/hooks/use-tus-upload'; import { cn } from '@nice/ui/lib/utils'; import { Upload, X, Play, Pause, Check, AlertCircle, File, Copy } from 'lucide-react'; import { toast } from '@nice/ui/components/sonner'; import { Button } from '@nice/ui/components/button'; import { Progress } from '@nice/ui/components/progress'; import { useAuth } from '@/components/providers/auth-provider'; import * as tus from 'tus-js-client'; export interface FileUploadItem { id: string; file: File; progress: number; status: 'waiting' | 'uploading' | 'success' | 'error' | 'paused'; error?: string; uploadUrl?: string; tusUpload?: tus.Upload; } export interface FileUploadProps { className?: string; accept?: string; maxSize?: number; maxFiles?: number; multiple?: boolean; disabled?: boolean; placeholder?: string; description?: string; metadata?: UploadMetadata; maxConcurrentUploads?: number; // 最大并发上传数量 onUploadComplete?: (files: Array<{ file: File; uploadUrl: string }>) => void; onUploadError?: (error: string) => void; } export function FileUpload({ className, accept = '*/*', maxSize = 100 * 1024 * 1024, // 100MB maxFiles = 10, multiple = false, disabled = false, placeholder = '点击上传文件或拖拽文件到此处', description = `支持所有文件类型,单个文件最大 ${Math.round(maxSize / (1024 * 1024))}MB`, metadata = {}, onUploadComplete, onUploadError, }: FileUploadProps) { const [uploadItems, setUploadItems] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); const { user } = useAuth(); const retryDelays = [1000, 3000, 5000]; const validateFile = useCallback((file: File): string | null => { if (file.size > maxSize) { return `文件大小不能超过 ${Math.round(maxSize / (1024 * 1024))}MB`; } return null; }, [maxSize]); const validateFiles = useCallback((files: File[]): { valid: File[]; errors: string[] } => { const valid: File[] = []; const errors: string[] = []; if (!multiple && files.length > 1) { errors.push('只能选择一个文件'); return { valid: [], errors }; } if (files.length > maxFiles) { errors.push(`最多只能选择 ${maxFiles} 个文件`); return { valid: [], errors }; } files.forEach(file => { const error = validateFile(file); if (error) { errors.push(`${file.name}: ${error}`); } else { valid.push(file); } }); return { valid, errors }; }, [multiple, maxFiles, validateFile]); // 构建TUS元数据 const buildTusMetadata = useCallback((file: File, customMetadata: UploadMetadata = {}) => { const metadata: Record = { filename: file.name, filetype: file.type || 'application/octet-stream', filesize: file.size.toString(), }; // 添加用户信息 if (user?.id) { metadata.authorId = user.id; } if (user?.organizationId) { metadata.organizationId = user.organizationId; } // 添加自定义元数据 Object.entries(customMetadata).forEach(([key, value]) => { if (value !== undefined && value !== null) { if (typeof value === 'object') { metadata[key] = JSON.stringify(value); } else { metadata[key] = String(value); } } }); return metadata; }, [user]); const createUploadItem = useCallback((file: File): FileUploadItem => { return { id: `${file.name}-${Date.now()}-${Math.random()}`, file, progress: 0, status: 'waiting', }; }, []); const updateUploadItem = useCallback((id: string, updates: Partial) => { setUploadItems(prev => prev.map(item => item.id === id ? { ...item, ...updates } : item )); }, []); const removeUploadItem = useCallback((id: string) => { setUploadItems(prev => { const itemToRemove = prev.find(item => item.id === id); if (itemToRemove?.tusUpload && (itemToRemove.status === 'uploading' || itemToRemove.status === 'paused')) { itemToRemove.tusUpload.abort(); } return prev.filter(item => item.id !== id); }); }, []); // 创建TUS上传实例 const createTusUpload = useCallback((file: File, itemId: string) => { console.log('🔍 createTusUpload 被调用,文件:', file.name, 'itemId:', itemId); const tusMetadata = buildTusMetadata(file, metadata); const upload = new tus.Upload(file, { endpoint: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/files/`, retryDelays, metadata: tusMetadata, chunkSize: 1024 * 1024, // 1MB 分块大小 onError: (error: Error) => { console.error('上传失败:', error); updateUploadItem(itemId, { status: 'error', error: error.message, }); onUploadError?.(error.message); toast.error(`${file.name} 上传失败: ${error.message}`); }, onProgress: (bytesUploaded: number, bytesTotal: number) => { const progress = Math.round((bytesUploaded / bytesTotal) * 100); updateUploadItem(itemId, { progress }); }, onSuccess: () => { console.log('🔍 TUS上传成功,文件:', file.name, 'itemId:', itemId, 'uploadUrl:', upload.url); updateUploadItem(itemId, { status: 'success', uploadUrl: upload.url || undefined, progress: 100, }); }, onShouldRetry: (err: Error, retryAttempt: number) => { const tusError = err as any; const status = tusError.originalResponse ? tusError.originalResponse.getStatus() : 0; if (status === 401 || status === 403 || status === 413) { return false; } if (retryAttempt >= retryDelays.length) { return false; } return true; }, }); console.log('🔍 TUS上传实例创建完成,准备开始上传:', file.name); return upload; }, [buildTusMetadata, metadata, retryDelays, updateUploadItem, onUploadError]); // 移除旧的队列管理逻辑,现在直接在handleFiles中处理上传 // 检查是否所有文件都完成了 const [hasNotifiedCompletion, setHasNotifiedCompletion] = useState(false); useEffect(() => { const allCompleted = uploadItems.length > 0 && uploadItems.every(item => item.status === 'success' || item.status === 'error' ); if (allCompleted && !hasNotifiedCompletion) { const successfulFiles = uploadItems .filter(item => item.status === 'success' && item.uploadUrl) .map(item => ({ file: item.file, uploadUrl: item.uploadUrl! })); if (successfulFiles.length > 0) { onUploadComplete?.(successfulFiles); toast.success('所有文件上传完成'); setHasNotifiedCompletion(true); } } // 当有新的上传开始时,重置通知状态 if (uploadItems.some(item => item.status === 'waiting' || item.status === 'uploading')) { setHasNotifiedCompletion(false); } }, [uploadItems, onUploadComplete, hasNotifiedCompletion]); // 移除旧的队列管理useEffect,现在直接在handleFiles中处理上传 const handleFiles = useCallback(async (files: File[]) => { console.log('🔍 handleFiles 被调用,文件数量:', files.length, '文件名:', files.map(f => f.name)); const { valid, errors } = validateFiles(files); if (errors.length > 0) { errors.forEach(error => toast.error(error)); return; } if (valid.length === 0) return; // 如果是单选模式,清除之前的上传项 if (!multiple) { setUploadItems(prev => { // 取消所有正在进行的上传 prev.forEach(item => { if (item.tusUpload && (item.status === 'uploading' || item.status === 'paused')) { item.tusUpload.abort(); } }); return []; }); } // 创建上传项并立即开始上传 const newItems = valid.map(file => { const item = createUploadItem(file); console.log('🔍 创建新的上传项:', { id: item.id, fileName: item.file.name }); // 立即创建并开始上传,避免队列管理的复杂性 setTimeout(() => { console.log('🔍 立即开始上传文件:', file.name); const tusUpload = createTusUpload(file, item.id); // 更新状态为上传中 setUploadItems(prev => prev.map(prevItem => prevItem.id === item.id ? { ...prevItem, status: 'uploading' as const, tusUpload } : prevItem )); tusUpload.start(); }, 0); return item; }); setUploadItems(prev => multiple ? [...newItems, ...prev] : newItems); }, [validateFiles, multiple, createUploadItem, createTusUpload]); const handleInputChange = useCallback((e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { handleFiles(files); } // 清空input值,允许重复选择同一文件 e.target.value = ''; }, [handleFiles]); const handleClick = useCallback(() => { if (!disabled) { fileInputRef.current?.click(); } }, [disabled]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); if (!disabled) { setIsDragOver(true); } }, [disabled]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (disabled) return; const files = Array.from(e.dataTransfer.files); if (files.length > 0) { handleFiles(files); } }, [disabled, handleFiles]); const pauseUpload = useCallback((itemId: string) => { const item = uploadItems.find(item => item.id === itemId); if (item && item.tusUpload && item.status === 'uploading') { item.tusUpload.abort(); updateUploadItem(itemId, { status: 'paused' }); } }, [uploadItems, updateUploadItem]); const resumeUpload = useCallback((itemId: string) => { const item = uploadItems.find(item => item.id === itemId); if (item && item.status === 'paused') { // 重新创建上传实例并开始上传 const tusUpload = createTusUpload(item.file, item.id); tusUpload.start(); updateUploadItem(itemId, { status: 'uploading', tusUpload }); } }, [uploadItems, updateUploadItem, createTusUpload]); const formatFileSize = useCallback((bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }, []); const copyDownloadLink = useCallback(async (uploadUrl: string) => { try { await navigator.clipboard.writeText(uploadUrl); toast.success('下载链接已复制到剪贴板'); } catch (error) { console.error('复制失败:', error); toast.error('复制失败,请手动复制链接'); } }, []); return (
{/* 上传区域 */}

{placeholder}

{description}

{/* 上传列表 */} {uploadItems.length > 0 && (
{uploadItems.map((item) => (
{/* 文件信息头部 */}
{item.file.name}
{formatFileSize(item.file.size)}
{/* 状态图标和操作按钮 */}
{item.status === 'uploading' && ( )} {item.status === 'paused' && ( )} {item.status === 'success' && ( <> {item.uploadUrl && ( )} )} {item.status === 'error' && ( )}
{/* 进度条 */}
{item.status === 'success' && '完成'} {item.status === 'uploading' && '上传中'} {item.status === 'paused' && '已暂停'} {item.status === 'error' && `错误: ${item.error}`} {item.progress}%
{/* 下载链接 */} {item.uploadUrl && item.status === 'success' && (
下载链接: {item.uploadUrl}
)}
))}
)}
); } export default FileUpload;