mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-22 14:54:44 +08:00
feat:UI大调整,WEBRTC切换
This commit is contained in:
@@ -1,233 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Upload, MessageSquare, Monitor } from 'lucide-react';
|
||||
import { Upload, MessageSquare, Monitor, Github, ExternalLink } from 'lucide-react';
|
||||
import Hero from '@/components/Hero';
|
||||
import FileTransfer from '@/components/FileTransfer';
|
||||
import TextTransfer from '@/components/TextTransfer';
|
||||
import DesktopShare from '@/components/DesktopShare';
|
||||
import { RoomStatusDisplay } from '@/components/RoomStatusDisplay';
|
||||
import { TabSwitchDialog } from '@/components/TabSwitchDialog';
|
||||
|
||||
// Hooks
|
||||
import { useFileTransfer } from '@/hooks/useFileTransfer';
|
||||
import { useRoomManager } from '@/hooks/useRoomManager';
|
||||
import { useFileSender } from '@/hooks/useFileSender';
|
||||
import { useFileReceiver } from '@/hooks/useFileReceiver';
|
||||
import { useWebSocketHandler } from '@/hooks/useWebSocketHandler';
|
||||
import { useTabManager } from '@/hooks/useTabManager';
|
||||
import { useUtilities } from '@/hooks/useUtilities';
|
||||
import { useUrlHandler } from '@/hooks/useUrlHandler';
|
||||
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function HomePage() {
|
||||
// 文件传输相关
|
||||
const {
|
||||
fileTransfers,
|
||||
transferProgresses,
|
||||
initFileTransfer,
|
||||
receiveFileChunk,
|
||||
completeFileDownload,
|
||||
clearTransfers,
|
||||
setTransferProgresses
|
||||
} = useFileTransfer();
|
||||
|
||||
// 房间管理相关
|
||||
const {
|
||||
selectedFiles,
|
||||
pickupCode,
|
||||
pickupLink,
|
||||
currentRole,
|
||||
receiverFiles,
|
||||
isConnecting,
|
||||
roomStatus,
|
||||
isConnected,
|
||||
websocket,
|
||||
setSelectedFiles,
|
||||
setReceiverFiles,
|
||||
setRoomStatus,
|
||||
setIsConnecting,
|
||||
setCurrentRole,
|
||||
resetConnectingState,
|
||||
generateCode,
|
||||
joinRoom,
|
||||
updateFileList,
|
||||
handleRemoveFile,
|
||||
clearFiles,
|
||||
resetRoom,
|
||||
sendMessage,
|
||||
disconnect,
|
||||
connect
|
||||
} = useRoomManager();
|
||||
|
||||
// Tab管理相关
|
||||
const {
|
||||
activeTab,
|
||||
showConfirmDialog,
|
||||
setShowConfirmDialog,
|
||||
handleTabChange,
|
||||
confirmTabSwitch,
|
||||
cancelTabSwitch,
|
||||
getModeDescription,
|
||||
updateUrlParams
|
||||
} = useTabManager(isConnected, pickupCode, isConnecting);
|
||||
|
||||
// 工具函数
|
||||
const { copyToClipboard, showNotification } = useUtilities();
|
||||
|
||||
// 文件发送处理
|
||||
const { handleFileRequest } = useFileSender(selectedFiles, sendMessage);
|
||||
|
||||
// 文件接收处理
|
||||
const { downloadFile } = useFileReceiver(
|
||||
receiverFiles,
|
||||
transferProgresses,
|
||||
setTransferProgresses,
|
||||
websocket,
|
||||
sendMessage
|
||||
);
|
||||
|
||||
// WebSocket连接状态变化处理
|
||||
useEffect(() => {
|
||||
resetConnectingState();
|
||||
}, [resetConnectingState]);
|
||||
|
||||
// 额外的连接状态重置逻辑
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
setIsConnecting(false);
|
||||
console.log('WebSocket已连接,重置连接中状态');
|
||||
}
|
||||
}, [isConnected, setIsConnecting]);
|
||||
|
||||
// 监听WebSocket错误事件
|
||||
useEffect(() => {
|
||||
const handleWebSocketError = (event: CustomEvent) => {
|
||||
console.error('WebSocket连接错误:', event.detail);
|
||||
setIsConnecting(false);
|
||||
showNotification('连接失败,请检查网络或重试', 'error');
|
||||
};
|
||||
|
||||
const handleWebSocketConnected = (event: CustomEvent) => {
|
||||
console.log('WebSocket连接成功:', event.detail);
|
||||
setIsConnecting(false);
|
||||
showNotification('连接成功!', 'success');
|
||||
};
|
||||
|
||||
window.addEventListener('websocket-error', handleWebSocketError as EventListener);
|
||||
window.addEventListener('websocket-connected', handleWebSocketConnected as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('websocket-error', handleWebSocketError as EventListener);
|
||||
window.removeEventListener('websocket-connected', handleWebSocketConnected as EventListener);
|
||||
};
|
||||
}, [setIsConnecting, showNotification]);
|
||||
|
||||
// WebSocket消息处理
|
||||
useWebSocketHandler({
|
||||
currentRole,
|
||||
setReceiverFiles,
|
||||
setRoomStatus,
|
||||
setIsConnecting,
|
||||
initFileTransfer,
|
||||
receiveFileChunk,
|
||||
completeFileDownload,
|
||||
handleFileRequest
|
||||
});
|
||||
|
||||
// URL参数处理
|
||||
useUrlHandler({
|
||||
isConnected,
|
||||
pickupCode,
|
||||
setCurrentRole,
|
||||
joinRoom
|
||||
});
|
||||
|
||||
// 处理添加更多文件
|
||||
const handleAddMoreFiles = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files || []);
|
||||
const newFiles = [...selectedFiles, ...files];
|
||||
setSelectedFiles(newFiles);
|
||||
|
||||
if (pickupCode && files.length > 0) {
|
||||
updateFileList(newFiles);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
{/* Hero Section */}
|
||||
<div className="relative min-h-screen">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-blue-400/20 to-indigo-600/20 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 sm:px-6 py-8 max-w-6xl">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6 md:py-8">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<Hero />
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/80 backdrop-blur-sm border-0 shadow-lg h-12 sm:h-14 p-1 mb-6">
|
||||
{/* Main Content */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Tabs defaultValue="webrtc" className="w-full">
|
||||
{/* Tabs Navigation - 横向布局 */}
|
||||
<div className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-lg mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsTrigger
|
||||
value="file"
|
||||
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-blue-500 data-[state=active]:to-indigo-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
|
||||
value="webrtc"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
>
|
||||
<Upload className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">传文件</span>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文件传输</span>
|
||||
<span className="sm:hidden">文件</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="text"
|
||||
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-emerald-500 data-[state=active]:to-teal-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">传文字</span>
|
||||
<span className="sm:hidden">文字</span>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文本传输</span>
|
||||
<span className="sm:hidden">文本</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="desktop"
|
||||
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-purple-500 data-[state=active]:to-pink-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600"
|
||||
>
|
||||
<Monitor className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">共享桌面</span>
|
||||
<span className="sm:hidden">桌面</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="file" className="mt-6 animate-fade-in-up">
|
||||
<FileTransfer
|
||||
selectedFiles={selectedFiles}
|
||||
onFilesChange={setSelectedFiles}
|
||||
onGenerateCode={generateCode}
|
||||
pickupCode={pickupCode}
|
||||
pickupLink={pickupLink}
|
||||
onCopyCode={() => copyToClipboard(pickupCode, '取件码已复制到剪贴板!')}
|
||||
onCopyLink={() => copyToClipboard(pickupLink, '取件链接已复制到剪贴板!')}
|
||||
onAddMoreFiles={handleAddMoreFiles}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
onClearFiles={clearFiles}
|
||||
onReset={resetRoom}
|
||||
onJoinRoom={joinRoom}
|
||||
receiverFiles={receiverFiles}
|
||||
onDownloadFile={downloadFile}
|
||||
transferProgresses={transferProgresses}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
|
||||
<RoomStatusDisplay roomStatus={roomStatus} currentRole={currentRole} />
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCFileTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="text" className="mt-6 animate-fade-in-up">
|
||||
<TabsContent value="text" className="mt-0 animate-fade-in-up">
|
||||
<TextTransfer
|
||||
onSendText={async (text: string) => {
|
||||
try {
|
||||
@@ -242,63 +72,40 @@ export default function HomePage() {
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.error || '创建文字传输房间失败';
|
||||
showNotification(errorMessage, 'error');
|
||||
return '';
|
||||
throw new Error(data.error || '创建文本房间失败');
|
||||
}
|
||||
|
||||
return data.code;
|
||||
} catch (error) {
|
||||
console.error('创建文字传输房间失败:', error);
|
||||
showNotification('网络错误,请重试', 'error');
|
||||
return '';
|
||||
console.error('创建文本房间失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onReceiveText={async (code: string) => {
|
||||
console.log('onReceiveText被调用,但文字内容将通过WebSocket获取:', code);
|
||||
return '';
|
||||
}}
|
||||
websocket={websocket}
|
||||
isConnected={isConnected}
|
||||
currentRole={currentRole}
|
||||
onCreateWebSocket={(code: string, role: 'sender' | 'receiver') => {
|
||||
if (websocket) {
|
||||
disconnect();
|
||||
try {
|
||||
const response = await fetch(`/api/get-text-content?code=${code}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '获取文本内容失败');
|
||||
}
|
||||
|
||||
return data.text;
|
||||
} catch (error) {
|
||||
console.error('获取文本内容失败:', error);
|
||||
throw error;
|
||||
}
|
||||
connect(code, role);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="desktop" className="mt-6 animate-fade-in-up">
|
||||
<DesktopShare
|
||||
onStartSharing={async () => {
|
||||
showNotification('桌面共享功能开发中', 'info');
|
||||
return 'DEF456';
|
||||
}}
|
||||
onStopSharing={async () => {
|
||||
showNotification('桌面共享已停止', 'info');
|
||||
}}
|
||||
onJoinSharing={async (code: string) => {
|
||||
showNotification('桌面共享功能开发中', 'info');
|
||||
}}
|
||||
/>
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
<DesktopShare />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="h-8 sm:h-16"></div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<TabSwitchDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
onConfirm={confirmTabSwitch}
|
||||
onCancel={cancelTabSwitch}
|
||||
description={getModeDescription()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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');
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing code parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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');
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing code parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('API Route: Getting room status, proxying to:', `${GO_BACKEND_URL}/api/room-status?code=${code}`);
|
||||
|
||||
const response = await fetch(`${GO_BACKEND_URL}/api/room-status?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 status', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('API Route: Updating files, proxying to:', `${GO_BACKEND_URL}/api/update-files`);
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${GO_BACKEND_URL}/api/update-files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
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 update files', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -133,15 +133,58 @@ export default function DesktopShare({ onStartSharing, onStopSharing, onJoinShar
|
||||
</div>
|
||||
|
||||
{mode === 'share' ? (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-pink-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Share className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
|
||||
<Share 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">
|
||||
{isSharing ? '桌面共享进行中' : '开始共享您的桌面屏幕'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isSharing ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isSharing && connectionCode && (
|
||||
<div className="mt-1 text-xs text-purple-600">
|
||||
{connectionCode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">共享桌面</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">
|
||||
{isSharing ? '桌面共享进行中' : '开始共享您的桌面屏幕'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -202,15 +245,63 @@ export default function DesktopShare({ onStartSharing, onStopSharing, onJoinShar
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Monitor className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor 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">
|
||||
{isViewing ? '正在观看桌面共享' : '输入连接码观看他人的桌面'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isViewing ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : isLoading ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isViewing && (
|
||||
<div className="mt-1 text-xs text-indigo-600">
|
||||
观看中
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">观看桌面</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">
|
||||
{isViewing ? '正在观看桌面共享' : '输入连接码观看他人的桌面'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
|
||||
import { FileInfo, TransferProgress } from '@/types';
|
||||
|
||||
interface FileReceiveProps {
|
||||
onJoinRoom: (code: string) => void;
|
||||
files: FileInfo[];
|
||||
onDownloadFile: (fileId: string) => void;
|
||||
transferProgresses: TransferProgress[];
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
}
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
export function FileReceive({
|
||||
onJoinRoom,
|
||||
files,
|
||||
onDownloadFile,
|
||||
transferProgresses,
|
||||
isConnected,
|
||||
isConnecting
|
||||
}: FileReceiveProps) {
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pickupCode.length === 6) {
|
||||
onJoinRoom(pickupCode.toUpperCase());
|
||||
}
|
||||
}, [pickupCode, onJoinRoom]);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
|
||||
if (value.length <= 6) {
|
||||
setPickupCode(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 如果已经连接但没有文件,显示等待界面
|
||||
if ((isConnected || isConnecting) && files.length === 0) {
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">等待文件</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600 mb-6">
|
||||
{isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'}
|
||||
</p>
|
||||
|
||||
{/* 连接状态指示器 */}
|
||||
<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 ${isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
|
||||
<span className={`text-sm font-medium ${isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
|
||||
{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="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-4">
|
||||
<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 sm:text-xl font-semibold text-slate-800">可下载文件</h3>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{isConnected ? (
|
||||
<span className="text-emerald-600">✅ 已连接,可以下载文件</span>
|
||||
) : (
|
||||
<span className="text-amber-600">⏳ 正在建立连接...</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-emerald-100 to-teal-100 px-3 sm:px-4 py-2 rounded-full self-start sm:self-center">
|
||||
<span className="text-emerald-700 font-medium text-sm">{files.length} 个文件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{files.map((file) => {
|
||||
const progress = transferProgresses.find(p => p.originalFileId === file.id);
|
||||
const isDownloading = progress && progress.status === 'downloading';
|
||||
const isCompleted = progress && progress.status === 'completed';
|
||||
|
||||
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>
|
||||
{isCompleted && (
|
||||
<p className="text-xs text-emerald-600 font-medium">✅ 下载完成</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onDownloadFile(file.id)}
|
||||
disabled={!isConnected || isDownloading || isCompleted}
|
||||
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
|
||||
isCompleted
|
||||
? '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 ? '下载中...' : isCompleted ? '已完成' : '下载'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{progress && (progress.status === 'downloading' || progress.status === 'completed') && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>{progress.status === 'completed' ? '下载完成' : '正在下载...'}</span>
|
||||
<span className="font-medium">{progress.progress.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 ${
|
||||
progress.status === 'completed'
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600'
|
||||
: 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||||
}`}
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>{formatFileSize(progress.receivedSize)} / {formatFileSize(progress.totalSize)}</span>
|
||||
{progress.status === 'downloading' && (
|
||||
<span>预计还需 {Math.ceil((progress.totalSize - progress.receivedSize) / 1024 / 1024)} MB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示取件码输入界面
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">输入取件码</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">请输入6位取件码来获取文件</p>
|
||||
</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={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 || isConnecting}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, Download } from 'lucide-react';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import { FileReceive } from '@/components/FileReceive';
|
||||
import { FileInfo, TransferProgress } from '@/types';
|
||||
|
||||
interface FileTransferProps {
|
||||
// 发送方相关
|
||||
selectedFiles: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
onGenerateCode: () => void;
|
||||
pickupCode: string;
|
||||
pickupLink: string;
|
||||
onCopyCode: () => void;
|
||||
onCopyLink: () => void;
|
||||
onAddMoreFiles: () => void;
|
||||
onRemoveFile: (updatedFiles: File[]) => void;
|
||||
onClearFiles?: () => void;
|
||||
onReset: () => void;
|
||||
|
||||
// 接收方相关
|
||||
onJoinRoom: (code: string) => void;
|
||||
receiverFiles: FileInfo[];
|
||||
onDownloadFile: (fileId: string) => void;
|
||||
transferProgresses: TransferProgress[];
|
||||
|
||||
// 通用状态
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function FileTransfer({
|
||||
selectedFiles,
|
||||
onFilesChange,
|
||||
onGenerateCode,
|
||||
pickupCode,
|
||||
pickupLink,
|
||||
onCopyCode,
|
||||
onCopyLink,
|
||||
onAddMoreFiles,
|
||||
onRemoveFile,
|
||||
onClearFiles,
|
||||
onReset,
|
||||
onJoinRoom,
|
||||
receiverFiles,
|
||||
onDownloadFile,
|
||||
transferProgresses,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
disabled = false
|
||||
}: FileTransferProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'file' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
setMode(urlMode);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'file');
|
||||
params.set('mode', newMode);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
<Button
|
||||
variant={mode === 'send' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('send')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
发送文件
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'receive' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('receive')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
接收文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'send' ? (
|
||||
<div className="animate-fade-in-up">
|
||||
<FileUpload
|
||||
selectedFiles={selectedFiles}
|
||||
onFilesChange={onFilesChange}
|
||||
onGenerateCode={onGenerateCode}
|
||||
pickupCode={pickupCode}
|
||||
pickupLink={pickupLink}
|
||||
onCopyCode={onCopyCode}
|
||||
onCopyLink={onCopyLink}
|
||||
onAddMoreFiles={onAddMoreFiles}
|
||||
onRemoveFile={onRemoveFile}
|
||||
onClearFiles={onClearFiles}
|
||||
onReset={onReset}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-in-up">
|
||||
<FileReceive
|
||||
onJoinRoom={onJoinRoom}
|
||||
files={receiverFiles}
|
||||
onDownloadFile={onDownloadFile}
|
||||
transferProgresses={transferProgresses}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Github } from 'lucide-react';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<div className="text-center mb-8 sm:mb-12 animate-fade-in-up">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-4">
|
||||
<div className="text-center mb-6 animate-fade-in-up">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-2">
|
||||
文件快传
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-slate-600 max-w-2xl mx-auto leading-relaxed px-4 mb-4">
|
||||
<p className="text-sm sm:text-base text-slate-600 max-w-xl mx-auto leading-relaxed px-4 mb-3">
|
||||
安全、快速、简单的传输服务
|
||||
<br />
|
||||
<span className="text-sm sm:text-base text-slate-500">支持文件、文字、桌面共享 - 无需注册,即传即用</span>
|
||||
<span className="text-xs sm:text-sm text-slate-500">支持文件、文字、桌面共享 - 无需注册,即传即用</span>
|
||||
</p>
|
||||
|
||||
{/* GitHub开源链接 */}
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center space-x-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm rounded-lg transition-all duration-200 hover:scale-105 transform border border-slate-200 hover:border-slate-300"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span>开源项目</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-slate-400 font-mono hover:text-slate-600 transition-colors duration-200 hover:underline"
|
||||
>
|
||||
https://github.com/MatrixSeven/file-transfer-go
|
||||
</a>
|
||||
</div>
|
||||
{/* 分割线 */}
|
||||
<div className="w-64 sm:w-80 md:w-96 lg:w-[32rem] xl:w-[40rem] h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-4 mb-2 opacity-60"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,32 +512,63 @@ export default function TextTransfer({
|
||||
</div>
|
||||
|
||||
{mode === 'send' ? (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<MessageSquare className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare 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">
|
||||
{isRoomCreated ? '实时编辑,对方可以同步看到' : '输入要传输的文本内容'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">传送文字</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">
|
||||
{isRoomCreated ? '实时编辑,对方可以同步看到' : '输入要传输的文本内容'}
|
||||
</p>
|
||||
{/* 连接状态显示 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{isRoomCreated && (
|
||||
<div className="flex items-center justify-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className={isConnected ? 'text-emerald-600' : 'text-red-600'}>
|
||||
{isConnected ? '实时连接已建立' : '连接断开'}
|
||||
</span>
|
||||
</div>
|
||||
{connectedUsers > 0 && (
|
||||
<div className="flex items-center text-blue-600">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
{connectedUsers} 人在线
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isRoomCreated ? (
|
||||
isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span className="text-red-600">WS</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
{connectedUsers > 0 && (
|
||||
<div className="mt-1 text-xs text-blue-600">
|
||||
{connectedUsers} 人在线
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -690,33 +721,68 @@ export default function TextTransfer({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<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">
|
||||
{(receivedText || textContent) ?
|
||||
(isConnected ? '已连接,可以实时查看和编辑' : '连接断开,等待重连') :
|
||||
'输入6位房间码来获取文字内容'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">加入房间</h2>
|
||||
<p className="text-sm sm:text-base text-slate-600">输入6位房间码来获取文字内容</p>
|
||||
|
||||
{/* 连接状态显示 */}
|
||||
{(receivedText || textContent) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className={isConnected ? 'text-emerald-600' : 'text-red-600'}>
|
||||
{isConnected ? '实时连接已建立' : '连接断开'}
|
||||
</span>
|
||||
</div>
|
||||
{connectedUsers > 0 && (
|
||||
<div className="flex items-center text-blue-600">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
{connectedUsers} 人在线
|
||||
</div>
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{(receivedText || textContent) ? (
|
||||
isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span className="text-red-600">WS</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{connectedUsers > 0 && (
|
||||
<div className="mt-1 text-xs text-blue-600">
|
||||
{connectedUsers} 人在线
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
511
chuan-next/src/components/WebRTCFileTransfer.tsx
Normal file
511
chuan-next/src/components/WebRTCFileTransfer.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
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 { Upload, Download } from 'lucide-react';
|
||||
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
|
||||
import { WebRTCFileReceive } from '@/components/webrtc/WebRTCFileReceive';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export const WebRTCFileTransfer: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// 独立的文件状态
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [fileList, setFileList] = useState<FileInfo[]>([]);
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
|
||||
|
||||
// 房间状态
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
isTransferring,
|
||||
isWebSocketConnected,
|
||||
transferProgress,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
sendFile,
|
||||
sendFileList,
|
||||
requestFile: requestFileFromHook,
|
||||
onFileReceived,
|
||||
onFileListReceived,
|
||||
onFileRequested
|
||||
} = useWebRTCTransfer();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
if (type === 'webrtc' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
setMode(urlMode);
|
||||
if (code && urlMode === 'receive') {
|
||||
// 自动加入房间
|
||||
joinRoom(code);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'webrtc');
|
||||
params.set('mode', newMode);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
// 生成文件ID
|
||||
const generateFileId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
// 文件选择处理
|
||||
const handleFileSelect = (files: File[]) => {
|
||||
console.log('=== 文件选择 ===');
|
||||
console.log('新文件:', files.map(f => f.name));
|
||||
|
||||
// 更新选中的文件
|
||||
setSelectedFiles(prev => [...prev, ...files]);
|
||||
|
||||
// 创建对应的文件信息
|
||||
const newFileInfos: FileInfo[] = files.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
setFileList(prev => {
|
||||
const updatedList = [...prev, ...newFileInfos];
|
||||
console.log('更新后的文件列表:', updatedList);
|
||||
|
||||
// 如果已连接,立即同步文件列表
|
||||
if (isConnected && pickupCode) {
|
||||
console.log('立即同步文件列表到对端');
|
||||
setTimeout(() => sendFileList(updatedList), 100);
|
||||
}
|
||||
|
||||
return updatedList;
|
||||
});
|
||||
};
|
||||
|
||||
// 创建房间 (发送模式)
|
||||
const generateCode = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
toast({
|
||||
title: "请先选择文件",
|
||||
description: "需要选择文件才能创建传输房间",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('=== 创建房间 ===');
|
||||
console.log('选中文件数:', selectedFiles.length);
|
||||
|
||||
// 创建后端房间
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: selectedFiles.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}))
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '创建房间失败');
|
||||
}
|
||||
|
||||
const code = data.code;
|
||||
setPickupCode(code);
|
||||
|
||||
console.log('房间创建成功,取件码:', code);
|
||||
|
||||
// 连接WebRTC作为发送方
|
||||
connect(code, 'sender');
|
||||
|
||||
toast({
|
||||
title: "房间创建成功",
|
||||
description: `取件码: ${code}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建房间失败:', error);
|
||||
toast({
|
||||
title: "创建房间失败",
|
||||
description: error instanceof Error ? error.message : '网络错误,请重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 加入房间 (接收模式)
|
||||
const joinRoom = (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
setPickupCode(code.trim());
|
||||
connect(code.trim(), 'receiver');
|
||||
|
||||
toast({
|
||||
title: "正在连接...",
|
||||
description: `尝试连接到房间: ${code}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 处理文件列表更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileListReceived((fileInfos: FileInfo[]) => {
|
||||
console.log('=== 收到文件列表更新 ===');
|
||||
console.log('文件列表:', fileInfos);
|
||||
console.log('当前模式:', mode);
|
||||
|
||||
if (mode === 'receive') {
|
||||
setFileList(fileInfos);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileListReceived, mode]);
|
||||
|
||||
// 处理文件接收
|
||||
useEffect(() => {
|
||||
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
|
||||
console.log('=== 接收到文件 ===');
|
||||
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
|
||||
|
||||
// 更新下载的文件
|
||||
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
|
||||
|
||||
// 更新文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileData.id
|
||||
? { ...item, status: 'completed' as const, progress: 100 }
|
||||
: item
|
||||
));
|
||||
|
||||
toast({
|
||||
title: "文件下载完成",
|
||||
description: `${fileData.file.name} 已准备好下载`,
|
||||
});
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileReceived]);
|
||||
|
||||
// 实时更新传输进度
|
||||
useEffect(() => {
|
||||
console.log('=== 进度更新 ===');
|
||||
console.log('传输中:', isTransferring, '进度:', transferProgress);
|
||||
|
||||
if (isTransferring && transferProgress > 0) {
|
||||
console.log('更新文件传输进度:', transferProgress);
|
||||
setFileList(prev => prev.map(item => {
|
||||
if (item.status === 'downloading') {
|
||||
console.log(`更新文件 ${item.name} 进度从 ${item.progress} 到 ${transferProgress}`);
|
||||
return { ...item, progress: transferProgress };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
}, [isTransferring, transferProgress]);
|
||||
|
||||
// 处理文件请求(发送方监听)
|
||||
useEffect(() => {
|
||||
const cleanup = onFileRequested((fileId: string, fileName: string) => {
|
||||
console.log('=== 收到文件请求 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
|
||||
|
||||
if (mode === 'send') {
|
||||
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
|
||||
|
||||
// 在发送方的selectedFiles中查找对应文件
|
||||
const file = selectedFiles.find(f => f.name === fileName);
|
||||
|
||||
if (!file) {
|
||||
console.error('找不到匹配的文件:', fileName);
|
||||
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
|
||||
toast({
|
||||
title: "文件不存在",
|
||||
description: `无法找到文件: ${fileName}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
|
||||
|
||||
// 发送文件
|
||||
sendFile(file, fileId);
|
||||
} else {
|
||||
console.warn('接收模式下收到文件请求,忽略');
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, toast]);
|
||||
|
||||
// 连接状态变化时同步文件列表
|
||||
useEffect(() => {
|
||||
console.log('=== 连接状态变化 ===');
|
||||
console.log('连接状态:', {
|
||||
isConnected,
|
||||
pickupCode,
|
||||
mode,
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length
|
||||
});
|
||||
|
||||
if (isConnected && pickupCode && mode === 'send' && selectedFiles.length > 0) {
|
||||
// 确保有文件列表
|
||||
if (fileList.length === 0) {
|
||||
console.log('创建文件列表并发送...');
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
setFileList(newFileInfos);
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
sendFileList(newFileInfos);
|
||||
}, 500);
|
||||
} else if (fileList.length > 0) {
|
||||
console.log('发送现有文件列表...');
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
sendFileList(fileList);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [isConnected, pickupCode, mode, selectedFiles.length]);
|
||||
|
||||
// 请求下载文件(接收方调用)
|
||||
const requestFile = (fileId: string) => {
|
||||
if (mode !== 'receive') {
|
||||
console.error('requestFile只能在接收模式下调用');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInfo = fileList.find(f => f.id === fileId);
|
||||
if (!fileInfo) {
|
||||
console.error('找不到文件信息:', fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 开始请求文件 ===');
|
||||
console.log('文件信息:', { name: fileInfo.name, id: fileId, size: fileInfo.size });
|
||||
console.log('当前文件状态:', fileInfo.status);
|
||||
console.log('WebRTC连接状态:', { isConnected, isTransferring });
|
||||
|
||||
// 更新文件状态为下载中
|
||||
setFileList(prev => {
|
||||
const updated = prev.map(item =>
|
||||
item.id === fileId
|
||||
? { ...item, status: 'downloading' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
console.log('更新后的文件列表:', updated.find(f => f.id === fileId));
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 使用hook的requestFile功能
|
||||
console.log('调用hook的requestFile...');
|
||||
requestFileFromHook(fileId, fileInfo.name);
|
||||
|
||||
toast({
|
||||
title: "请求文件",
|
||||
description: `正在请求文件: ${fileInfo.name}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 复制取件码
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(pickupCode);
|
||||
toast({
|
||||
title: "取件码已复制",
|
||||
description: "取件码已复制到剪贴板",
|
||||
});
|
||||
};
|
||||
|
||||
// 复制链接
|
||||
const copyLink = () => {
|
||||
const link = `${window.location.origin}?type=webrtc&mode=receive&code=${pickupCode}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
toast({
|
||||
title: "取件链接已复制",
|
||||
description: "取件链接已复制到剪贴板",
|
||||
});
|
||||
};
|
||||
|
||||
// 重置状态
|
||||
const resetRoom = () => {
|
||||
console.log('=== 重置房间 ===');
|
||||
disconnect();
|
||||
setPickupCode('');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
setDownloadedFiles(new Map());
|
||||
};
|
||||
|
||||
// 添加更多文件
|
||||
const addMoreFiles = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 清空文件
|
||||
const clearFiles = () => {
|
||||
console.log('=== 清空文件 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
if (isConnected && pickupCode) {
|
||||
sendFileList([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文件到本地
|
||||
const downloadFile = (fileId: string) => {
|
||||
const file = downloadedFiles.get(fileId);
|
||||
if (!file) return;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "文件已保存",
|
||||
description: `${file.name} 已保存到下载文件夹`,
|
||||
});
|
||||
};
|
||||
|
||||
// 处理下载请求(接收模式)
|
||||
const handleDownloadRequest = (fileId: string) => {
|
||||
const file = downloadedFiles.get(fileId);
|
||||
if (file) {
|
||||
// 文件已下载完成,保存到本地
|
||||
downloadFile(fileId);
|
||||
} else {
|
||||
// 文件未下载,请求传输
|
||||
requestFile(fileId);
|
||||
}
|
||||
};
|
||||
|
||||
const pickupLink = pickupCode ? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=webrtc&mode=receive&code=${pickupCode}` : '';
|
||||
|
||||
// 显示错误信息
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
title: "连接错误",
|
||||
description: error,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
<Button
|
||||
variant={mode === 'send' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('send')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
发送文件
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'receive' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('receive')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
接收文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'send' ? (
|
||||
<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}
|
||||
onFilesChange={setSelectedFiles}
|
||||
onGenerateCode={generateCode}
|
||||
pickupCode={pickupCode}
|
||||
pickupLink={pickupLink}
|
||||
onCopyCode={copyCode}
|
||||
onCopyLink={copyLink}
|
||||
onAddMoreFiles={addMoreFiles}
|
||||
onRemoveFile={setSelectedFiles}
|
||||
onClearFiles={clearFiles}
|
||||
onReset={resetRoom}
|
||||
disabled={isTransferring}
|
||||
isConnected={isConnected}
|
||||
isWebSocketConnected={isWebSocketConnected}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<WebRTCFileReceive
|
||||
onJoinRoom={joinRoom}
|
||||
files={fileList}
|
||||
onDownloadFile={handleDownloadRequest}
|
||||
transferProgress={transferProgress}
|
||||
isTransferring={isTransferring}
|
||||
isConnected={isConnected}
|
||||
isConnecting={!!pickupCode && !isConnected}
|
||||
isWebSocketConnected={isWebSocketConnected}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(Array.from(e.target.files || []))}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
438
chuan-next/src/components/webrtc/WebRTCFileReceive.tsx
Normal file
438
chuan-next/src/components/webrtc/WebRTCFileReceive.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
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';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: 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;
|
||||
transferProgress: number;
|
||||
isTransferring: boolean;
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected?: boolean;
|
||||
downloadedFiles?: Map<string, File>;
|
||||
}
|
||||
|
||||
export function WebRTCFileReceive({
|
||||
onJoinRoom,
|
||||
files,
|
||||
onDownloadFile,
|
||||
transferProgress,
|
||||
isTransferring,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isWebSocketConnected = false,
|
||||
downloadedFiles
|
||||
}: WebRTCFileReceiveProps) {
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pickupCode.length === 6) {
|
||||
onJoinRoom(pickupCode.toUpperCase());
|
||||
}
|
||||
}, [pickupCode, onJoinRoom]);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
|
||||
if (value.length <= 6) {
|
||||
setPickupCode(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 如果已经连接但没有文件,显示等待界面
|
||||
if ((isConnected || isConnecting) && files.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-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">
|
||||
{isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 ${isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
|
||||
<span className={`text-sm font-medium ${isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
|
||||
{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">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<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-500">
|
||||
{isConnected ? (
|
||||
<span className="text-emerald-600">✅ 已连接,可以下载文件</span>
|
||||
) : (
|
||||
<span className="text-amber-600">⏳ 正在建立连接...</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{files.length} 个文件
|
||||
</div>
|
||||
</div>
|
||||
</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);
|
||||
const currentProgress = isDownloading && isTransferring ? transferProgress : file.progress;
|
||||
|
||||
console.log('文件状态:', {
|
||||
fileName: file.name,
|
||||
status: file.status,
|
||||
progress: file.progress,
|
||||
isDownloading,
|
||||
isTransferring,
|
||||
transferProgress,
|
||||
currentProgress
|
||||
});
|
||||
|
||||
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 && isTransferring && (
|
||||
<p className="text-xs text-blue-600 font-medium">⏳ 传输中...{currentProgress.toFixed(1)}%</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onDownloadFile(file.id)}
|
||||
disabled={!isConnected || (isDownloading && isTransferring)}
|
||||
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)
|
||||
? '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 ? '保存文件' : '开始传输'}
|
||||
</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 mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<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>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">WS</span>
|
||||
</>
|
||||
) : isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : isConnecting ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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={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 || isConnecting}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
|
||||
|
||||
interface FileUploadProps {
|
||||
selectedFiles: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
onGenerateCode: () => void;
|
||||
pickupCode?: string;
|
||||
pickupLink?: string;
|
||||
onCopyCode?: () => void;
|
||||
onCopyLink?: () => void;
|
||||
onAddMoreFiles?: () => void;
|
||||
onRemoveFile?: (updatedFiles: File[]) => void;
|
||||
onClearFiles?: () => void;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
@@ -36,7 +30,24 @@ const formatFileSize = (bytes: number): string => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export default function FileUpload({
|
||||
interface WebRTCFileUploadProps {
|
||||
selectedFiles: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
onGenerateCode: () => void;
|
||||
pickupCode?: string;
|
||||
pickupLink?: string;
|
||||
onCopyCode?: () => void;
|
||||
onCopyLink?: () => void;
|
||||
onAddMoreFiles?: () => void;
|
||||
onRemoveFile?: (updatedFiles: File[]) => void;
|
||||
onClearFiles?: () => void;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
isConnected?: boolean;
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
|
||||
export function WebRTCFileUpload({
|
||||
selectedFiles,
|
||||
onFilesChange,
|
||||
onGenerateCode,
|
||||
@@ -48,8 +59,10 @@ export default function FileUpload({
|
||||
onRemoveFile,
|
||||
onClearFiles,
|
||||
onReset,
|
||||
disabled = false
|
||||
}: FileUploadProps) {
|
||||
disabled = false,
|
||||
isConnected = false,
|
||||
isWebSocketConnected = false
|
||||
}: WebRTCFileUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -99,12 +112,41 @@ export default function FileUpload({
|
||||
if (selectedFiles.length === 0 && !pickupCode) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Upload 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">拖拽文件到下方区域或点击选择文件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-slate-800 mb-2">选择文件</h2>
|
||||
<p className="text-slate-600">拖拽文件到下方区域或点击选择文件</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -130,7 +172,7 @@ export default function FileUpload({
|
||||
或者 <span className="text-blue-600 font-medium underline">点击选择文件</span>
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-4">
|
||||
支持多个文件同时上传,无大小限制
|
||||
支持多个文件同时上传,WebRTC点对点传输
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,15 +192,63 @@ export default function FileUpload({
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 文件列表 */}
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-4 sm:mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-slate-800">已选择文件</h3>
|
||||
<p className="text-slate-500 text-sm">{selectedFiles.length} 个文件准备传输</p>
|
||||
<h3 className="text-lg font-semibold text-slate-800">已选择文件</h3>
|
||||
<p className="text-sm text-slate-500">{selectedFiles.length} 个文件准备传输</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : pickupCode ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +349,7 @@ export default function FileUpload({
|
||||
|
||||
{/* 取件码展示 */}
|
||||
{pickupCode && (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
|
||||
<FileText className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { FileInfo, RoomStatus } from '@/types';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { apiPost, apiGet } from '@/lib/api-utils';
|
||||
|
||||
export const useRoomManager = () => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [pickupCode, setPickupCode] = useState<string>('');
|
||||
const [pickupLink, setPickupLink] = useState<string>('');
|
||||
const [currentRole, setCurrentRole] = useState<'sender' | 'receiver'>('sender');
|
||||
const [receiverFiles, setReceiverFiles] = useState<FileInfo[]>([]);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [roomStatus, setRoomStatus] = useState<RoomStatus | null>(null);
|
||||
|
||||
const { websocket, isConnected, connect, disconnect, sendMessage } = useWebSocket();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 生成取件码
|
||||
const generateCode = useCallback(async () => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const fileInfos = selectedFiles.map((file, index) => ({
|
||||
id: 'file_' + index,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await apiPost('/api/create-room', { files: fileInfos });
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const code = data.code;
|
||||
setPickupCode(code);
|
||||
setCurrentRole('sender');
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const link = `${baseUrl}/?type=file&mode=receive&code=${code}`;
|
||||
setPickupLink(link);
|
||||
|
||||
connect(code, 'sender');
|
||||
showToast('取件码生成成功!');
|
||||
} else {
|
||||
showToast('生成取件码失败: ' + data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成取件码失败:', error);
|
||||
showToast('生成取件码失败,请重试', 'error');
|
||||
}
|
||||
}, [selectedFiles, connect, showToast]);
|
||||
|
||||
// 加入房间
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
// 防止重复连接
|
||||
if (isConnecting || (isConnected && pickupCode === code)) {
|
||||
console.log('已在连接中或已连接,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
const response = await apiGet(`/api/room-info?code=${code}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPickupCode(code);
|
||||
setCurrentRole('receiver');
|
||||
setReceiverFiles(data.files || []);
|
||||
// 开始连接WebSocket
|
||||
connect(code, 'receiver');
|
||||
console.log('房间信息获取成功,开始建立WebSocket连接');
|
||||
// 注意:isConnecting状态会在WebSocket连接建立后自动重置
|
||||
// 不在这里显示成功消息,等WebSocket连接成功后再显示
|
||||
} else {
|
||||
showToast(data.message || '取件码不存在或已过期', 'error');
|
||||
setIsConnecting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error);
|
||||
showToast('取件码不存在或已过期', 'error');
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [connect, showToast, isConnecting, isConnected, pickupCode]);
|
||||
|
||||
// 通过WebSocket更新文件列表
|
||||
const updateFileList = useCallback((files: File[]) => {
|
||||
if (!pickupCode || !websocket || websocket.readyState !== WebSocket.OPEN) {
|
||||
console.log('无法更新文件列表: pickupCode=', pickupCode, 'websocket状态=', websocket?.readyState);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInfos = files.map((file, index) => ({
|
||||
id: 'file_' + index,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}));
|
||||
|
||||
console.log('通过WebSocket发送文件列表更新:', fileInfos);
|
||||
sendMessage({
|
||||
type: 'update-file-list',
|
||||
payload: {
|
||||
files: fileInfos
|
||||
}
|
||||
});
|
||||
|
||||
showToast('文件列表已更新');
|
||||
}, [pickupCode, websocket, sendMessage, showToast]);
|
||||
|
||||
// 处理文件删除后的同步
|
||||
const handleRemoveFile = useCallback((updatedFiles: File[]) => {
|
||||
if (pickupCode) {
|
||||
updateFileList(updatedFiles);
|
||||
}
|
||||
}, [pickupCode, updateFileList]);
|
||||
|
||||
// 清空文件列表但保持房间连接
|
||||
const clearFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
if (pickupCode) {
|
||||
updateFileList([]);
|
||||
showToast('文件列表已清空,房间保持连接', 'success');
|
||||
}
|
||||
}, [pickupCode, updateFileList, showToast]);
|
||||
|
||||
// 完全重置状态(关闭房间)
|
||||
const resetRoom = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
setPickupCode('');
|
||||
setPickupLink('');
|
||||
setReceiverFiles([]);
|
||||
setRoomStatus(null);
|
||||
disconnect();
|
||||
showToast('已断开连接', 'info');
|
||||
}, [disconnect, showToast]);
|
||||
|
||||
// 重置连接状态
|
||||
const resetConnectingState = useCallback(() => {
|
||||
if (isConnected && isConnecting) {
|
||||
setIsConnecting(false);
|
||||
console.log('WebSocket连接已建立,重置连接状态');
|
||||
}
|
||||
}, [isConnected, isConnecting]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
selectedFiles,
|
||||
pickupCode,
|
||||
pickupLink,
|
||||
currentRole,
|
||||
receiverFiles,
|
||||
isConnecting,
|
||||
roomStatus,
|
||||
isConnected,
|
||||
websocket,
|
||||
|
||||
// 状态更新函数
|
||||
setSelectedFiles,
|
||||
setReceiverFiles,
|
||||
setRoomStatus,
|
||||
setIsConnecting,
|
||||
setCurrentRole,
|
||||
resetConnectingState,
|
||||
|
||||
// 房间操作
|
||||
generateCode,
|
||||
joinRoom,
|
||||
updateFileList,
|
||||
handleRemoveFile,
|
||||
clearFiles,
|
||||
resetRoom,
|
||||
|
||||
// WebSocket 相关
|
||||
sendMessage,
|
||||
disconnect,
|
||||
connect
|
||||
};
|
||||
};
|
||||
145
chuan-next/src/hooks/useWebRTCTransfer.new.ts
Normal file
145
chuan-next/src/hooks/useWebRTCTransfer.new.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useWebRTCConnection } from './webrtc/useWebRTCConnection';
|
||||
import { useFileTransfer } from './webrtc/useFileTransfer';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function useWebRTCTransfer() {
|
||||
const connection = useWebRTCConnection();
|
||||
const fileTransfer = useFileTransfer();
|
||||
|
||||
// 文件列表回调存储
|
||||
const fileListCallbacks = useRef<Array<(fileList: FileInfo[]) => void>>([]);
|
||||
|
||||
// 设置数据通道消息处理
|
||||
useEffect(() => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (dataChannel && dataChannel.readyState === 'open') {
|
||||
console.log('设置数据通道消息处理器');
|
||||
|
||||
// 扩展消息处理以包含文件列表
|
||||
const originalHandler = fileTransfer.handleMessage;
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
console.log('收到数据通道消息:', typeof event.data);
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'file-list') {
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(callback => {
|
||||
callback(message.payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析文件列表消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他消息类型
|
||||
originalHandler(event);
|
||||
};
|
||||
}
|
||||
}, [connection.isConnected, connection.getDataChannel, fileTransfer.handleMessage]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback((file: File, fileId?: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}`;
|
||||
console.log('=== 发送文件 ===');
|
||||
console.log('文件:', file.name, 'ID:', actualFileId, '大小:', file.size);
|
||||
|
||||
fileTransfer.sendFile(file, actualFileId, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.sendFile]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 请求文件 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId);
|
||||
|
||||
fileTransfer.requestFile(fileId, fileName, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.requestFile]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 发送文件列表 ===');
|
||||
console.log('文件列表:', fileList);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
});
|
||||
|
||||
try {
|
||||
dataChannel.send(message);
|
||||
console.log('文件列表已发送');
|
||||
} catch (error) {
|
||||
console.error('发送文件列表失败:', error);
|
||||
}
|
||||
}, [connection.getDataChannel]);
|
||||
|
||||
// 注册文件列表接收回调
|
||||
const onFileListReceived = useCallback((callback: (fileList: FileInfo[]) => void) => {
|
||||
console.log('注册文件列表回调');
|
||||
fileListCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileListCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileListCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 连接状态
|
||||
isConnected: connection.isConnected,
|
||||
isConnecting: connection.isConnecting,
|
||||
error: connection.error || fileTransfer.error,
|
||||
|
||||
// 传输状态
|
||||
isTransferring: fileTransfer.isTransferring,
|
||||
transferProgress: fileTransfer.transferProgress,
|
||||
receivedFiles: fileTransfer.receivedFiles,
|
||||
|
||||
// 操作方法
|
||||
connect: connection.connect,
|
||||
disconnect: connection.disconnect,
|
||||
sendFile,
|
||||
requestFile,
|
||||
sendFileList,
|
||||
|
||||
// 回调注册
|
||||
onFileRequested: fileTransfer.onFileRequested,
|
||||
onFileReceived: fileTransfer.onFileReceived,
|
||||
onFileListReceived,
|
||||
};
|
||||
}
|
||||
146
chuan-next/src/hooks/useWebRTCTransfer.ts
Normal file
146
chuan-next/src/hooks/useWebRTCTransfer.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useWebRTCConnection } from './webrtc/useWebRTCConnection';
|
||||
import { useFileTransfer } from './webrtc/useFileTransfer';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function useWebRTCTransfer() {
|
||||
const connection = useWebRTCConnection();
|
||||
const fileTransfer = useFileTransfer();
|
||||
|
||||
// 文件列表回调存储
|
||||
const fileListCallbacks = useRef<Array<(fileList: FileInfo[]) => void>>([]);
|
||||
|
||||
// 设置数据通道消息处理
|
||||
useEffect(() => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (dataChannel && dataChannel.readyState === 'open') {
|
||||
console.log('设置数据通道消息处理器');
|
||||
|
||||
// 扩展消息处理以包含文件列表
|
||||
const originalHandler = fileTransfer.handleMessage;
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
console.log('收到数据通道消息:', typeof event.data);
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'file-list') {
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(callback => {
|
||||
callback(message.payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析文件列表消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他消息类型
|
||||
originalHandler(event);
|
||||
};
|
||||
}
|
||||
}, [connection.isConnected, connection.getDataChannel, fileTransfer.handleMessage]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback((file: File, fileId?: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}`;
|
||||
console.log('=== 发送文件 ===');
|
||||
console.log('文件:', file.name, 'ID:', actualFileId, '大小:', file.size);
|
||||
|
||||
fileTransfer.sendFile(file, actualFileId, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.sendFile]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 请求文件 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId);
|
||||
|
||||
fileTransfer.requestFile(fileId, fileName, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.requestFile]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 发送文件列表 ===');
|
||||
console.log('文件列表:', fileList);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
});
|
||||
|
||||
try {
|
||||
dataChannel.send(message);
|
||||
console.log('文件列表已发送');
|
||||
} catch (error) {
|
||||
console.error('发送文件列表失败:', error);
|
||||
}
|
||||
}, [connection.getDataChannel]);
|
||||
|
||||
// 注册文件列表接收回调
|
||||
const onFileListReceived = useCallback((callback: (fileList: FileInfo[]) => void) => {
|
||||
console.log('注册文件列表回调');
|
||||
fileListCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileListCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileListCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 连接状态
|
||||
isConnected: connection.isConnected,
|
||||
isConnecting: connection.isConnecting,
|
||||
isWebSocketConnected: connection.isWebSocketConnected,
|
||||
error: connection.error || fileTransfer.error,
|
||||
|
||||
// 传输状态
|
||||
isTransferring: fileTransfer.isTransferring,
|
||||
transferProgress: fileTransfer.transferProgress,
|
||||
receivedFiles: fileTransfer.receivedFiles,
|
||||
|
||||
// 操作方法
|
||||
connect: connection.connect,
|
||||
disconnect: connection.disconnect,
|
||||
sendFile,
|
||||
requestFile,
|
||||
sendFileList,
|
||||
|
||||
// 回调注册
|
||||
onFileRequested: fileTransfer.onFileRequested,
|
||||
onFileReceived: fileTransfer.onFileReceived,
|
||||
onFileListReceived,
|
||||
};
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { UseWebSocketReturn, WebSocketMessage } from '@/types';
|
||||
import { getWebSocketUrl } from '@/lib/api-utils';
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
const [websocket, setWebsocket] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentCodeRef = useRef<string>('');
|
||||
const currentRoleRef = useRef<'sender' | 'receiver'>('sender');
|
||||
|
||||
const connect = useCallback((code: string, role: 'sender' | 'receiver') => {
|
||||
// 防止重复连接 - 更严格的检查
|
||||
if (websocket &&
|
||||
(websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) &&
|
||||
currentCodeRef.current === code &&
|
||||
currentRoleRef.current === role) {
|
||||
console.log('WebSocket已连接或正在连接,跳过重复连接', {
|
||||
readyState: websocket.readyState,
|
||||
currentCode: currentCodeRef.current,
|
||||
newCode: code,
|
||||
currentRole: currentRoleRef.current,
|
||||
newRole: role
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有现有连接,先关闭
|
||||
if (websocket) {
|
||||
console.log('关闭现有WebSocket连接');
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
currentCodeRef.current = code;
|
||||
currentRoleRef.current = role;
|
||||
|
||||
// 连接到Go后端的WebSocket - 使用配置文件中的URL
|
||||
const baseWsUrl = getWebSocketUrl();
|
||||
const wsUrl = `${baseWsUrl}?code=${code}&role=${role}`;
|
||||
|
||||
console.log('连接WebSocket:', wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
setIsConnected(true);
|
||||
setWebsocket(ws);
|
||||
|
||||
// 发送连接建立确认事件
|
||||
const connectEvent = new CustomEvent('websocket-connected', {
|
||||
detail: { code, role }
|
||||
});
|
||||
window.dispatchEvent(connectEvent);
|
||||
|
||||
// 发送初始连接信息
|
||||
const message = {
|
||||
type: 'connect',
|
||||
payload: {
|
||||
code: code,
|
||||
role: role,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('收到WebSocket消息:', message);
|
||||
|
||||
// 分发事件
|
||||
const customEvent = new CustomEvent('websocket-message', {
|
||||
detail: message
|
||||
});
|
||||
window.dispatchEvent(customEvent);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket连接关闭:', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
setWebsocket(null);
|
||||
|
||||
// 发送连接关闭事件
|
||||
const closeEvent = new CustomEvent('websocket-close', {
|
||||
detail: { code: event.code, reason: event.reason }
|
||||
});
|
||||
window.dispatchEvent(closeEvent);
|
||||
|
||||
// 如果不是正常关闭且有房间码,尝试重连
|
||||
if (event.code !== 1000 && currentCodeRef.current) {
|
||||
console.log('尝试重新连接...');
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect(currentCodeRef.current, currentRoleRef.current);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
console.error('WebSocket状态:', ws.readyState);
|
||||
console.error('WebSocket URL:', wsUrl);
|
||||
setIsConnected(false);
|
||||
|
||||
// 发送连接错误事件
|
||||
const errorEvent = new CustomEvent('websocket-error', {
|
||||
detail: { error, url: wsUrl, readyState: ws.readyState }
|
||||
});
|
||||
window.dispatchEvent(errorEvent);
|
||||
};
|
||||
}, [websocket]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
currentCodeRef.current = '';
|
||||
|
||||
if (websocket) {
|
||||
websocket.close(1000, 'User disconnected');
|
||||
}
|
||||
}, [websocket]);
|
||||
|
||||
const sendMessage = useCallback((message: WebSocketMessage) => {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket未连接,无法发送消息');
|
||||
}
|
||||
}, [websocket]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
};
|
||||
}, [websocket]);
|
||||
|
||||
return {
|
||||
websocket,
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage
|
||||
};
|
||||
}
|
||||
299
chuan-next/src/hooks/webrtc/useFileTransfer.ts
Normal file
299
chuan-next/src/hooks/webrtc/useFileTransfer.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface FileTransferState {
|
||||
isTransferring: boolean;
|
||||
transferProgress: number;
|
||||
receivedFiles: Array<{ id: string; file: File }>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface FileChunk {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 16 * 1024; // 16KB chunks
|
||||
|
||||
export function useFileTransfer() {
|
||||
const [state, setState] = useState<FileTransferState>({
|
||||
isTransferring: false,
|
||||
transferProgress: 0,
|
||||
receivedFiles: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 存储接收中的文件数据
|
||||
const receivingFiles = useRef<Map<string, {
|
||||
metadata: FileMetadata;
|
||||
chunks: ArrayBuffer[];
|
||||
receivedChunks: number;
|
||||
totalChunks: number;
|
||||
}>>(new Map());
|
||||
|
||||
// 文件请求回调
|
||||
const fileRequestCallbacks = useRef<Array<(fileId: string, fileName: string) => void>>([]);
|
||||
const fileReceivedCallbacks = useRef<Array<(fileData: { id: string; file: File }) => void>>([]);
|
||||
|
||||
const updateState = useCallback((updates: Partial<FileTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback(async (file: File, fileId: string, dataChannel: RTCDataChannel) => {
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪');
|
||||
updateState({ error: '数据通道未准备就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 开始发送文件 ===');
|
||||
console.log('文件:', file.name, '大小:', file.size, 'ID:', fileId);
|
||||
|
||||
updateState({ isTransferring: true, transferProgress: 0, error: null });
|
||||
|
||||
try {
|
||||
// 发送文件元数据
|
||||
const metadata: FileMetadata = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
};
|
||||
|
||||
const metadataMessage = JSON.stringify({
|
||||
type: 'file-start',
|
||||
payload: metadata
|
||||
});
|
||||
|
||||
console.log('发送文件元数据:', metadataMessage);
|
||||
dataChannel.send(metadataMessage);
|
||||
|
||||
// 计算分块数量
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
console.log('总分块数:', totalChunks);
|
||||
|
||||
// 分块发送文件
|
||||
let sentChunks = 0;
|
||||
|
||||
const sendNextChunk = () => {
|
||||
if (sentChunks >= totalChunks) {
|
||||
// 发送结束信号
|
||||
const endMessage = JSON.stringify({
|
||||
type: 'file-end',
|
||||
payload: { id: fileId }
|
||||
});
|
||||
dataChannel.send(endMessage);
|
||||
|
||||
updateState({ isTransferring: false, transferProgress: 100 });
|
||||
console.log('文件发送完成:', file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = sentChunks * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
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);
|
||||
|
||||
sentChunks++;
|
||||
const progress = (sentChunks / totalChunks) * 100;
|
||||
updateState({ transferProgress: progress });
|
||||
|
||||
console.log(`发送进度: ${progress.toFixed(1)}%, 块: ${sentChunks}/${totalChunks}`);
|
||||
|
||||
// 短暂延迟,避免阻塞
|
||||
setTimeout(sendNextChunk, 10);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('读取文件块失败');
|
||||
updateState({ error: '读取文件失败', isTransferring: false });
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(chunk);
|
||||
};
|
||||
|
||||
sendNextChunk();
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送文件失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '发送文件失败',
|
||||
isTransferring: false
|
||||
});
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理接收到的消息
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('收到消息:', message.type, message.payload);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-list':
|
||||
// 文件列表消息由主hook处理
|
||||
console.log('文件列表消息将由主hook处理');
|
||||
return;
|
||||
|
||||
case 'file-start':
|
||||
const metadata = message.payload as FileMetadata;
|
||||
console.log('开始接收文件:', metadata.name, '大小:', metadata.size);
|
||||
|
||||
receivingFiles.current.set(metadata.id, {
|
||||
metadata,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
totalChunks: Math.ceil(metadata.size / CHUNK_SIZE)
|
||||
});
|
||||
|
||||
updateState({ isTransferring: true, transferProgress: 0 });
|
||||
break;
|
||||
|
||||
case 'file-chunk':
|
||||
const chunkInfo = message.payload;
|
||||
console.log(`接收文件块: ${chunkInfo.chunkIndex + 1}/${chunkInfo.totalChunks}`);
|
||||
break;
|
||||
|
||||
case 'file-end':
|
||||
const { id: fileId } = message.payload;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 组装文件
|
||||
const blob = new Blob(fileInfo.chunks, { type: fileInfo.metadata.type });
|
||||
const file = new File([blob], fileInfo.metadata.name, { type: fileInfo.metadata.type });
|
||||
|
||||
console.log('文件接收完成:', file.name);
|
||||
|
||||
// 添加到接收文件列表
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
|
||||
isTransferring: false,
|
||||
transferProgress: 100
|
||||
}));
|
||||
|
||||
// 触发回调
|
||||
fileReceivedCallbacks.current.forEach(callback => {
|
||||
callback({ id: fileId, file });
|
||||
});
|
||||
|
||||
// 清理
|
||||
receivingFiles.current.delete(fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-request':
|
||||
const { fileId: requestedFileId, fileName } = message.payload;
|
||||
console.log('收到文件请求:', fileName, 'ID:', requestedFileId);
|
||||
|
||||
// 触发文件请求回调
|
||||
fileRequestCallbacks.current.forEach(callback => {
|
||||
callback(requestedFileId, fileName);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析消息失败:', error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
// 处理文件块数据
|
||||
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++;
|
||||
|
||||
const progress = (fileInfo.receivedChunks / fileInfo.totalChunks) * 100;
|
||||
updateState({ transferProgress: progress });
|
||||
|
||||
console.log(`文件接收进度: ${progress.toFixed(1)}%, 块: ${fileInfo.receivedChunks}/${fileInfo.totalChunks}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string, dataChannel: RTCDataChannel) => {
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('请求文件:', fileName, 'ID:', fileId);
|
||||
|
||||
const requestMessage = JSON.stringify({
|
||||
type: 'file-request',
|
||||
payload: { fileId, fileName }
|
||||
});
|
||||
|
||||
dataChannel.send(requestMessage);
|
||||
}, []);
|
||||
|
||||
// 注册文件请求回调
|
||||
const onFileRequested = useCallback((callback: (fileId: string, fileName: string) => void) => {
|
||||
fileRequestCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileRequestCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileRequestCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册文件接收回调
|
||||
const onFileReceived = useCallback((callback: (fileData: { id: string; file: File }) => void) => {
|
||||
fileReceivedCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileReceivedCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileReceivedCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sendFile,
|
||||
requestFile,
|
||||
handleMessage,
|
||||
onFileRequested,
|
||||
onFileReceived,
|
||||
};
|
||||
}
|
||||
203
chuan-next/src/hooks/webrtc/useWebRTCConnection.ts
Normal file
203
chuan-next/src/hooks/webrtc/useWebRTCConnection.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
interface WebRTCConnectionState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
error: string | null;
|
||||
localDataChannel: RTCDataChannel | null;
|
||||
remoteDataChannel: RTCDataChannel | null;
|
||||
}
|
||||
|
||||
export function useWebRTCConnection() {
|
||||
const [state, setState] = useState<WebRTCConnectionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
error: null,
|
||||
localDataChannel: null,
|
||||
remoteDataChannel: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
|
||||
const STUN_SERVERS = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
];
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCConnectionState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('=== 开始WebRTC连接 ===');
|
||||
console.log('房间代码:', roomCode, '角色:', role);
|
||||
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
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}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
// WebSocket事件处理
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
updateState({ isWebSocketConnected: true });
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('收到信令消息:', message);
|
||||
|
||||
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 }));
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.answer));
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
if (message.candidate) {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('信令错误:', message.error);
|
||||
updateState({ error: message.error, isConnecting: false });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理信令消息失败:', error);
|
||||
updateState({ error: '信令处理失败', isConnecting: false });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
updateState({ error: 'WebSocket连接失败', isConnecting: false, isWebSocketConnected: false });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket连接已关闭');
|
||||
updateState({ isWebSocketConnected: false });
|
||||
};
|
||||
|
||||
// ICE候选事件
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('发送ICE候选:', event.candidate);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
candidate: event.candidate
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 连接状态变化
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log('连接状态:', pc.connectionState);
|
||||
const isConnected = pc.connectionState === 'connected';
|
||||
updateState({
|
||||
isConnected,
|
||||
isConnecting: !isConnected && pc.connectionState !== 'failed'
|
||||
});
|
||||
|
||||
if (pc.connectionState === 'failed') {
|
||||
updateState({ error: '连接失败', isConnecting: false });
|
||||
}
|
||||
};
|
||||
|
||||
// 如果是发送方,创建数据通道
|
||||
if (role === 'sender') {
|
||||
const dataChannel = pc.createDataChannel('fileTransfer', {
|
||||
ordered: true
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('数据通道已打开 (发送方)');
|
||||
updateState({ localDataChannel: dataChannel });
|
||||
};
|
||||
|
||||
// 创建offer
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({ type: 'offer', offer }));
|
||||
} else {
|
||||
// 接收方等待数据通道
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
console.log('收到数据通道 (接收方)');
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('数据通道已打开 (接收方)');
|
||||
updateState({ remoteDataChannel: dataChannel });
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false
|
||||
});
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('断开WebRTC连接');
|
||||
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
error: null,
|
||||
localDataChannel: null,
|
||||
remoteDataChannel: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getDataChannel = useCallback(() => {
|
||||
return dcRef.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
connect,
|
||||
disconnect,
|
||||
getDataChannel,
|
||||
};
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const getCurrentBaseUrl = () => {
|
||||
const getCurrentWsUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 在开发模式下,始终使用后端的WebSocket地址
|
||||
if (window.location.hostname === 'localhost' && window.location.port === '3000') {
|
||||
if (window.location.hostname === 'localhost' && (window.location.port === '3000' || window.location.port === '3001')) {
|
||||
return 'ws://localhost:8080/ws/p2p';
|
||||
}
|
||||
// 在生产模式下,使用当前域名
|
||||
|
||||
Reference in New Issue
Block a user