feat:传输文件优化

This commit is contained in:
MatrixSeven
2025-08-02 23:11:45 +08:00
parent 3a4a762cc9
commit 324408f6b2
22 changed files with 651 additions and 172 deletions

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
console.log('API Route: Getting room info, proxying to:', `${GO_BACKEND_URL}/api/room-info?code=${code}`);
const response = await fetch(`${GO_BACKEND_URL}/api/room-info?code=${code}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
console.log('Backend response:', response.status, data);
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('API Route Error:', error);
return NextResponse.json(
{ error: 'Failed to get room info', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useWebRTCTransfer } from '@/hooks/useWebRTCTransfer';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { WebRTCFileReceive } from '@/components/webrtc/WebRTCFileReceive';
@@ -21,11 +21,17 @@ interface FileInfo {
export const WebRTCFileTransfer: React.FC = () => {
const searchParams = useSearchParams();
const router = useRouter();
const { showToast } = useToast();
// 独立的文件状态
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [fileList, setFileList] = useState<FileInfo[]>([]);
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
const [currentTransferFile, setCurrentTransferFile] = useState<{
fileId: string;
fileName: string;
progress: number;
} | null>(null);
// 房间状态
const [pickupCode, setPickupCode] = useState('');
@@ -34,9 +40,8 @@ export const WebRTCFileTransfer: React.FC = () => {
const {
isConnected,
isTransferring,
isConnecting,
isWebSocketConnected,
transferProgress,
error,
connect,
disconnect,
@@ -45,7 +50,8 @@ export const WebRTCFileTransfer: React.FC = () => {
requestFile: requestFileFromHook,
onFileReceived,
onFileListReceived,
onFileRequested
onFileRequested,
onFileProgress
} = useWebRTCTransfer();
// 从URL参数中获取初始模式
@@ -112,11 +118,7 @@ export const WebRTCFileTransfer: React.FC = () => {
// 创建房间 (发送模式)
const generateCode = async () => {
if (selectedFiles.length === 0) {
toast({
title: "请先选择文件",
description: "需要选择文件才能创建传输房间",
variant: "destructive",
});
showToast("需要选择文件才能创建传输房间", "error");
return;
}
@@ -154,17 +156,10 @@ export const WebRTCFileTransfer: React.FC = () => {
// 连接WebRTC作为发送方
connect(code, 'sender');
toast({
title: "房间创建成功",
description: `取件码: ${code}`,
});
showToast(`房间创建成功,取件码: ${code}`, "success");
} catch (error) {
console.error('创建房间失败:', error);
toast({
title: "创建房间失败",
description: error instanceof Error ? error.message : '网络错误,请重试',
variant: "destructive",
});
showToast(error instanceof Error ? error.message : '网络错误,请重试', "error");
}
};
@@ -176,10 +171,27 @@ export const WebRTCFileTransfer: React.FC = () => {
setPickupCode(code.trim());
connect(code.trim(), 'receiver');
toast({
title: "正在连接...",
description: `尝试连接到房间: ${code}`,
});
showToast(`正在连接到房间: ${code}`, "info");
};
// 重置连接状态 (用于连接失败后重新输入)
const resetConnection = () => {
console.log('=== 重置连接状态 ===');
// 断开当前连接
disconnect();
// 清空状态
setPickupCode('');
setFileList([]);
setDownloadedFiles(new Map());
// 如果是接收模式更新URL移除code参数
if (mode === 'receive') {
const params = new URLSearchParams(searchParams.toString());
params.delete('code');
router.push(`?${params.toString()}`, { scroll: false });
}
};
// 处理文件列表更新
@@ -197,6 +209,17 @@ export const WebRTCFileTransfer: React.FC = () => {
return cleanup;
}, [onFileListReceived, mode]);
// 处理连接错误
useEffect(() => {
if (error && mode === 'receive') {
console.log('=== 连接错误处理 ===');
console.log('错误信息:', error);
// 显示错误提示
showToast(`连接失败: ${error}`, "error");
}
}, [error, mode, showToast]);
// 处理文件接收
useEffect(() => {
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
@@ -213,31 +236,51 @@ export const WebRTCFileTransfer: React.FC = () => {
: item
));
toast({
title: "文件下载完成",
description: `${fileData.file.name} 已准备好下载`,
});
showToast(`${fileData.file.name} 已准备好下载`, "success");
});
return cleanup;
}, [onFileReceived]);
}, [onFileReceived, showToast]);
// 实时更新传输进度
// 监听文件级别的进度更新
useEffect(() => {
console.log('=== 进度更新 ===');
console.log('传输中:', isTransferring, '进度:', transferProgress);
if (isTransferring && transferProgress > 0) {
console.log('更新文件传输进度:', transferProgress);
const cleanup = onFileProgress((progressInfo) => {
console.log('=== 文件进度更新 ===');
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
// 更新当前传输文件信息
setCurrentTransferFile({
fileId: progressInfo.fileId,
fileName: progressInfo.fileName,
progress: progressInfo.progress
});
// 更新文件列表中对应文件的进度
setFileList(prev => prev.map(item => {
if (item.status === 'downloading') {
console.log(`更新文件 ${item.name} 进度从 ${item.progress}${transferProgress}`);
return { ...item, progress: transferProgress };
if (item.id === progressInfo.fileId || item.name === progressInfo.fileName) {
const newProgress = progressInfo.progress;
const newStatus = newProgress >= 100 ? 'completed' as const : 'downloading' as const;
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${newProgress}`);
return { ...item, progress: newProgress, status: newStatus };
}
return item;
}));
}
}, [isTransferring, transferProgress]);
// 当传输完成时显示提示
if (progressInfo.progress >= 100 && mode === 'send') {
showToast(`文件发送完成: ${progressInfo.fileName}`, "success");
setCurrentTransferFile(null);
}
});
return cleanup;
}, [onFileProgress, mode, showToast]);
// 实时更新传输进度(旧逻辑 - 删除)
// useEffect(() => {
// ...已删除的旧代码...
// }, [...]);
// 处理文件请求(发送方监听)
useEffect(() => {
@@ -254,25 +297,31 @@ export const WebRTCFileTransfer: React.FC = () => {
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
toast({
title: "文件不存在",
description: `无法找到文件: ${fileName}`,
variant: "destructive",
});
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading
setFileList(prev => prev.map(item =>
item.id === fileId || item.name === fileName
? { ...item, status: 'downloading' as const, progress: 0 }
: item
));
// 发送文件
sendFile(file, fileId);
// 显示开始传输的提示
showToast(`开始发送文件: ${fileName}`, "info");
} else {
console.warn('接收模式下收到文件请求,忽略');
}
});
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, toast]);
}, [onFileRequested, mode, selectedFiles, sendFile, showToast]);
// 连接状态变化时同步文件列表
useEffect(() => {
@@ -328,7 +377,7 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('=== 开始请求文件 ===');
console.log('文件信息:', { name: fileInfo.name, id: fileId, size: fileInfo.size });
console.log('当前文件状态:', fileInfo.status);
console.log('WebRTC连接状态:', { isConnected, isTransferring });
console.log('WebRTC连接状态:', { isConnected, isTransferring: !!currentTransferFile });
// 更新文件状态为下载中
setFileList(prev => {
@@ -345,29 +394,20 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('调用hook的requestFile...');
requestFileFromHook(fileId, fileInfo.name);
toast({
title: "请求文件",
description: `正在请求文件: ${fileInfo.name}`,
});
showToast(`正在请求文件: ${fileInfo.name}`, "info");
};
// 复制取件码
const copyCode = () => {
navigator.clipboard.writeText(pickupCode);
toast({
title: "取件码已复制",
description: "取件码已复制到剪贴板",
});
showToast("取件码已复制到剪贴板", "success");
};
// 复制链接
const copyLink = () => {
const link = `${window.location.origin}?type=webrtc&mode=receive&code=${pickupCode}`;
navigator.clipboard.writeText(link);
toast({
title: "取件链接已复制",
description: "取件链接已复制到剪贴板",
});
showToast("取件链接已复制到剪贴板", "success");
};
// 重置状态
@@ -409,10 +449,7 @@ export const WebRTCFileTransfer: React.FC = () => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "文件已保存",
description: `${file.name} 已保存到下载文件夹`,
});
showToast(`${file.name} 已保存到下载文件夹`, "success");
};
// 处理下载请求(接收模式)
@@ -432,13 +469,9 @@ export const WebRTCFileTransfer: React.FC = () => {
// 显示错误信息
useEffect(() => {
if (error) {
toast({
title: "连接错误",
description: error,
variant: "destructive",
});
showToast(error, "error");
}
}, [error]);
}, [error, showToast]);
return (
<div className="space-y-4 sm:space-y-6">
@@ -468,6 +501,7 @@ export const WebRTCFileTransfer: React.FC = () => {
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<WebRTCFileUpload
selectedFiles={selectedFiles}
fileList={fileList}
onFilesChange={setSelectedFiles}
onGenerateCode={generateCode}
pickupCode={pickupCode}
@@ -478,7 +512,7 @@ export const WebRTCFileTransfer: React.FC = () => {
onRemoveFile={setSelectedFiles}
onClearFiles={clearFiles}
onReset={resetRoom}
disabled={isTransferring}
disabled={!!currentTransferFile}
isConnected={isConnected}
isWebSocketConnected={isWebSocketConnected}
/>
@@ -489,12 +523,12 @@ export const WebRTCFileTransfer: React.FC = () => {
onJoinRoom={joinRoom}
files={fileList}
onDownloadFile={handleDownloadRequest}
transferProgress={transferProgress}
isTransferring={isTransferring}
isConnected={isConnected}
isConnecting={!!pickupCode && !isConnected}
isConnecting={isConnecting}
isWebSocketConnected={isWebSocketConnected}
downloadedFiles={downloadedFiles}
error={error}
onReset={resetConnection}
/>
</div>
)}

View File

@@ -4,6 +4,7 @@ import React, { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
interface FileInfo {
id: string;
@@ -34,31 +35,76 @@ interface WebRTCFileReceiveProps {
onJoinRoom: (code: string) => void;
files: FileInfo[];
onDownloadFile: (fileId: string) => void;
transferProgress: number;
isTransferring: boolean;
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected?: boolean;
downloadedFiles?: Map<string, File>;
error?: string | null;
onReset?: () => void;
}
export function WebRTCFileReceive({
onJoinRoom,
files,
onDownloadFile,
transferProgress,
isTransferring,
isConnected,
isConnecting,
isWebSocketConnected = false,
downloadedFiles
downloadedFiles,
error = null,
onReset
}: WebRTCFileReceiveProps) {
const [pickupCode, setPickupCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const { showToast } = useToast();
const handleSubmit = useCallback((e: React.FormEvent) => {
// 验证取件码是否存在
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) {
const errorMessage = data.message || '取件码验证失败';
// 显示toast错误提示
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) {
onJoinRoom(pickupCode.toUpperCase());
const code = pickupCode.toUpperCase();
// 先验证取件码是否存在
const isValid = await validatePickupCode(code);
if (isValid) {
// 验证成功后再进行WebRTC连接
onJoinRoom(code);
}
}
}, [pickupCode, onJoinRoom]);
@@ -69,6 +115,19 @@ export function WebRTCFileReceive({
}
}, []);
// 当验证失败时重置输入状态
React.useEffect(() => {
if (error && !isConnecting && !isConnected && !isValidating) {
// 延迟重置,确保用户能看到错误信息
const timer = setTimeout(() => {
console.log('重置取件码输入');
setPickupCode('');
}, 3000); // 3秒后重置
return () => clearTimeout(timer);
}
}, [error, isConnecting, isConnected, isValidating]);
// 如果已经连接但没有文件,显示等待界面
if ((isConnected || isConnecting) && files.length === 0) {
return (
@@ -237,15 +296,13 @@ export function WebRTCFileReceive({
const isDownloading = file.status === 'downloading';
const isCompleted = file.status === 'completed';
const hasDownloadedFile = downloadedFiles?.has(file.id);
const currentProgress = isDownloading && isTransferring ? transferProgress : file.progress;
const currentProgress = file.progress;
console.log('文件状态:', {
fileName: file.name,
status: file.status,
progress: file.progress,
isDownloading,
isTransferring,
transferProgress,
currentProgress
});
@@ -262,24 +319,24 @@ export function WebRTCFileReceive({
{hasDownloadedFile && (
<p className="text-xs text-emerald-600 font-medium"> </p>
)}
{isDownloading && isTransferring && (
{isDownloading && (
<p className="text-xs text-blue-600 font-medium"> ...{currentProgress.toFixed(1)}%</p>
)}
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!isConnected || (isDownloading && isTransferring)}
disabled={!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 && isTransferring)
: 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 && isTransferring) ? '传输中...' : hasDownloadedFile ? '保存文件' : '开始传输'}
{isDownloading ? '传输中...' : hasDownloadedFile ? '保存文件' : '开始传输'}
</Button>
</div>
@@ -387,7 +444,7 @@ export function WebRTCFileReceive({
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={isConnecting}
disabled={isValidating || 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) => (
@@ -411,9 +468,14 @@ export function WebRTCFileReceive({
<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 || isConnecting}
disabled={pickupCode.length !== 6 || isValidating || isConnecting}
>
{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>
) : 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>

View File

@@ -2,7 +2,7 @@
import React, { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
interface FileInfo {
@@ -32,6 +32,7 @@ const formatFileSize = (bytes: number): string => {
interface WebRTCFileUploadProps {
selectedFiles: File[];
fileList?: FileInfo[]; // 添加文件列表信息(包含状态和进度)
onFilesChange: (files: File[]) => void;
onGenerateCode: () => void;
pickupCode?: string;
@@ -49,6 +50,7 @@ interface WebRTCFileUploadProps {
export function WebRTCFileUpload({
selectedFiles,
fileList = [],
onFilesChange,
onGenerateCode,
pickupCode,
@@ -254,31 +256,77 @@ export function WebRTCFileUpload({
</div>
<div className="space-y-3 mb-4 sm:mb-6">
{selectedFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${index}`}
className="group flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl hover:shadow-md transition-all duration-200"
>
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<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="min-w-0 flex-1">
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<p className="text-xs sm:text-sm text-slate-500">{formatFileSize(file.size)}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
disabled={disabled}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all duration-200 flex-shrink-0 ml-2"
{selectedFiles.map((file, index) => {
// 查找对应的文件信息(包含状态和进度)
const fileInfo = fileList.find(f => f.name === file.name && f.size === file.size);
const isTransferringThisFile = fileInfo?.status === 'downloading';
const currentProgress = fileInfo?.progress || 0;
const fileStatus = fileInfo?.status || 'ready';
return (
<div
key={`${file.name}-${file.size}-${index}`}
className="group bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl hover:shadow-md transition-all duration-200"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<div className="flex items-center justify-between p-3 sm:p-4">
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<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="min-w-0 flex-1">
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<div className="flex items-center space-x-2">
<p className="text-xs sm:text-sm text-slate-500">{formatFileSize(file.size)}</p>
{fileStatus === 'downloading' && (
<div className="flex items-center space-x-1">
<div className="w-1 h-1 bg-orange-500 rounded-full animate-pulse"></div>
<span className="text-xs text-orange-600 font-medium"></span>
</div>
)}
{fileStatus === 'completed' && (
<div className="flex items-center space-x-1">
<div className="w-1 h-1 bg-emerald-500 rounded-full"></div>
<span className="text-xs text-emerald-600 font-medium"></span>
</div>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
disabled={disabled || fileStatus === 'downloading'}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all duration-200 flex-shrink-0 ml-2 disabled:opacity-50"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* 传输进度条 */}
{(fileStatus === 'downloading' || fileStatus === 'completed') && currentProgress > 0 && (
<div className="px-3 sm:px-4 pb-3 sm:pb-4">
<div className="space-y-2">
<div className="flex justify-between text-xs text-slate-600">
<span>{fileStatus === 'downloading' ? '正在发送...' : '发送完成'}</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 ${
fileStatus === 'completed'
? 'bg-gradient-to-r from-emerald-500 to-emerald-600'
: 'bg-gradient-to-r from-orange-500 to-orange-600'
}`}
style={{ width: `${currentProgress}%` }}
></div>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
{/* 操作按钮 */}

View File

@@ -141,6 +141,7 @@ export function useWebRTCTransfer() {
// 回调注册
onFileRequested: fileTransfer.onFileRequested,
onFileReceived: fileTransfer.onFileReceived,
onFileProgress: fileTransfer.onFileProgress,
onFileListReceived,
};
}

View File

@@ -7,6 +7,12 @@ interface FileTransferState {
error: string | null;
}
interface FileProgressInfo {
fileId: string;
fileName: string;
progress: number;
}
interface FileChunk {
fileId: string;
chunkIndex: number;
@@ -21,7 +27,7 @@ interface FileMetadata {
type: string;
}
const CHUNK_SIZE = 16 * 1024; // 16KB chunks
const CHUNK_SIZE = 256 * 1024; // 256KB chunks - 平衡传输效率和内存使用
export function useFileTransfer() {
const [state, setState] = useState<FileTransferState>({
@@ -39,9 +45,17 @@ export function useFileTransfer() {
totalChunks: number;
}>>(new Map());
// 存储当前正在接收的块信息
const expectedChunk = useRef<{
fileId: string;
chunkIndex: number;
totalChunks: number;
} | null>(null);
// 文件请求回调
const fileRequestCallbacks = useRef<Array<(fileId: string, fileName: string) => void>>([]);
const fileReceivedCallbacks = useRef<Array<(fileData: { id: string; file: File }) => void>>([]);
const fileProgressCallbacks = useRef<Array<(progressInfo: FileProgressInfo) => void>>([]);
const updateState = useCallback((updates: Partial<FileTransferState>) => {
setState(prev => ({ ...prev, ...updates }));
@@ -107,27 +121,54 @@ export function useFileTransfer() {
if (event.target?.result && dataChannel.readyState === 'open') {
const arrayBuffer = event.target.result as ArrayBuffer;
// 发送分块数据
const chunkMessage = JSON.stringify({
type: 'file-chunk',
payload: {
fileId,
chunkIndex: sentChunks,
totalChunks
}
});
dataChannel.send(chunkMessage);
dataChannel.send(arrayBuffer);
// 检查缓冲区状态,避免过度缓冲
if (dataChannel.bufferedAmount > 1024 * 1024) { // 1MB 缓冲区限制
console.log('数据通道缓冲区满,等待清空...');
// 等待缓冲区清空后再发送
const waitForBuffer = () => {
if (dataChannel.bufferedAmount < 256 * 1024) { // 等到缓冲区低于 256KB
sendChunkData();
} else {
setTimeout(waitForBuffer, 10);
}
};
waitForBuffer();
} else {
sendChunkData();
}
sentChunks++;
const progress = (sentChunks / totalChunks) * 100;
updateState({ transferProgress: progress });
function sendChunkData() {
// 发送分块数据
const chunkMessage = JSON.stringify({
type: 'file-chunk',
payload: {
fileId,
chunkIndex: sentChunks,
totalChunks
}
});
dataChannel.send(chunkMessage);
dataChannel.send(arrayBuffer);
console.log(`发送进度: ${progress.toFixed(1)}%, 块: ${sentChunks}/${totalChunks}`);
sentChunks++;
const progress = (sentChunks / totalChunks) * 100;
updateState({ transferProgress: progress });
// 短暂延迟,避免阻塞
setTimeout(sendNextChunk, 10);
// 通知文件级别的进度
fileProgressCallbacks.current.forEach(callback => {
callback({
fileId,
fileName: file.name,
progress
});
});
console.log(`发送进度: ${progress.toFixed(1)}%, 块: ${sentChunks}/${totalChunks}, 文件: ${file.name}, 缓冲区: ${dataChannel.bufferedAmount} bytes`);
// 立即发送下一个块,不等待
sendNextChunk();
}
}
};
@@ -179,7 +220,15 @@ export function useFileTransfer() {
case 'file-chunk':
const chunkInfo = message.payload;
console.log(`接收文件块: ${chunkInfo.chunkIndex + 1}/${chunkInfo.totalChunks}`);
const { fileId: chunkFileId, chunkIndex, totalChunks } = chunkInfo;
console.log(`接收文件块信息: ${chunkIndex + 1}/${totalChunks}, 文件ID: ${chunkFileId}`);
// 设置期望的下一个块
expectedChunk.current = {
fileId: chunkFileId,
chunkIndex,
totalChunks
};
break;
case 'file-end':
@@ -229,18 +278,37 @@ export function useFileTransfer() {
const arrayBuffer = event.data;
console.log('收到文件块数据:', arrayBuffer.byteLength, 'bytes');
// 找到最近开始接收的文件(简化逻辑)
for (const [fileId, fileInfo] of receivingFiles.current.entries()) {
if (fileInfo.receivedChunks < fileInfo.totalChunks) {
fileInfo.chunks.push(arrayBuffer);
fileInfo.receivedChunks++;
// 检查是否有期望的块信息
if (expectedChunk.current) {
const { fileId, chunkIndex } = expectedChunk.current;
const fileInfo = receivingFiles.current.get(fileId);
if (fileInfo) {
// 确保chunks数组足够大
if (!fileInfo.chunks[chunkIndex]) {
fileInfo.chunks[chunkIndex] = arrayBuffer;
fileInfo.receivedChunks++;
const progress = (fileInfo.receivedChunks / fileInfo.totalChunks) * 100;
updateState({ transferProgress: progress });
const progress = (fileInfo.receivedChunks / fileInfo.totalChunks) * 100;
updateState({ transferProgress: progress });
console.log(`文件接收进度: ${progress.toFixed(1)}%, 块: ${fileInfo.receivedChunks}/${fileInfo.totalChunks}`);
break;
// 通知文件级别的进度
fileProgressCallbacks.current.forEach(callback => {
callback({
fileId,
fileName: fileInfo.metadata.name,
progress
});
});
console.log(`文件接收进度: ${progress.toFixed(1)}%, 块: ${fileInfo.receivedChunks}/${fileInfo.totalChunks}, 文件: ${fileInfo.metadata.name}`);
}
// 清除期望的块信息
expectedChunk.current = null;
}
} else {
console.warn('收到块数据但没有对应的块信息');
}
}
}, [updateState]);
@@ -288,6 +356,19 @@ export function useFileTransfer() {
};
}, []);
// 注册文件进度回调
const onFileProgress = useCallback((callback: (progressInfo: FileProgressInfo) => void) => {
fileProgressCallbacks.current.push(callback);
// 返回清理函数
return () => {
const index = fileProgressCallbacks.current.indexOf(callback);
if (index > -1) {
fileProgressCallbacks.current.splice(index, 1);
}
};
}, []);
return {
...state,
sendFile,
@@ -295,5 +376,6 @@ export function useFileTransfer() {
handleMessage,
onFileRequested,
onFileReceived,
onFileProgress,
};
}

View File

@@ -1,3 +1,4 @@
import { url } from 'inspector';
import { useState, useRef, useCallback } from 'react';
interface WebRTCConnectionState {
@@ -22,6 +23,7 @@ export function useWebRTCConnection() {
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const STUN_SERVERS = [
{ urls: 'stun:stun.l.google.com:19302' },
@@ -29,29 +31,78 @@ export function useWebRTCConnection() {
{ urls: 'stun:stun2.l.google.com:19302' },
];
// 连接超时时间30秒
const CONNECTION_TIMEOUT = 30000;
const updateState = useCallback((updates: Partial<WebRTCConnectionState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 清理超时定时器
const clearConnectionTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
// 处理连接超时
const handleConnectionTimeout = useCallback(() => {
console.warn('WebRTC连接超时');
updateState({
error: '连接超时,请检查取件码是否正确或稍后重试',
isConnecting: false
});
// 清理连接
if (wsRef.current) {
wsRef.current.close();
}
if (pcRef.current) {
pcRef.current.close();
}
}, [updateState]);
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('=== 开始WebRTC连接 ===');
console.log('房间代码:', roomCode, '角色:', role);
// 清理之前的超时定时器
clearConnectionTimeout();
updateState({ isConnecting: true, error: null });
// 设置连接超时
timeoutRef.current = setTimeout(() => {
handleConnectionTimeout();
}, CONNECTION_TIMEOUT);
try {
// 创建PeerConnection
const pc = new RTCPeerConnection({ iceServers: STUN_SERVERS });
pcRef.current = pc;
// 连接WebSocket信令服务器
const ws = new WebSocket(`ws://localhost:8080/ws/webrtc?room=${roomCode}&role=${role}`);
const ws = new WebSocket(`ws://localhost:8080/ws/webrtc?code=${roomCode}&role=${role}`);
wsRef.current = ws;
// WebSocket事件处理
ws.onopen = () => {
ws.onopen = async () => {
console.log('WebSocket连接已建立');
updateState({ isWebSocketConnected: true });
// 如果是发送方在WebSocket连接建立后创建offer
if (role === 'sender') {
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', payload: offer }));
console.log('已发送offer');
} catch (error) {
console.error('创建offer失败:', error);
updateState({ error: '创建连接失败', isConnecting: false });
}
}
};
ws.onmessage = async (event) => {
@@ -61,19 +112,26 @@ export function useWebRTCConnection() {
switch (message.type) {
case 'offer':
await pc.setRemoteDescription(new RTCSessionDescription(message.offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({ type: 'answer', answer }));
if (message.payload) {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('已发送answer');
}
}
break;
case 'answer':
await pc.setRemoteDescription(new RTCSessionDescription(message.answer));
if (message.payload) {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
}
break;
case 'ice-candidate':
if (message.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
if (message.payload) {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
}
break;
@@ -104,7 +162,7 @@ export function useWebRTCConnection() {
console.log('发送ICE候选:', event.candidate);
ws.send(JSON.stringify({
type: 'ice-candidate',
candidate: event.candidate
payload: event.candidate
}));
}
};
@@ -113,12 +171,19 @@ export function useWebRTCConnection() {
pc.onconnectionstatechange = () => {
console.log('连接状态:', pc.connectionState);
const isConnected = pc.connectionState === 'connected';
if (isConnected) {
// 连接成功,清理超时定时器
clearConnectionTimeout();
}
updateState({
isConnected,
isConnecting: !isConnected && pc.connectionState !== 'failed'
});
if (pc.connectionState === 'failed') {
clearConnectionTimeout();
updateState({ error: '连接失败', isConnecting: false });
}
};
@@ -126,19 +191,27 @@ export function useWebRTCConnection() {
// 如果是发送方,创建数据通道
if (role === 'sender') {
const dataChannel = pc.createDataChannel('fileTransfer', {
ordered: true
ordered: true,
maxPacketLifeTime: undefined,
maxRetransmits: undefined
});
dcRef.current = dataChannel;
// 设置缓冲区管理
dataChannel.bufferedAmountLowThreshold = 256 * 1024; // 256KB
dataChannel.onopen = () => {
console.log('数据通道已打开 (发送方)');
console.log('数据通道配置:', {
id: dataChannel.id,
label: dataChannel.label,
maxPacketLifeTime: dataChannel.maxPacketLifeTime,
maxRetransmits: dataChannel.maxRetransmits,
ordered: dataChannel.ordered,
bufferedAmountLowThreshold: dataChannel.bufferedAmountLowThreshold
});
updateState({ localDataChannel: dataChannel });
};
// 创建offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', offer }));
} else {
// 接收方等待数据通道
pc.ondatachannel = (event) => {
@@ -155,16 +228,20 @@ export function useWebRTCConnection() {
} catch (error) {
console.error('连接失败:', error);
clearConnectionTimeout();
updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false
});
}
}, [updateState]);
}, [updateState, clearConnectionTimeout, handleConnectionTimeout]);
const disconnect = useCallback(() => {
console.log('断开WebRTC连接');
// 清理超时定时器
clearConnectionTimeout();
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
@@ -188,7 +265,7 @@ export function useWebRTCConnection() {
localDataChannel: null,
remoteDataChannel: null,
});
}, []);
}, [clearConnectionTimeout]);
const getDataChannel = useCallback(() => {
return dcRef.current;