517 lines
21 KiB
TypeScript
Executable File
517 lines
21 KiB
TypeScript
Executable File
'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; |