Files
file-transfer-go/chuan-next/src/components/webrtc/WebRTCFileReceive.tsx
MatrixSeven 550be8bcc6 feat:降级
2025-09-15 18:28:16 +08:00

456 lines
19 KiB
TypeScript
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 { ConnectionStatus } from '@/components/ConnectionStatus';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import { useReadConnectState } from '@/hooks/connection/state/useWebConnectStateManager';
import { TransferProgressTracker } from '@/lib/transfer-utils';
import { Archive, Clock, Download, FileText, Image, Music, Video, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: number; // 传输开始时间
}
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5 text-white" />;
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5 text-white" />;
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5 text-white" />;
return <FileText className="w-5 h-5 text-white" />;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
interface WebRTCFileReceiveProps {
onJoinRoom: (code: string) => void;
files: FileInfo[];
onDownloadFile: (fileId: string) => void;
downloadedFiles?: Map<string, File>;
error?: string | null;
onReset?: () => void;
pickupCode?: string;
}
export function WebRTCFileReceive({
onJoinRoom,
files,
onDownloadFile,
downloadedFiles,
error = null,
onReset,
pickupCode: propPickupCode
}: WebRTCFileReceiveProps) {
const [pickupCode, setPickupCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const { showToast } = useToast();
// 用于跟踪传输进度的trackers
const transferTrackers = useRef<Map<string, TransferProgressTracker>>(new Map());
// 使用传入的取件码或本地状态的取件码
const displayPickupCode = propPickupCode || pickupCode;
const { getConnectState } = useReadConnectState();
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
setIsValidating(true);
console.log('开始验证取件码:', code);
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
let errorMessage = data.message || '取件码验证失败';
// 特殊处理房间人数已满的情况
if (data.message?.includes('房间人数已满') || data.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (data.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (data.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
// 显示toast错误提示
showToast(errorMessage, 'error');
console.log('验证失败:', errorMessage);
return false;
}
// 检查房间是否已满
if (data.is_room_full) {
const errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
showToast(errorMessage, 'error');
console.log('房间已满:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
console.error('验证取件码时发生错误:', error);
const errorMessage = '网络错误,请检查连接后重试';
// 显示toast错误提示
showToast(errorMessage, 'error');
return false;
} finally {
setIsValidating(false);
}
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (pickupCode.length === 6) {
const code = pickupCode.toUpperCase();
// 先验证取件码是否存在
const isValid = await validatePickupCode(code);
if (isValid) {
// 验证成功后再进行WebRTC连接
onJoinRoom(code);
}
}
}, [pickupCode, onJoinRoom]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, '');
if (value.length <= 6) {
setPickupCode(value);
}
}, []);
// 当验证失败时重置输入状态
React.useEffect(() => {
if (error && !getConnectState().isConnecting && !getConnectState().isConnected && !isValidating) {
// 延迟重置,确保用户能看到错误信息
const timer = setTimeout(() => {
console.log('重置取件码输入');
setPickupCode('');
}, 3000); // 3秒后重置
return () => clearTimeout(timer);
}
}, [error, getConnectState, isValidating]);
// 如果已经连接但没有文件,显示等待界面
if ((getConnectState().isConnected || getConnectState().isConnecting) && files.length === 0) {
return (
<div>
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
{getConnectState().isWebSocketConnected}
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<ConnectionStatus
currentRoom={displayPickupCode ? { code: displayPickupCode, role: 'receiver' } : null}
/>
<Button
onClick={onReset}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div> <div className="text-center">
{/* 连接状态指示器 */}
<div className="flex items-center justify-center space-x-4 mb-6">
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${getConnectState().isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
<span className={`text-sm font-medium ${getConnectState().isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
{getConnectState().isConnected ? '连接已建立' : '连接中...'}
</span>
</div>
</div>
{/* 等待动画 */}
<div className="flex justify-center space-x-1 mb-6">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.1}s` }}
></div>
))}
</div>
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<p className="text-xs sm:text-sm text-slate-600 text-center">
💡 <span className="font-medium"></span>
</p>
</div>
</div>
</div>
);
}
// 如果已经连接并且有文件列表,显示文件列表
if (files.length > 0) {
return (
<div className="space-y-4 sm:space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={{ code: displayPickupCode, role: 'receiver' }}
/>
</div>
<div>
<div className="space-y-3 sm:space-y-4">
{files.map((file) => {
const isDownloading = file.status === 'downloading';
const isCompleted = file.status === 'completed';
const hasDownloadedFile = downloadedFiles?.has(file.id);
console.log('文件状态:', {
fileName: file.name,
status: file.status,
progress: file.progress,
isDownloading
});
// 计算传输进度信息
let transferInfo = null;
let currentProgress = 0; // 使用稳定的进度值
if (isDownloading && file) {
const fileKey = `${file.name}-${file.size}`;
let tracker = transferTrackers.current.get(fileKey);
// 如果tracker不存在创建一个新的
if (!tracker) {
tracker = new TransferProgressTracker(file.size);
transferTrackers.current.set(fileKey, tracker);
}
// 更新传输进度
const transferredBytes = (file.progress / 100) * file.size;
const progressInfo = tracker.update(transferredBytes);
transferInfo = progressInfo;
currentProgress = progressInfo.percentage; // 使用稳定的百分比
} else {
// 如果不在传输中,使用原始进度值
currentProgress = file.progress;
}
// 清理已完成的tracker
if (file.status === 'completed') {
const fileKey = `${file.name}-${file.size}`;
transferTrackers.current.delete(fileKey);
}
return (
<div key={file.id} className="bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition-all duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-3 gap-3">
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
{getFileIcon(file.type)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<p className="text-sm text-slate-500">{formatFileSize(file.size)}</p>
{hasDownloadedFile && (
<p className="text-xs text-emerald-600 font-medium"> </p>
)}
{isDownloading && (
<div className="space-y-1">
{/* 传输速度和剩余时间信息 */}
{transferInfo && (
<div className="flex items-center space-x-3">
<div className="flex items-center gap-1 text-xs text-blue-600">
<Zap className="w-3 h-3 flex-shrink-0" />
<span className="w-3 font-mono text-right">{transferInfo.speed.displaySpeed}</span>
<span className='w-2'/>
<span className="w-3">{transferInfo.speed.unit}</span>
<span className='w-3'/>
</div>
{transferInfo.remainingTime.seconds < Infinity && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Clock className="w-3 h-3 flex-shrink-0" />
<span></span>
<span className="w-3 font-mono text-right">{transferInfo.remainingTime.display}</span>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!getConnectState().isConnected || isDownloading}
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
hasDownloadedFile
? 'bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white'
: isDownloading
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
: 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white'
}`}
>
<Download className="w-4 h-4 mr-2" />
{isDownloading ? '传输中...' : hasDownloadedFile ? '保存文件' : '开始传输'}
</Button>
</div>
{(isDownloading || isCompleted) && currentProgress > 0 && (
<div className="mt-3 space-y-2">
<div className="flex justify-between text-sm text-slate-600">
<span>
{hasDownloadedFile ? '传输完成' : '正在传输...'}
</span>
<span className="font-medium">{currentProgress.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
hasDownloadedFile
? 'bg-gradient-to-r from-emerald-500 to-emerald-600'
: 'bg-gradient-to-r from-emerald-500 to-teal-500'
}`}
style={{ width: `${currentProgress}%` }}
></div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
// 显示取件码输入界面
return (
<div>
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">6</p>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={null}
/>
</div>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
<div className="space-y-3">
<div className="relative">
<Input
value={pickupCode}
onChange={handleInputChange}
placeholder="请输入取件码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isValidating || getConnectState().isConnecting}
/>
<div className="absolute inset-x-0 -bottom-4 sm:-bottom-6 flex justify-center space-x-1 sm:space-x-2">
{[...Array(6)].map((_, i) => (
<div
key={i}
className={`w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full transition-all duration-200 ${
i < pickupCode.length
? 'bg-emerald-500'
: 'bg-slate-300'
}`}
/>
))}
</div>
</div>
<div className="h-3 sm:h-4"></div>
<p className="text-center text-xs sm:text-sm text-slate-500">
{pickupCode.length}/6
</p>
</div>
<Button
type="submit"
className="w-full h-10 sm:h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
disabled={pickupCode.length !== 6 || isValidating || getConnectState().isConnecting}
>
{isValidating ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
) : getConnectState().isConnecting ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<Download className="w-5 h-5" />
<span></span>
</div>
)}
</Button>
</form>
{/* 使用提示 */}
<div className="mt-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<p className="text-sm text-slate-600 text-center">
💡 <span className="font-medium"></span>24
</p>
</div>
</div>
);
}