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

517 lines
21 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;