casualroom/apps/fenghuo/web/components/common/file-upload.tsx

517 lines
21 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'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<FileUploadItem[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<string, string> = {
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<FileUploadItem>) => {
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<HTMLInputElement>) => {
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 (
<div className={cn('w-full', className)}>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
className="hidden"
disabled={disabled}
/>
{/* 上传区域 */}
<div
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'relative border-2 border-dashed rounded-lg p-8 transition-all',
'flex flex-col items-center justify-center min-h-[120px]',
{
'border-gray-300 hover:border-gray-400': !disabled && !isDragOver,
'border-blue-400 bg-blue-50': isDragOver && !disabled,
'border-gray-200 bg-gray-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}
)}
>
<Upload className={cn('w-8 h-8 mb-3', {
'text-gray-400': disabled,
'text-gray-500': !disabled && !isDragOver,
'text-blue-500': isDragOver && !disabled,
})} />
<p className={cn('text-sm font-medium mb-1', {
'text-gray-400': disabled,
'text-gray-700': !disabled,
})}>
{placeholder}
</p>
<p className={cn('text-xs', {
'text-gray-300': disabled,
'text-gray-500': !disabled,
})}>
{description}
</p>
</div>
{/* 上传列表 */}
{uploadItems.length > 0 && (
<div className="mt-4 space-y-3 max-h-[140px] overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{uploadItems.map((item) => (
<div key={item.id} className="border rounded-lg p-3 bg-white shadow-sm">
{/* 文件信息头部 */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<File className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate mb-1" title={item.file.name}>
{item.file.name}
</div>
<div className="text-xs text-gray-500">
{formatFileSize(item.file.size)}
</div>
</div>
</div>
{/* 状态图标和操作按钮 */}
<div className="flex items-center space-x-1 flex-shrink-0">
{item.status === 'uploading' && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => pauseUpload(item.id)}
>
<Pause className="w-3 h-3" />
</Button>
)}
{item.status === 'paused' && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => resumeUpload(item.id)}
>
<Play className="w-3 h-3" />
</Button>
)}
{item.status === 'success' && (
<>
<Check className="w-3.5 h-3.5 text-green-500" />
{item.uploadUrl && (
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => copyDownloadLink(item.uploadUrl!)}
title="复制下载链接"
>
<Copy className="w-3 h-3" />
</Button>
)}
</>
)}
{item.status === 'error' && (
<AlertCircle className="w-3.5 h-3.5 text-red-500" />
)}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeUploadItem(item.id)}
>
<X className="w-3 h-3" />
</Button>
</div>
</div>
{/* 进度条 */}
<div className="space-y-1.5">
<Progress value={item.progress} className="h-1.5" />
<div className="flex justify-between items-center text-xs">
<span className={`font-medium ${item.status === 'success' ? 'text-green-600' :
item.status === 'error' ? 'text-red-600' :
item.status === 'uploading' ? 'text-blue-600' :
'text-gray-500'
}`}>
{item.status === 'success' && '完成'}
{item.status === 'uploading' && '上传中'}
{item.status === 'paused' && '已暂停'}
{item.status === 'error' && `错误: ${item.error}`}
</span>
<span className="text-gray-500 font-mono">{item.progress}%</span>
</div>
</div>
{/* 下载链接 */}
{item.uploadUrl && item.status === 'success' && (
<div className="mt-2 pt-2 border-t border-gray-100">
<div className="text-xs text-blue-600 truncate font-mono" title={item.uploadUrl}>
<span className='text-gray-900'></span>
<span>{item.uploadUrl}</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
export default FileUpload;