7 Commits

22 changed files with 1626 additions and 3459 deletions

View File

@@ -1,115 +0,0 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build Go Binaries
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: [amd64, arm64]
exclude:
- goos: windows
goarch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Install dependencies
run: go mod download
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
mkdir -p build
BINARY_NAME=chuan
if [ "$GOOS" = "windows" ]; then
BINARY_NAME="${BINARY_NAME}.exe"
fi
VERSION=${GITHUB_REF_NAME:-dev}
BUILD_TIME=$(date +'%Y-%m-%d %H:%M:%S')
go build -ldflags "-X main.Version=${VERSION} -X main.BuildTime='${BUILD_TIME}'" \
-o build/${BINARY_NAME} cmd/main.go
# 创建发布包
cd build
if [ "$GOOS" = "windows" ]; then
zip ../chuan-${GOOS}-${GOARCH}.zip ${BINARY_NAME}
else
tar -czf ../chuan-${GOOS}-${GOARCH}.tar.gz ${BINARY_NAME}
fi
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: chuan-${{ matrix.goos }}-${{ matrix.goarch }}
path: chuan-${{ matrix.goos }}-${{ matrix.goarch }}.*
retention-days: 5
release:
name: Create Release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release
find artifacts -name "*.zip" -o -name "*.tar.gz" | xargs -I {} cp {} release/
ls -la release/
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: release/*
generate_release_notes: true
draft: false
prerelease: false
body: |
## 🚀 川 P2P文件传输系统 ${{ github.ref_name }}
### 📦 下载说明
- `chuan-linux-amd64.tar.gz` - Linux x64
- `chuan-linux-arm64.tar.gz` - Linux ARM64
- `chuan-darwin-amd64.tar.gz` - macOS Intel
- `chuan-darwin-arm64.tar.gz` - macOS Apple Silicon
- `chuan-windows-amd64.zip` - Windows x64
### 🏃‍♂️ 快速开始
1. 下载对应平台的二进制文件
2. 解压后运行 `./chuan` (Linux/macOS) 或 `chuan.exe` (Windows)
3. 访问 http://localhost:8080 开始使用
### ✨ 主要功能
- 🔄 P2P文件传输无需服务器中转
- 🎯 6位取件码简单易用
- 👥 多人房间支持
- 📁 动态添加文件
- 🚀 高速传输优化
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Accelerator
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,5 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// 编译器配置 - 在生产环境中去掉 console.log
compiler: {
removeConsole: process.env.NODE_ENV === 'production' ? {
exclude: ['error', 'warn', 'info'], // 保留 console.error, console.warn, console.info
} : false,
},
// 环境变量配置
env: {
GO_BACKEND_URL: process.env.GO_BACKEND_URL,

View File

@@ -6,7 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Upload, MessageSquare, Monitor } from 'lucide-react';
import Hero from '@/components/Hero';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import TextTransferWrapper from '@/components/TextTransferWrapper';
import {WebRTCTextImageTransfer} from '@/components/WebRTCTextImageTransfer';
export default function HomePage() {
const searchParams = useSearchParams();
@@ -90,7 +90,7 @@ export default function HomePage() {
</TabsContent>
<TabsContent value="message" className="mt-0 animate-fade-in-up">
<TextTransferWrapper />
<WebRTCTextImageTransfer />
</TabsContent>
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">

View File

@@ -1,40 +0,0 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface TabSwitchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
onCancel: () => void;
description: string;
}
export const TabSwitchDialog: React.FC<TabSwitchDialogProps> = ({
open,
onOpenChange,
onConfirm,
onCancel,
description
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
</Button>
<Button onClick={onConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from 'react';
import { useWebRTCTransfer } from '@/hooks/useWebRTCTransfer';
import TextTransfer from './TextTransfer';
export default function TextTransferWrapper() {
const webrtc = useWebRTCTransfer();
const [currentRole, setCurrentRole] = useState<'sender' | 'receiver' | undefined>();
const [pickupCode, setPickupCode] = useState<string>();
// 创建房间并建立连接
const handleCreateWebSocket = useCallback(async (code: string, role: 'sender' | 'receiver') => {
console.log('=== TextTransferWrapper: 开始建立WebRTC连接 ===');
console.log('房间码:', code, '角色:', role);
setCurrentRole(role);
setPickupCode(code);
try {
// 建立WebRTC连接
await webrtc.text.connect(code, role);
console.log('WebRTC连接请求已发送');
} catch (error) {
console.error('建立WebRTC连接失败:', error);
}
}, [webrtc.text.connect]);
// 处理文字消息接收
useEffect(() => {
if (!webrtc.text.onMessageReceived) {
return;
}
const unsubscribeMessage = webrtc.text.onMessageReceived((message) => {
console.log('收到文字消息:', message);
// 检查是否是图片消息
if (message.text && message.text.startsWith('[IMAGE]')) {
const imageData = message.text.substring(7); // 移除 '[IMAGE]' 前缀
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'image-send',
payload: { imageData }
}
}));
} else {
// 普通文字消息
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'text-content',
payload: { text: message.text }
}
}));
}
});
return unsubscribeMessage;
}, [webrtc.text.onMessageReceived]);
// 处理实时文本更新接收
useEffect(() => {
if (!webrtc.text.onRealTimeText) {
return;
}
const unsubscribeRealTime = webrtc.text.onRealTimeText((text: string) => {
console.log('收到实时文本更新:', text);
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'text-update',
payload: { text }
}
}));
});
return unsubscribeRealTime;
}, [webrtc.text.onRealTimeText]);
// 监听连接状态变化并发送事件
useEffect(() => {
console.log('WebRTC文字传输状态:', {
isConnected: webrtc.text.isConnected,
isConnecting: webrtc.text.isConnecting,
isWebSocketConnected: webrtc.text.isWebSocketConnected,
error: webrtc.text.connectionError
});
// WebSocket信令连接成功时发送事件
if (webrtc.text.isWebSocketConnected) {
console.log('WebSocket信令连接成功通知TextTransfer组件');
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'websocket-signaling-connected',
payload: { code: pickupCode, role: currentRole }
}
}));
}
// WebRTC数据通道连接中
if (webrtc.text.isConnecting) {
console.log('WebRTC数据通道连接中通知TextTransfer组件');
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'webrtc-connecting',
payload: { code: pickupCode, role: currentRole }
}
}));
}
// WebRTC数据通道连接成功
if (webrtc.text.isConnected) {
console.log('WebRTC数据通道连接成功通知TextTransfer组件');
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'webrtc-connected',
payload: { code: pickupCode, role: currentRole }
}
}));
}
if (webrtc.text.connectionError) {
console.error('WebRTC连接错误:', webrtc.text.connectionError);
window.dispatchEvent(new CustomEvent('websocket-message', {
detail: {
type: 'webrtc-error',
payload: { message: webrtc.text.connectionError }
}
}));
}
}, [webrtc.text.isConnected, webrtc.text.isConnecting, webrtc.text.isWebSocketConnected, webrtc.text.connectionError, pickupCode, currentRole]);
// 模拟WebSocket对象来保持与TextTransfer的兼容性
const mockWebSocket = {
send: (data: string) => {
// 数据必须通过WebRTC数据通道发送不能通过WebSocket
if (!webrtc.text.isConnected) {
console.warn('WebRTC数据通道未建立无法发送数据。当前状态:', {
isWebSocketConnected: webrtc.text.isWebSocketConnected,
isConnected: webrtc.text.isConnected
});
return;
}
try {
const message = JSON.parse(data);
console.log('通过WebRTC数据通道发送消息:', message.type);
switch (message.type) {
case 'text-update':
// 通过WebRTC数据通道发送实时文本更新
if (webrtc.text.sendRealTimeText) {
webrtc.text.sendRealTimeText(message.payload.text);
}
break;
case 'text-send':
// 通过WebRTC数据通道发送完整文字消息
webrtc.text.sendMessage(message.payload.text);
break;
case 'image-send':
// 通过WebRTC数据通道发送图片数据
const imageMessage = `[IMAGE]${message.payload.imageData}`;
webrtc.text.sendMessage(imageMessage);
break;
default:
console.log('未处理的消息类型:', message.type);
}
} catch (error) {
console.error('通过WebRTC发送消息失败:', error);
}
},
readyState: webrtc.text.isConnected ? 1 : 0, // WebSocket.OPEN = 1但实际是WebRTC状态
close: () => {
webrtc.text.disconnect();
}
};
return (
<TextTransfer
websocket={mockWebSocket as any}
isConnected={webrtc.text.isConnected} // WebRTC连接状态
isWebSocketConnected={webrtc.text.isWebSocketConnected} // WebSocket信令状态
currentRole={currentRole}
pickupCode={pickupCode}
onCreateWebSocket={handleCreateWebSocket}
/>
);
}

View File

@@ -2,7 +2,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useWebRTCTransfer } from '@/hooks/useWebRTCTransfer';
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
@@ -41,6 +42,10 @@ export const WebRTCFileTransfer: React.FC = () => {
const urlProcessedRef = useRef(false); // 使用 ref 防止重复处理 URL
const fileInputRef = useRef<HTMLInputElement>(null);
// 创建共享连接
const connection = useSharedWebRTCManager();
// 使用共享连接创建业务层
const {
isConnected,
isConnecting,
@@ -55,7 +60,7 @@ export const WebRTCFileTransfer: React.FC = () => {
onFileListReceived,
onFileRequested,
onFileProgress
} = useWebRTCTransfer();
} = useFileTransferBusiness(connection);
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
const joinRoom = useCallback(async (code: string) => {

View File

@@ -1,84 +1,20 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useWebRTCTransfer } from '@/hooks/useWebRTCTransfer';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Send, Copy, Link, Upload, Download, X } from 'lucide-react';
import QRCodeDisplay from '@/components/QRCodeDisplay';
interface Message {
id: string;
type: 'text' | 'image';
content: string;
timestamp: Date;
sender: 'self' | 'peer';
fileName?: string;
}
import { Send, Download, X } from 'lucide-react';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
export const WebRTCTextImageTransfer: React.FC = () => {
const searchParams = useSearchParams();
const router = useRouter();
const { showToast } = useToast();
// 状态管理
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [pickupCode, setPickupCode] = useState('');
const [inputCode, setInputCode] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [textInput, setTextInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const {
text: textTransfer,
file: fileTransfer,
connectAll,
disconnectAll,
hasAnyConnection,
isAnyConnecting,
hasAnyError
} = useWebRTCTransfer();
// 加入房间 - 提前定义以供 useEffect 使用
const joinRoom = useCallback(async (code: string) => {
const trimmedCode = code.trim().toUpperCase();
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
// 检查是否已经在连接或已经连接
if (isAnyConnecting) {
console.log('已经在连接中,跳过重复请求');
return;
}
if (hasAnyConnection) {
console.log('已经连接,跳过重复请求');
return;
}
try {
setPickupCode(trimmedCode);
await connectAll(trimmedCode, 'receiver');
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
} catch (error) {
console.error('加入房间失败:', error);
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
}
}, [isAnyConnecting, hasAnyConnection, connectAll]);
// 从URL参数中获取初始模式
useEffect(() => {
@@ -92,24 +28,11 @@ export const WebRTCTextImageTransfer: React.FC = () => {
setMode(urlMode);
setHasProcessedInitialUrl(true);
if (code && urlMode === 'receive') {
setInputCode(code);
// 延迟执行连接,避免重复调用
const timeoutId = setTimeout(() => {
// 检查是否已经连接或正在连接
if (!hasAnyConnection && !isAnyConnecting) {
joinRoom(code);
}
}, 100);
return () => clearTimeout(timeoutId);
}
}
}, [searchParams, hasProcessedInitialUrl, hasAnyConnection, isAnyConnecting, joinRoom]);
}, [searchParams, hasProcessedInitialUrl]);
// 更新URL参数
const updateMode = useCallback((newMode: 'send' | 'receive') => {
const updateMode = (newMode: 'send' | 'receive') => {
console.log('=== 切换模式 ===', newMode);
setMode(newMode);
@@ -122,226 +45,11 @@ export const WebRTCTextImageTransfer: React.FC = () => {
}
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
// 监听文本消息
useEffect(() => {
const cleanup = textTransfer.onMessageReceived((message) => {
setMessages(prev => [...prev, {
id: message.id,
type: 'text',
content: message.text,
timestamp: new Date(message.timestamp),
sender: 'peer'
}]);
// 移除不必要的Toast提示 - 消息在聊天界面中已经显示了
});
return cleanup;
}, [textTransfer.onMessageReceived]);
// 监听打字状态
useEffect(() => {
const cleanup = textTransfer.onTypingStatus((typing) => {
setIsTyping(typing);
});
return cleanup;
}, [textTransfer.onTypingStatus]);
// 监听文件(图片)接收
useEffect(() => {
const cleanup = fileTransfer.onFileReceived((fileData) => {
if (fileData.file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target?.result as string;
setMessages(prev => [...prev, {
id: fileData.id,
type: 'image',
content: imageData,
timestamp: new Date(),
sender: 'peer',
fileName: fileData.file.name
}]);
// 移除不必要的Toast提示 - 图片在聊天界面中已经显示了
};
reader.readAsDataURL(fileData.file);
}
});
return cleanup;
}, [fileTransfer.onFileReceived]);
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 创建空房间
const createRoom = async () => {
try {
const response = await fetch('/api/create-room', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'message',
initialText: textInput.trim() || '',
hasImages: false,
maxFileSize: 5 * 1024 * 1024,
settings: {
enableRealTimeText: true,
enableImageTransfer: true
}
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '创建房间失败');
}
const code = data.code;
setPickupCode(code);
await connectAll(code, 'sender');
// 如果有初始文本,发送它
if (textInput.trim()) {
setTimeout(() => {
sendTextMessage();
}, 1000);
}
showToast(`消息房间创建成功!取件码: ${code}`, "success");
} catch (error) {
console.error('创建房间失败:', error);
showToast(error instanceof Error ? error.message : '创建房间失败', "error");
}
};
// 发送文本消息
const sendTextMessage = () => {
if (!textInput.trim() || !textTransfer.isConnected) return;
const message = {
id: `msg_${Date.now()}`,
type: 'text' as const,
content: textInput.trim(),
timestamp: new Date(),
sender: 'self' as const
};
setMessages(prev => [...prev, message]);
textTransfer.sendMessage(textInput.trim());
setTextInput('');
// 重置自动调整高度
if (textareaRef.current) {
textareaRef.current.style.height = '40px';
}
};
// 处理文本输入变化(实时同步)
const handleTextInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setTextInput(value);
// 自动调整高度
const textarea = e.target;
textarea.style.height = '40px';
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
// 发送实时文字(如果已连接)
if (textTransfer.isConnected) {
// 发送打字状态
textTransfer.sendTypingStatus(value.length > 0);
// 清除之前的定时器
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// 设置新的定时器来停止打字状态
if (value.length > 0) {
typingTimeoutRef.current = setTimeout(() => {
textTransfer.sendTypingStatus(false);
}, 2000);
}
}
};
// 处理图片选择
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', "error");
return;
}
if (file.size > 5 * 1024 * 1024) {
showToast('图片文件大小不能超过5MB', "error");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target?.result as string;
const message = {
id: `img_${Date.now()}`,
type: 'image' as const,
content: imageData,
timestamp: new Date(),
sender: 'self' as const,
fileName: file.name
};
setMessages(prev => [...prev, message]);
if (fileTransfer.isConnected) {
fileTransfer.sendFile(file);
// 移除发送图片的Toast提示 - 图片在聊天界面中已经显示了
}
};
reader.readAsDataURL(file);
event.target.value = '';
};
// 复制分享链接
const copyShareLink = () => {
const baseUrl = window.location.origin + window.location.pathname;
const shareLink = `${baseUrl}?type=message&mode=receive&code=${pickupCode}`;
navigator.clipboard.writeText(shareLink).then(() => {
showToast('分享链接已复制', "success");
}).catch(() => {
showToast('复制失败,请手动复制', "error");
});
};
// 复制取件码
const copyCode = () => {
navigator.clipboard.writeText(pickupCode);
showToast("取件码已复制", "success");
};
// 重新开始
const restart = () => {
setPickupCode('');
setInputCode('');
setMessages([]);
setTextInput('');
setIsTyping(false);
// 重新开始函数
const handleRestart = () => {
setPreviewImage(null);
disconnectAll();
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'message');
@@ -350,7 +58,7 @@ export const WebRTCTextImageTransfer: React.FC = () => {
router.push(`?${params.toString()}`, { scroll: false });
};
const pickupLink = pickupCode ? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=message&mode=receive&code=${pickupCode}` : '';
const code = searchParams.get('code') || '';
return (
<div className="space-y-4 sm:space-y-6">
@@ -362,8 +70,8 @@ export const WebRTCTextImageTransfer: React.FC = () => {
onClick={() => updateMode('send')}
className="px-6 py-2 rounded-lg"
>
<MessageSquare className="w-4 h-4 mr-2" />
<Send className="w-4 h-4 mr-2" />
</Button>
<Button
variant={mode === 'receive' ? 'default' : 'ghost'}
@@ -371,371 +79,23 @@ export const WebRTCTextImageTransfer: React.FC = () => {
className="px-6 py-2 rounded-lg"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</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">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
{mode === 'send' ? (
<div className="space-y-6">
{!pickupCode ? (
// 创建房间前的界面 - 和文件传输完全一致的结构
<div className="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-green-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"></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 ${textTransfer.isWebSocketConnected ? 'bg-green-500' : '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 ${textTransfer.isConnected ? 'bg-green-500' : 'bg-slate-400'}`}></div>
<span className="text-slate-600">RTC</span>
</div>
</div>
</div>
</div>
{/* 消息输入区域 */}
<div className="border-2 border-dashed border-slate-300 rounded-xl p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<textarea
ref={textareaRef}
value={textInput}
onChange={handleTextInputChange}
placeholder="输入要发送的消息..."
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"
rows={4}
style={{ minHeight: '100px', maxHeight: '200px' }}
/>
<div className="mt-2 text-xs text-slate-500">
{textInput.length}/50000
</div>
</div>
{/* 图片上传按钮 */}
<div className="flex items-center space-x-2">
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
className="flex items-center space-x-1"
>
<Image className="w-4 h-4" />
<span></span>
</Button>
<span className="text-xs text-slate-500"> JPG, PNG, GIF 5MB</span>
</div>
</div>
</div>
{/* 创建房间按钮 */}
<div className="flex justify-center">
<Button
onClick={createRoom}
disabled={isAnyConnecting}
className="px-8 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl shadow-lg"
>
{isAnyConnecting ? '创建中...' : '创建消息房间'}
</Button>
</div>
</div>
) : (
// 房间已创建,显示取件码和聊天界面
<div className="space-y-6">
{/* 取件码显示 */}
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
<h3 className="text-lg font-semibold text-emerald-800 mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<div className="text-sm text-emerald-700 mb-2"></div>
<div className="flex items-center space-x-2">
<span className="text-3xl font-mono font-bold text-emerald-800 bg-white px-4 py-2 rounded-lg">
{pickupCode}
</span>
<Button onClick={copyCode} size="sm" variant="outline">
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<Button onClick={copyShareLink} className="w-full" size="sm">
<Link className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex items-center justify-center">
<QRCodeDisplay value={pickupLink} size={120} />
</div>
</div>
</div>
{/* 连接状态 */}
{hasAnyConnection ? (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-blue-700 text-sm"> </p>
</div>
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="text-center">
<p className="text-amber-700 mb-2">...</p>
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600 mx-auto"></div>
</div>
</div>
)}
{/* 聊天界面 */}
{hasAnyConnection && (
<div className="space-y-4">
{/* 消息历史 */}
<div className="bg-slate-50 rounded-lg p-4 max-h-80 overflow-y-auto">
{messages.length === 0 ? (
<p className="text-slate-500 text-center"></p>
) : (
<div className="space-y-3">
{messages.map((message) => (
<div key={message.id} className={`flex ${message.sender === 'self' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-xs ${message.sender === 'self' ? 'bg-emerald-500 text-white' : 'bg-white'} rounded-lg p-3 shadow`}>
{message.type === 'text' ? (
<p className="break-words">{message.content}</p>
) : (
<div>
<img
src={message.content}
alt={message.fileName}
className="max-w-full h-auto rounded cursor-pointer"
onClick={() => setPreviewImage(message.content)}
/>
<p className="text-xs mt-1 opacity-75">{message.fileName}</p>
</div>
)}
</div>
</div>
))}
{/* 打字状态指示器 */}
{isTyping && (
<div className="flex justify-start">
<div className="bg-gray-200 rounded-lg p-3">
<p className="text-gray-600 text-sm">...</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* 消息输入区域 */}
<div className="flex space-x-2">
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
>
<Image className="w-4 h-4" />
</Button>
<textarea
ref={textareaRef}
value={textInput}
onChange={handleTextInputChange}
placeholder="输入消息..."
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"
rows={1}
style={{ minHeight: '40px', maxHeight: '120px' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendTextMessage();
}
}}
/>
<Button onClick={sendTextMessage} disabled={!textInput.trim()} className="bg-emerald-500 hover:bg-emerald-600">
<Send className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
)}
</div>
<WebRTCTextSender onRestart={handleRestart} onPreviewImage={setPreviewImage} />
) : (
// 接收模式
<div className="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-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"></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">
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-green-500' : 'bg-slate-400'}`}></div>
<span className="text-slate-600">WS</span>
</div>
<div className="text-slate-300">|</div>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-green-500' : 'bg-slate-400'}`}></div>
<span className="text-slate-600">RTC</span>
</div>
</div>
</div>
</div>
{!hasAnyConnection ? (
<div className="border-2 border-dashed border-slate-300 rounded-xl p-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
6
</label>
<div className="flex space-x-2">
<input
type="text"
value={inputCode}
onChange={(e) => setInputCode(e.target.value.toUpperCase())}
placeholder="取件码"
maxLength={6}
className="flex-1 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-lg text-center"
/>
<Button
onClick={() => joinRoom(inputCode)}
disabled={!inputCode.trim() || isAnyConnecting}
className="px-6"
>
{isAnyConnecting ? '连接中...' : '加入'}
</Button>
</div>
</div>
</div>
) : (
// 已连接,显示消息界面
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-800 mb-1"></h4>
<p className="text-blue-700">: {pickupCode}</p>
</div>
<div className="bg-slate-50 rounded-lg p-4 max-h-80 overflow-y-auto">
{messages.length === 0 ? (
<p className="text-slate-500 text-center">...</p>
) : (
<div className="space-y-3">
{messages.map((message) => (
<div key={message.id} className={`flex ${message.sender === 'self' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-xs ${message.sender === 'self' ? 'bg-blue-500 text-white' : 'bg-white'} rounded-lg p-3 shadow`}>
{message.type === 'text' ? (
<p className="break-words">{message.content}</p>
) : (
<div>
<img
src={message.content}
alt={message.fileName}
className="max-w-full h-auto rounded cursor-pointer"
onClick={() => setPreviewImage(message.content)}
/>
<p className="text-xs mt-1 opacity-75">{message.fileName}</p>
</div>
)}
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-gray-200 rounded-lg p-3">
<p className="text-gray-600 text-sm">...</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* 接收方也可以发送消息 */}
<div className="flex space-x-2">
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
>
<Image className="w-4 h-4" />
</Button>
<textarea
ref={textareaRef}
value={textInput}
onChange={handleTextInputChange}
placeholder="回复消息..."
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={1}
style={{ minHeight: '40px', maxHeight: '120px' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendTextMessage();
}
}}
/>
<Button onClick={sendTextMessage} disabled={!textInput.trim()}>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
<WebRTCTextReceiver
initialCode={code}
onPreviewImage={setPreviewImage}
onRestart={handleRestart}
/>
)}
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
{/* 图片预览模态框 */}
{previewImage && (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={() => setPreviewImage(null)}>
@@ -751,15 +111,6 @@ export const WebRTCTextImageTransfer: React.FC = () => {
</div>
</div>
)}
{/* 错误状态和重新开始按钮 */}
{hasAnyError && (
<div className="text-center">
<Button onClick={restart} variant="outline">
</Button>
</div>
)}
</div>
);
};

View File

@@ -1,35 +0,0 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,366 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
import { useTextTransferBusiness } from '@/hooks/webrtc/useTextTransferBusiness';
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Download } from 'lucide-react';
interface WebRTCTextReceiverProps {
initialCode?: string;
onPreviewImage: (imageUrl: string) => void;
onRestart?: () => void;
}
export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
initialCode = '',
onPreviewImage,
onRestart
}) => {
const { showToast } = useToast();
// 状态管理
const [pickupCode, setPickupCode] = useState('');
const [inputCode, setInputCode] = useState(initialCode);
const [receivedText, setReceivedText] = useState(''); // 实时接收的文本内容
const [receivedImages, setReceivedImages] = useState<Array<{ id: string, content: string, fileName?: string }>>([]);
const [isTyping, setIsTyping] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const hasTriedAutoConnect = useRef(false);
// 创建共享连接 [需要优化]
const connection = useSharedWebRTCManager();
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
const fileTransfer = useFileTransferBusiness(connection);
// 连接所有传输通道
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
console.log('=== 连接所有传输通道 ===', { code, role });
await connection.connect(code, role);
// await Promise.all([
// textTransfer.connect(code, role),
// fileTransfer.connect(code, role)
// ]);
}, [textTransfer, fileTransfer]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
// 监听连接错误并显示 toast
useEffect(() => {
if (hasAnyError) {
console.error('[WebRTCTextReceiver] 连接错误:', hasAnyError);
showToast(hasAnyError, 'error');
}
}, [hasAnyError, showToast]);
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
setIsValidating(true);
console.log('开始验证取件码:', code);
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
const errorMessage = data.message || '取件码验证失败';
showToast(errorMessage, 'error');
console.log('验证失败:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
console.error('验证取件码时发生错误:', error);
const errorMessage = '网络错误,请检查连接后重试';
showToast(errorMessage, 'error');
return false;
} finally {
setIsValidating(false);
}
};
// 重新开始
const restart = () => {
setPickupCode('');
setInputCode('');
setReceivedText('');
setReceivedImages([]);
setIsTyping(false);
// 断开连接
textTransfer.disconnect();
fileTransfer.disconnect();
if (onRestart) {
onRestart();
}
};
// 加入房间
const joinRoom = useCallback(async (code: string) => {
const trimmedCode = code.trim().toUpperCase();
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
if (isAnyConnecting || isValidating) {
console.log('已经在连接中,跳过重复请求');
return;
}
if (hasAnyConnection) {
console.log('已经连接,跳过重复请求');
return;
}
try {
console.log('=== 开始验证和连接房间 ===', trimmedCode);
const isValid = await validatePickupCode(trimmedCode);
if (!isValid) {
return;
}
setPickupCode(trimmedCode);
await connectAll(trimmedCode, 'receiver');
console.log('=== 房间连接成功 ===', trimmedCode);
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
} catch (error) {
console.error('加入房间失败:', error);
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
setPickupCode('');
}
}, [isAnyConnecting, hasAnyConnection, connectAll, showToast, isValidating, validatePickupCode]);
// 监听实时文本同步
useEffect(() => {
const cleanup = textTransfer.onTextSync((text: string) => {
setReceivedText(text);
});
return cleanup;
}, [textTransfer.onTextSync]);
// 监听打字状态
useEffect(() => {
const cleanup = textTransfer.onTypingStatus((typing: boolean) => {
setIsTyping(typing);
});
return cleanup;
}, [textTransfer.onTypingStatus]);
// 监听文件(图片)接收
useEffect(() => {
const cleanup = fileTransfer.onFileReceived((fileData) => {
if (fileData.file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target?.result as string;
setReceivedImages(prev => [...prev, {
id: fileData.id,
content: imageData,
fileName: fileData.file.name
}]);
};
reader.readAsDataURL(fileData.file);
}
});
return cleanup;
}, [fileTransfer.onFileReceived]);
// 处理初始代码连接
useEffect(() => {
// initialCode isAutoConnected
console.log(`initialCode: ${initialCode}, hasTriedAutoConnect: ${hasTriedAutoConnect.current}`);
if (initialCode && initialCode.length === 6 && !hasTriedAutoConnect.current) {
console.log('=== 自动连接初始代码 ===', initialCode);
hasTriedAutoConnect.current = true
setInputCode(initialCode);
joinRoom(initialCode);
return;
}
}, [initialCode]);
return (
<div className="space-y-6">
{!hasAnyConnection ? (
// 输入取件码界面
<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>
<form onSubmit={(e) => { e.preventDefault(); joinRoom(inputCode); }} className="space-y-4 sm:space-y-6">
<div className="space-y-3">
<div className="relative">
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
placeholder="请输入取件码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isValidating || isAnyConnecting}
/>
</div>
<p className="text-center text-xs sm:text-sm text-slate-500">
{inputCode.length}/6
</p>
</div>
<div className="flex justify-center">
<Button
type="submit"
disabled={inputCode.length !== 6 || isValidating || isAnyConnecting}
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"
>
{isValidating ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
) : isAnyConnecting ? (
<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>
</div>
</form>
</div>
) : (
// 已连接,显示实时文本
<div className="space-y-6">
<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">
<MessageSquare 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">
<span className="text-emerald-600"> </span>
</p>
</div>
</div>
</div>
{/* 连接成功状态 */}
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
<h4 className="font-semibold text-emerald-800 mb-1"></h4>
<p className="text-emerald-700">: {pickupCode}</p>
</div>
{/* 实时文本显示区域 */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<MessageSquare className="w-5 h-5 mr-2" />
</h4>
<div className="flex items-center space-x-3 text-sm">
<span className="text-slate-500">
{receivedText.length} / 50,000
</span>
{textTransfer.isConnected && (
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="font-medium">WebRTC实时同步</span>
</div>
)}
</div>
</div>
<div className="relative">
<textarea
value={receivedText}
readOnly
placeholder="等待对方发送文字内容...&#10;&#10;💡 实时同步显示,对方的编辑会立即显示在这里"
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg bg-slate-50 text-slate-700 placeholder-slate-400 resize-none"
/>
{!receivedText && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 rounded-lg border border-slate-300">
<div className="text-center">
<MessageSquare className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600">...</p>
<p className="text-sm text-slate-500 mt-2"></p>
</div>
</div>
)}
</div>
{/* 打字状态提示 */}
{isTyping && (
<div className="flex items-center space-x-2 mt-3 text-sm text-slate-500">
<div className="flex space-x-1">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="w-1 h-1 bg-slate-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.1}s` }}
></div>
))}
</div>
<span className="italic">...</span>
</div>
)}
</div>
{/* 接收到的图片 */}
{receivedImages.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-6 border border-slate-200">
<h4 className="text-lg font-semibold text-slate-800 mb-4"></h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{receivedImages.map((image) => (
<img
key={image.id}
src={image.content}
alt={image.fileName}
className="w-full h-32 object-cover rounded-lg border cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => onPreviewImage(image.content)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,545 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
import { useTextTransferBusiness } from '@/hooks/webrtc/useTextTransferBusiness';
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
import QRCodeDisplay from '@/components/QRCodeDisplay';
interface WebRTCTextSenderProps {
onRestart?: () => void;
onPreviewImage?: (imageUrl: string) => void;
}
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage }) => {
const { showToast } = useToast();
// 状态管理
const [pickupCode, setPickupCode] = useState('');
const [textInput, setTextInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [sentImages, setSentImages] = useState<Array<{id: string, url: string, fileName: string}>>([]);
// Refs
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 创建共享连接
const connection = useSharedWebRTCManager();
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
const fileTransfer = useFileTransferBusiness(connection);
// 连接所有传输通道
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
console.log('=== 连接所有传输通道 ===', { code, role });
await Promise.all([
textTransfer.connect(code, role),
fileTransfer.connect(code, role)
]);
}, [textTransfer, fileTransfer]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
// 重新开始
const restart = () => {
setPickupCode('');
setTextInput('');
setIsTyping(false);
// 清理发送的图片URL
sentImages.forEach(img => URL.revokeObjectURL(img.url));
setSentImages([]);
// 断开连接
textTransfer.disconnect();
fileTransfer.disconnect();
if (onRestart) {
onRestart();
}
};
// 监听实时文本同步(发送方可以看到自己发的内容被对方接收)
useEffect(() => {
const cleanup = textTransfer.onTextSync((text: string) => {
// 这里可以处理对方的实时文本,但通常发送方不需要监听自己发送的内容
console.log('收到对方的实时文本同步:', text);
});
return cleanup;
}, [textTransfer.onTextSync]);
// 监听打字状态
useEffect(() => {
const cleanup = textTransfer.onTypingStatus((typing: boolean) => {
setIsTyping(typing);
});
return cleanup;
}, [textTransfer.onTypingStatus]);
// 监听文件(图片)接收
useEffect(() => {
const cleanup = fileTransfer.onFileReceived((fileData) => {
if (fileData.file.type.startsWith('image/')) {
// 只显示toast提示不保存消息记录
showToast(`收到图片: ${fileData.file.name}`, "success");
}
});
return cleanup;
}, [fileTransfer.onFileReceived]);
// 创建空房间
const createRoom = useCallback(async () => {
try {
console.log('=== 开始创建房间 ===');
const currentText = textInput.trim();
const response = await fetch('/api/create-room', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'message',
initialText: currentText || '',
hasImages: false,
maxFileSize: 5 * 1024 * 1024,
settings: {
enableRealTimeText: true,
enableImageTransfer: true
}
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '创建房间失败');
}
const code = data.code;
console.log('=== 房间创建成功 ===', code);
setPickupCode(code);
await connectAll(code, 'sender');
// 如果有初始文本,发送它
if (currentText) {
setTimeout(() => {
if (textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(currentText);
// 重置自动调整高度
if (textareaRef.current) {
textareaRef.current.style.height = '40px';
}
}
}, 1000);
}
showToast(`消息房间创建成功!取件码: ${code}`, "success");
} catch (error) {
console.error('创建房间失败:', error);
showToast(error instanceof Error ? error.message : '创建房间失败', "error");
}
}, [textInput, connectAll, showToast, textTransfer]);
// 处理文本输入变化(实时同步)
const handleTextInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setTextInput(value);
// 自动调整高度 - 修复高度计算
const textarea = e.target;
textarea.style.height = 'auto'; // 先重置为auto
const newHeight = Math.min(Math.max(textarea.scrollHeight, 100), 300); // 最小100px最大300px
textarea.style.height = `${newHeight}px`;
// 实时同步文本内容(如果已连接)
if (textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(value);
// 发送打字状态
textTransfer.sendTypingStatus(value.length > 0);
// 清除之前的定时器
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// 设置新的定时器来停止打字状态
if (value.length > 0) {
typingTimeoutRef.current = setTimeout(() => {
textTransfer.sendTypingStatus(false);
}, 1000); // 缩短到1秒
}
}
};
// 处理图片发送(文件选择或粘贴)
const handleImageSend = async (file: File) => {
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', "error");
return;
}
if (file.size > 5 * 1024 * 1024) {
showToast('图片文件大小不能超过5MB', "error");
return;
}
// 创建预览URL并添加到显示列表
const imageUrl = URL.createObjectURL(file);
const imageId = `img_${Date.now()}`;
setSentImages(prev => [...prev, {
id: imageId,
url: imageUrl,
fileName: file.name
}]);
// 发送文件
if (fileTransfer.isConnected) {
fileTransfer.sendFile(file);
showToast('图片发送中...', "success");
} else {
showToast('请先连接到房间', "error");
}
};
// 处理图片选择
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
handleImageSend(file);
event.target.value = '';
};
// 处理键盘粘贴
const handlePaste = async (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
await handleImageSend(file);
}
break;
}
}
};
// 复制分享链接
const copyShareLink = () => {
const baseUrl = window.location.origin + window.location.pathname;
const shareLink = `${baseUrl}?type=message&mode=receive&code=${pickupCode}`;
navigator.clipboard.writeText(shareLink).then(() => {
showToast('分享链接已复制', "success");
}).catch(() => {
showToast('复制失败,请手动复制', "error");
});
};
// 复制取件码
const copyCode = () => {
navigator.clipboard.writeText(pickupCode);
showToast("取件码已复制", "success");
};
const pickupLink = pickupCode ? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=message&mode=receive&code=${pickupCode}` : '';
return (
<div className="space-y-6">
{!pickupCode ? (
// 创建房间前的界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<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"></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 ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : '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 ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
</div>
</div>
</div>
</div>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<MessageSquare className="w-10 h-10 text-blue-500" />
</div>
<h3 className="text-xl font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
onClick={createRoom}
disabled={isAnyConnecting}
className="px-8 py-3 bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isAnyConnecting ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Send className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
</div>
) : (
// 房间已创建,显示取件码和文本传输界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<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">
{hasAnyConnection ? '实时编辑,对方可以同步看到' : '等待对方连接'}
</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 ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-red-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 ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
</div>
</div>
</div>
</div>
{/* 文字编辑区域 - 移到最上面 */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<MessageSquare className="w-5 h-5 mr-2" />
</h4>
<div className="flex items-center space-x-3 text-sm">
<span className="text-slate-500">{textInput.length} / 50,000 </span>
{textTransfer.isConnected && (
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="font-medium">WebRTC实时同步</span>
</div>
)}
{textTransfer.isWebSocketConnected && !textTransfer.isConnected && (
<div className="flex items-center space-x-1 bg-blue-100 text-blue-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
<span className="font-medium"></span>
</div>
)}
</div>
</div>
<textarea
ref={textareaRef}
value={textInput}
onChange={handleTextInputChange}
onPaste={handlePaste}
placeholder="在这里编辑文字内容...&#10;&#10;💡 支持实时同步编辑,对方可以看到你的修改&#10;💡 可以直接粘贴图片 (Ctrl+V)"
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none text-slate-700 placeholder-slate-400"
/>
<div className="flex items-center justify-between mt-3">
<div className="flex items-center space-x-2">
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
className="flex items-center space-x-1"
>
<Image className="w-4 h-4" />
<span></span>
</Button>
{isTyping && (
<span className="text-sm text-slate-500 italic">...</span>
)}
{textTransfer.isConnected && (
<div className="flex items-center space-x-1 bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
<span className="font-medium"></span>
</div>
)}
</div>
</div>
</div>
{/* 发送的图片显示 */}
{sentImages.length > 0 && (
<div className="border-t border-slate-200 pt-6">
<h4 className="text-lg font-semibold text-slate-800 mb-4"></h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
{sentImages.map((image) => (
<div key={image.id} className="relative">
<img
src={image.url}
alt={image.fileName}
className="w-full h-32 object-cover rounded-lg border cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => onPreviewImage?.(image.url)}
/>
<div className="absolute bottom-1 left-1 right-1 bg-black/50 text-white text-xs px-2 py-1 rounded truncate">
{image.fileName}
</div>
</div>
))}
</div>
</div>
)}
{/* 取件码显示 - 和文件传输一致的风格 */}
<div className="border-t border-slate-200 pt-6">
{/* 左上角状态提示 - 类似已选择文件的风格 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600"></p>
</div>
</div>
</div>
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:取件码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
{pickupCode}
</div>
</div>
<Button
onClick={copyCode}
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
>
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={pickupLink}
size={120}
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
使访
</div>
</div>
</div>
{/* 底部:取件链接 */}
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{pickupLink}
</div>
</div>
<Button
onClick={copyShareLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
</Button>
</div>
</div>
</div>
</div>
)}
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
</div>
);
};

View File

@@ -1,193 +0,0 @@
"use client"
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -1,110 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
export const useTabManager = (isConnected: boolean, pickupCode: string, isConnecting: boolean) => {
const searchParams = useSearchParams();
const router = useRouter();
const [activeTab, setActiveTab] = useState<'file' | 'text' | 'desktop'>('file');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTabSwitch, setPendingTabSwitch] = useState<string>('');
// 从URL参数中获取初始状态
useEffect(() => {
const type = searchParams.get('type') as 'file' | 'text' | 'desktop';
if (type && ['file', 'text', 'desktop'].includes(type)) {
setActiveTab(type);
}
}, [searchParams]);
// 更新URL参数
const updateUrlParams = useCallback((tab: string, mode?: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('type', tab);
if (mode) {
params.set('mode', mode);
}
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
// 处理tab切换
const handleTabChange = useCallback((value: string) => {
// 检查是否已经建立连接或生成取件码
const hasActiveConnection = isConnected || pickupCode || isConnecting;
if (hasActiveConnection && value !== activeTab) {
// 如果已有活跃连接且要切换到不同的tab显示确认对话框
setPendingTabSwitch(value);
setShowConfirmDialog(true);
return;
}
// 如果没有活跃连接,正常切换
setActiveTab(value as 'file' | 'text' | 'desktop');
updateUrlParams(value);
}, [updateUrlParams, isConnected, pickupCode, isConnecting, activeTab]);
// 确认切换tab
const confirmTabSwitch = useCallback(() => {
if (pendingTabSwitch) {
const currentUrl = window.location.origin + window.location.pathname;
const newUrl = `${currentUrl}?type=${pendingTabSwitch}`;
// 在新标签页打开
window.open(newUrl, '_blank');
// 关闭对话框并清理状态
setShowConfirmDialog(false);
setPendingTabSwitch('');
}
}, [pendingTabSwitch]);
// 取消切换tab
const cancelTabSwitch = useCallback(() => {
setShowConfirmDialog(false);
setPendingTabSwitch('');
}, []);
// 获取模式描述
const getModeDescription = useCallback(() => {
let currentMode = '';
let targetMode = '';
switch (activeTab) {
case 'file':
currentMode = '文件传输';
break;
case 'text':
currentMode = '文字传输(开发中)';
break;
case 'desktop':
currentMode = '桌面共享(开发中)';
break;
}
switch (pendingTabSwitch) {
case 'file':
targetMode = '文件传输';
break;
case 'text':
targetMode = '文字传输(开发中)';
break;
case 'desktop':
targetMode = '桌面共享(开发中)';
break;
}
return `当前${currentMode}会话进行中,是否要在新标签页中打开${targetMode}`;
}, [activeTab, pendingTabSwitch]);
return {
activeTab,
showConfirmDialog,
setShowConfirmDialog,
handleTabChange,
confirmTabSwitch,
cancelTabSwitch,
getModeDescription,
updateUrlParams
};
};

View File

@@ -1,38 +0,0 @@
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
interface UseUrlHandlerProps {
isConnected: boolean;
pickupCode: string;
setCurrentRole: (role: 'sender' | 'receiver') => void;
joinRoom: (code: string) => Promise<void>;
}
export const useUrlHandler = ({ isConnected, pickupCode, setCurrentRole, joinRoom }: UseUrlHandlerProps) => {
const searchParams = useSearchParams();
// 处理URL参数中的取件码仅在首次加载时
useEffect(() => {
const code = searchParams.get('code');
const type = searchParams.get('type');
const mode = searchParams.get('mode');
// 只有在完整的URL参数情况下才自动加入房间
// 1. 有效的6位取件码
// 2. 当前未连接
// 3. 不是已经连接的同一个房间码
// 4. 必须是完整的链接有type、mode=receive和code参数
// 5. 不是文字类型文字类型由TextTransfer组件处理
if (code &&
code.length === 6 &&
!isConnected &&
pickupCode !== code.toUpperCase() &&
type &&
type !== 'text' &&
mode === 'receive') {
console.log('自动加入文件房间:', code.toUpperCase());
setCurrentRole('receiver');
joinRoom(code.toUpperCase());
}
}, [searchParams]); // 只依赖 searchParams避免重复触发
};

View File

@@ -1,28 +0,0 @@
import { useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
export const useUtilities = () => {
const { showToast } = useToast();
// 复制到剪贴板
const copyToClipboard = useCallback(async (text: string, successMessage: string) => {
try {
await navigator.clipboard.writeText(text);
showToast(successMessage, 'success');
} catch (err) {
console.error('复制失败:', err);
showToast('复制失败,请手动复制', 'error');
}
}, [showToast]);
// 显示通知
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => {
console.log(`[${type.toUpperCase()}] ${message}`);
showToast(message, type);
}, [showToast]);
return {
copyToClipboard,
showNotification
};
};

View File

@@ -1,160 +0,0 @@
import { useCallback } from 'react';
import { useFileTransferBusiness } from './webrtc/useFileTransferBusiness';
import { useTextTransferBusiness } from './webrtc/useTextTransferBusiness';
// 文件信息接口(与现有组件兼容)
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
/**
* 统一的 WebRTC 传输 Hook - 新架构版本
* 整合文件传输、文字传输等多种业务功能
*
* 设计原则:
* 1. 独立连接:每个业务功能有自己独立的 WebRTC 连接
* 2. 复用逻辑所有业务功能复用相同的连接建立逻辑useWebRTCCore
* 3. 简单精准:避免过度抽象,每个功能模块职责清晰
* 4. 易于扩展:可以轻松添加新的业务功能(如屏幕共享、语音传输等)
* 5. 向后兼容:与现有的 WebRTCFileTransfer 组件保持接口兼容
*/
export function useWebRTCTransfer() {
const fileTransfer = useFileTransferBusiness();
const textTransfer = useTextTransferBusiness();
// 文件传输连接
const connectFileTransfer = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
console.log('连接文件传输通道...');
return fileTransfer.connect(roomCode, role);
}, [fileTransfer.connect]);
// 文字传输连接
const connectTextTransfer = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
console.log('连接文字传输通道...');
return textTransfer.connect(roomCode, role);
}, [textTransfer.connect]);
// 统一连接方法 - 同时连接所有功能
const connectAll = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('=== 启动 WebRTC 多功能传输 ===');
console.log('房间代码:', roomCode, '角色:', role);
console.log('将建立独立的文件传输和文字传输连接');
// 并行连接所有功能(各自独立的连接)
await Promise.all([
connectFileTransfer(roomCode, role),
connectTextTransfer(roomCode, role),
]);
console.log('所有传输通道连接完成');
}, [connectFileTransfer, connectTextTransfer]);
// 统一断开连接
const disconnectAll = useCallback(() => {
console.log('断开所有 WebRTC 传输连接');
fileTransfer.disconnect();
textTransfer.disconnect();
}, [fileTransfer.disconnect, textTransfer.disconnect]);
// 为了与现有组件兼容,提供旧的接口形式
return {
// ===== 与现有组件兼容的接口 =====
// 连接状态
isConnected: fileTransfer.isConnected,
isConnecting: fileTransfer.isConnecting,
isWebSocketConnected: fileTransfer.isWebSocketConnected,
error: fileTransfer.connectionError || fileTransfer.error,
// 传输状态
isTransferring: fileTransfer.isTransferring,
transferProgress: fileTransfer.progress,
receivedFiles: fileTransfer.receivedFiles,
// 主要方法
connect: fileTransfer.connect,
disconnect: fileTransfer.disconnect,
sendFile: fileTransfer.sendFile,
requestFile: fileTransfer.requestFile,
sendFileList: fileTransfer.sendFileList,
// 回调方法
onFileRequested: fileTransfer.onFileRequested,
onFileReceived: fileTransfer.onFileReceived,
onFileProgress: fileTransfer.onFileProgress,
onFileListReceived: fileTransfer.onFileListReceived,
// ===== 新的命名空间接口供Demo等组件使用 =====
// 统一操作
connectAll,
disconnectAll,
// 文件传输功能命名空间
file: {
// 连接状态
isConnected: fileTransfer.isConnected,
isConnecting: fileTransfer.isConnecting,
isWebSocketConnected: fileTransfer.isWebSocketConnected,
connectionError: fileTransfer.connectionError,
// 传输状态
isTransferring: fileTransfer.isTransferring,
progress: fileTransfer.progress,
error: fileTransfer.error,
receivedFiles: fileTransfer.receivedFiles,
// 方法
connect: fileTransfer.connect,
disconnect: fileTransfer.disconnect,
sendFile: fileTransfer.sendFile,
sendFileList: fileTransfer.sendFileList,
requestFile: fileTransfer.requestFile,
onFileReceived: fileTransfer.onFileReceived,
onFileRequested: fileTransfer.onFileRequested,
onFileProgress: fileTransfer.onFileProgress,
onFileListReceived: fileTransfer.onFileListReceived,
},
// 文字传输功能命名空间
text: {
// 连接状态
isConnected: textTransfer.isConnected,
isConnecting: textTransfer.isConnecting,
isWebSocketConnected: textTransfer.isWebSocketConnected,
connectionError: textTransfer.connectionError,
// 传输状态
messages: textTransfer.messages,
isTyping: textTransfer.isTyping,
error: textTransfer.error,
// 方法
connect: textTransfer.connect,
disconnect: textTransfer.disconnect,
sendMessage: textTransfer.sendMessage,
sendTypingStatus: textTransfer.sendTypingStatus,
sendRealTimeText: textTransfer.sendRealTimeText,
clearMessages: textTransfer.clearMessages,
onMessageReceived: textTransfer.onMessageReceived,
onTypingStatus: textTransfer.onTypingStatus,
onRealTimeText: textTransfer.onRealTimeText,
},
// 整体状态(用于 UI 显示)
hasAnyConnection: fileTransfer.isConnected || textTransfer.isConnected,
isAnyConnecting: fileTransfer.isConnecting || textTransfer.isConnecting,
hasAnyError: Boolean(fileTransfer.connectionError || textTransfer.connectionError),
// 可以继续添加其他业务功能
// 例如:
// screen: { ... }, // 屏幕共享
// voice: { ... }, // 语音传输
// video: { ... }, // 视频传输
};
}

View File

@@ -1,119 +0,0 @@
import { useEffect } from 'react';
import { WebSocketMessage, FileInfo, RoomStatus } from '@/types';
import { useToast } from '@/components/ui/toast-simple';
interface UseWebSocketHandlerProps {
currentRole: 'sender' | 'receiver';
setReceiverFiles: (files: FileInfo[]) => void;
setRoomStatus: (status: RoomStatus | null) => void;
setIsConnecting: (connecting: boolean) => void;
initFileTransfer: (fileInfo: any) => void;
receiveFileChunk: (chunkData: any) => void;
completeFileDownload: (fileId: string) => void;
handleFileRequest: (payload: any) => Promise<void>;
}
export const useWebSocketHandler = ({
currentRole,
setReceiverFiles,
setRoomStatus,
setIsConnecting,
initFileTransfer,
receiveFileChunk,
completeFileDownload,
handleFileRequest
}: UseWebSocketHandlerProps) => {
const { showToast } = useToast();
useEffect(() => {
const handleWebSocketMessage = (event: CustomEvent<WebSocketMessage>) => {
const message = event.detail;
console.log('收到WebSocket消息:', message.type, message);
switch (message.type) {
case 'file-list':
console.log('处理file-list消息');
if (currentRole === 'receiver') {
setReceiverFiles((message.payload.files as FileInfo[]) || []);
setIsConnecting(false);
}
break;
case 'file-list-updated':
console.log('处理file-list-updated消息');
if (currentRole === 'receiver') {
setReceiverFiles((message.payload.files as FileInfo[]) || []);
showToast('文件列表已更新,发现新文件!');
}
break;
case 'room-status':
console.log('处理room-status消息');
setRoomStatus(message.payload as unknown as RoomStatus);
break;
case 'file-info':
console.log('处理file-info消息');
if (currentRole === 'receiver') {
initFileTransfer(message.payload);
}
break;
case 'file-chunk':
console.log('处理file-chunk消息');
if (currentRole === 'receiver') {
receiveFileChunk(message.payload);
}
break;
case 'file-complete':
console.log('处理file-complete消息');
if (currentRole === 'receiver') {
completeFileDownload(message.payload.file_id as string);
}
break;
case 'file-request':
console.log('处理file-request消息');
if (currentRole === 'sender') {
handleFileRequest(message.payload);
}
break;
case 'connected':
case 'connection-established':
console.log('WebSocket连接已建立');
setIsConnecting(false);
showToast('连接成功!', 'success');
break;
case 'text-content':
console.log('处理text-content消息');
// 文本内容由TextTransfer组件处理
setIsConnecting(false);
break;
default:
// 对于任何其他消息类型,也重置连接状态(说明连接已建立)
console.log('收到消息,连接已建立,重置连接状态');
setIsConnecting(false);
break;
}
};
window.addEventListener('websocket-message', handleWebSocketMessage as EventListener);
return () => {
window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener);
};
}, [
currentRole,
setReceiverFiles,
setRoomStatus,
setIsConnecting,
initFileTransfer,
receiveFileChunk,
completeFileDownload,
handleFileRequest,
showToast
]);
};

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useWebRTCCore } from './useWebRTCCore';
import type { WebRTCConnection } from './useSharedWebRTCManager';
// 文件传输状态
interface FileTransferState {
@@ -9,7 +9,7 @@ interface FileTransferState {
receivedFiles: Array<{ id: string; file: File }>;
}
// 文件信息(用于文件列表)
// 文件信息
interface FileInfo {
id: string;
name: string;
@@ -40,22 +40,14 @@ type FileRequestedCallback = (fileId: string, fileName: string) => void;
type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; progress: number }) => void;
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
const CHANNEL_NAME = 'file-transfer';
const CHUNK_SIZE = 256 * 1024; // 256KB
/**
* 文件传输业务层
* 使用 WebRTC 核心连接逻辑实现文件传输功能
* 每个实例有独立的连接,但复用相同的连接建立逻辑
*
* 支持功能:
* - 文件发送/接收
* - 文件列表同步
* - 文件请求机制
* - 进度跟踪
* - 多文件传输
* 必须传入共享的 WebRTC 连接
*/
export function useFileTransferBusiness() {
const webrtcCore = useWebRTCCore('file-transfer');
export function useFileTransferBusiness(connection: WebRTCConnection) {
const [state, setState] = useState<FileTransferState>({
isTransferring: false,
@@ -84,11 +76,11 @@ export function useFileTransferBusiness() {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 处理文件传输消息
// 消息处理器
const handleMessage = useCallback((message: any) => {
console.log('文件传输处理消息:', message.type);
if (!message.type.startsWith('file-')) return;
switch (message.type) {
console.log('文件传输收到消息:', message.type, message); switch (message.type) {
case 'file-metadata':
const metadata: FileMetadata = message.payload;
console.log('开始接收文件:', metadata.name);
@@ -127,10 +119,7 @@ export function useFileTransferBusiness() {
progress: 100
}));
// 触发回调
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
// 清理
receivingFiles.current.delete(fileId);
}
break;
@@ -165,7 +154,6 @@ export function useFileTransferBusiness() {
const progress = (fileInfo.receivedChunks / totalChunks) * 100;
updateState({ progress });
// 触发文件级别的进度回调
fileProgressCallbacks.current.forEach(cb => cb({
fileId: fileId,
fileName: fileInfo.metadata.name,
@@ -179,23 +167,24 @@ export function useFileTransferBusiness() {
// 设置处理器
useEffect(() => {
webrtcCore.setMessageHandler(handleMessage);
webrtcCore.setDataHandler(handleData);
// 使用共享连接的注册方式
const unregisterMessage = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
const unregisterData = connection.registerDataHandler(CHANNEL_NAME, handleData);
return () => {
webrtcCore.setMessageHandler(null);
webrtcCore.setDataHandler(null);
unregisterMessage();
unregisterData();
};
}, [webrtcCore.setMessageHandler, webrtcCore.setDataHandler, handleMessage, handleData]);
}, [handleMessage, handleData]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return webrtcCore.connect(roomCode, role);
}, [webrtcCore.connect]);
return connection.connect(roomCode, role);
}, [connection]);
// 发送文件
const sendFile = useCallback(async (file: File, fileId?: string) => {
if (webrtcCore.getChannelState() !== 'open') {
if (connection.getChannelState() !== 'open') {
updateState({ error: '连接未就绪' });
return;
}
@@ -209,7 +198,7 @@ export function useFileTransferBusiness() {
try {
// 1. 发送文件元数据
webrtcCore.sendMessage({
connection.sendMessage({
type: 'file-metadata',
payload: {
id: actualFileId,
@@ -217,7 +206,7 @@ export function useFileTransferBusiness() {
size: file.size,
type: file.type
}
});
}, CHANNEL_NAME);
// 2. 分块发送文件
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
@@ -226,23 +215,22 @@ export function useFileTransferBusiness() {
const chunk = file.slice(start, end);
// 先发送块信息
webrtcCore.sendMessage({
connection.sendMessage({
type: 'file-chunk-info',
payload: {
fileId: actualFileId,
chunkIndex,
totalChunks
}
});
}, CHANNEL_NAME);
// 再发送块数据
const arrayBuffer = await chunk.arrayBuffer();
webrtcCore.sendData(arrayBuffer);
connection.sendData(arrayBuffer);
const progress = ((chunkIndex + 1) / totalChunks) * 100;
updateState({ progress });
// 触发文件级别的进度回调
fileProgressCallbacks.current.forEach(cb => cb({
fileId: actualFileId,
fileName: file.name,
@@ -256,10 +244,10 @@ export function useFileTransferBusiness() {
}
// 3. 发送完成信号
webrtcCore.sendMessage({
connection.sendMessage({
type: 'file-complete',
payload: { fileId: actualFileId }
});
}, CHANNEL_NAME);
updateState({ isTransferring: false, progress: 100 });
console.log('文件发送完成:', file.name);
@@ -271,57 +259,54 @@ export function useFileTransferBusiness() {
isTransferring: false
});
}
}, [webrtcCore.getChannelState, webrtcCore.sendMessage, webrtcCore.sendData, updateState]);
}, [connection, updateState]);
// 发送文件列表
const sendFileList = useCallback((fileList: FileInfo[]) => {
if (webrtcCore.getChannelState() !== 'open') {
if (connection.getChannelState() !== 'open') {
console.error('数据通道未准备就绪,无法发送文件列表');
return;
}
console.log('发送文件列表:', fileList);
webrtcCore.sendMessage({
connection.sendMessage({
type: 'file-list',
payload: fileList
});
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
}, CHANNEL_NAME);
}, [connection]);
// 请求文件
const requestFile = useCallback((fileId: string, fileName: string) => {
if (webrtcCore.getChannelState() !== 'open') {
if (connection.getChannelState() !== 'open') {
console.error('数据通道未准备就绪,无法请求文件');
return;
}
console.log('请求文件:', fileName, fileId);
webrtcCore.sendMessage({
connection.sendMessage({
type: 'file-request',
payload: { fileId, fileName }
});
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
}, CHANNEL_NAME);
}, [connection]);
// 注册文件接收回调
// 注册回调函数
const onFileReceived = useCallback((callback: FileReceivedCallback) => {
fileReceivedCallbacks.current.add(callback);
return () => { fileReceivedCallbacks.current.delete(callback); };
}, []);
// 注册文件请求回调
const onFileRequested = useCallback((callback: FileRequestedCallback) => {
fileRequestedCallbacks.current.add(callback);
return () => { fileRequestedCallbacks.current.delete(callback); };
}, []);
// 注册进度回调
const onFileProgress = useCallback((callback: FileProgressCallback) => {
fileProgressCallbacks.current.add(callback);
return () => { fileProgressCallbacks.current.delete(callback); };
}, []);
// 注册文件列表回调
const onFileListReceived = useCallback((callback: FileListReceivedCallback) => {
fileListCallbacks.current.add(callback);
return () => { fileListCallbacks.current.delete(callback); };
@@ -329,17 +314,17 @@ export function useFileTransferBusiness() {
return {
// 继承基础连接状态
isConnected: webrtcCore.isConnected,
isConnecting: webrtcCore.isConnecting,
isWebSocketConnected: webrtcCore.isWebSocketConnected,
connectionError: webrtcCore.error,
isConnected: connection.isConnected,
isConnecting: connection.isConnecting,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error,
// 文件传输状态
...state,
// 操作方法
connect,
disconnect: webrtcCore.disconnect,
disconnect: connection.disconnect,
sendFile,
sendFileList,
requestFile,

View File

@@ -0,0 +1,505 @@
import { useState, useRef, useCallback } from 'react';
import { config } from '@/lib/config';
// 基础连接状态
interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
error: string | null;
}
// 消息类型
interface WebRTCMessage {
type: string;
payload: any;
channel?: string;
}
// 消息和数据处理器类型
type MessageHandler = (message: WebRTCMessage) => void;
type DataHandler = (data: ArrayBuffer) => void;
// WebRTC 连接接口
export interface WebRTCConnection {
// 状态
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
error: string | null;
// 操作方法
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
disconnect: () => void;
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
sendData: (data: ArrayBuffer) => boolean;
// 处理器注册
registerMessageHandler: (channel: string, handler: MessageHandler) => () => void;
registerDataHandler: (channel: string, handler: DataHandler) => () => void;
// 工具方法
getChannelState: () => RTCDataChannelState;
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') => boolean;
// 当前房间信息
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
}
/**
* 共享 WebRTC 连接管理器
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
*/
export function useSharedWebRTCManager(): WebRTCConnection {
const [state, setState] = useState<WebRTCState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 当前连接的房间信息
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
// 多通道消息处理器
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// STUN 服务器配置
const STUN_SERVERS = [
{ urls: 'stun:stun.chat.bilibili.com' },
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.miwifi.com' },
{ urls: 'stun:turn.cloudflare.com:3478' },
];
const updateState = useCallback((updates: Partial<WebRTCState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 清理连接
const cleanup = useCallback(() => {
// console.log('[SharedWebRTC] 清理连接');
// if (timeoutRef.current) {
// clearTimeout(timeoutRef.current);
// timeoutRef.current = null;
// }
// 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;
// }
// currentRoom.current = null;
}, []);
// 创建 Offer
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
const offer = await pc.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
});
await pc.setLocalDescription(offer);
const iceTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 发送 offer (超时发送)');
}
}, 3000);
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 发送 offer (ICE收集完成)');
}
} else {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 发送 offer (ICE收集完成)');
}
}
};
}
} catch (error) {
console.error('[SharedWebRTC] 创建 offer 失败:', error);
updateState({ error: '创建连接失败', isConnecting: false });
}
}, [updateState]);
// 处理数据通道消息
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as WebRTCMessage;
console.log('[SharedWebRTC] 收到消息:', message.type, message.channel || 'default');
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 兼容旧版本,广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} catch (error) {
console.error('[SharedWebRTC] 解析消息失败:', error);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('[SharedWebRTC] 收到数据:', event.data.byteLength, 'bytes');
// 数据优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 如果没有文件处理器,发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
}
}, []);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('[SharedWebRTC] 连接到房间:', roomCode, role);
// 检查是否已经连接到相同房间
if (currentRoom.current?.code === roomCode && currentRoom.current?.role === role) {
if (state.isConnected) {
console.log('[SharedWebRTC] 已连接到相同房间,复用连接');
return;
}
if (state.isConnecting) {
console.log('[SharedWebRTC] 正在连接到相同房间,等待连接完成');
return new Promise<void>((resolve, reject) => {
const checkConnection = () => {
if (state.isConnected) {
resolve();
} else if (!state.isConnecting) {
reject(new Error('连接失败'));
} else {
setTimeout(checkConnection, 100);
}
};
checkConnection();
});
}
}
// 如果要连接到不同房间,先断开当前连接
if (currentRoom.current && (currentRoom.current.code !== roomCode || currentRoom.current.role !== role)) {
console.log('[SharedWebRTC] 切换到新房间,断开当前连接');
cleanup();
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
}
if (state.isConnecting) {
console.warn('[SharedWebRTC] 正在连接中,跳过重复连接请求');
return;
}
cleanup();
currentRoom.current = { code: roomCode, role };
updateState({ isConnecting: true, error: null });
// 设置连接超时
timeoutRef.current = setTimeout(() => {
console.warn('[SharedWebRTC] 连接超时');
updateState({ error: '连接超时,请检查网络状况或重新尝试', isConnecting: false });
cleanup();
}, 30000);
try {
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: STUN_SERVERS,
iceCandidatePoolSize: 10,
});
pcRef.current = pc;
// 连接 WebSocket
const wsUrl = config.api.wsUrl.replace('/ws/p2p', '/ws/webrtc');
const ws = new WebSocket(`${wsUrl}?code=${roomCode}&role=${role}&channel=shared`);
wsRef.current = ws;
// WebSocket 事件处理
ws.onopen = () => {
console.log('[SharedWebRTC] WebSocket 连接已建立');
updateState({ isWebSocketConnected: true });
if (role === 'sender') {
createOffer(pc, ws);
}
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log('[SharedWebRTC] 收到信令消息:', message.type);
switch (message.type) {
case 'offer':
if (pc.signalingState === 'stable') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('[SharedWebRTC] 发送 answer');
}
break;
case 'answer':
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[SharedWebRTC] 处理 answer 完成');
}
break;
case 'ice-candidate':
if (message.payload && pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
console.log('[SharedWebRTC] 添加 ICE 候选');
}
break;
case 'error':
console.error('[SharedWebRTC] 信令错误:', message.error);
updateState({ error: message.error, isConnecting: false });
break;
}
} catch (error) {
console.error('[SharedWebRTC] 处理信令消息失败:', error);
}
};
ws.onerror = (error) => {
console.error('[SharedWebRTC] WebSocket 错误:', error);
updateState({ error: 'WebSocket连接失败请检查网络连接', isConnecting: false });
};
ws.onclose = () => {
console.log('[SharedWebRTC] WebSocket 连接已关闭');
updateState({ isWebSocketConnected: false });
};
// PeerConnection 事件处理
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice-candidate',
payload: event.candidate
}));
console.log('[SharedWebRTC] 发送 ICE 候选');
}
};
pc.onconnectionstatechange = () => {
console.log('[SharedWebRTC] 连接状态变化:', pc.connectionState);
switch (pc.connectionState) {
case 'connected':
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
updateState({ isConnected: true, isConnecting: false, error: null });
break;
case 'failed':
updateState({ error: 'WebRTC连接失败可能是网络防火墙阻止了连接', isConnecting: false, isConnected: false });
break;
case 'disconnected':
updateState({ isConnected: false });
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
break;
case 'closed':
updateState({ isConnected: false, isConnecting: false });
break;
}
};
// 数据通道处理
if (role === 'sender') {
const dataChannel = pc.createDataChannel('shared-channel', {
ordered: true,
maxRetransmits: 3
});
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
};
} else {
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
};
};
}
} catch (error) {
console.error('[SharedWebRTC] 连接失败:', error);
updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false
});
}
}, [updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[SharedWebRTC] 断开连接');
cleanup();
setState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
}, [cleanup]);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[SharedWebRTC] 数据通道未准备就绪');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
dataChannel.send(JSON.stringify(messageWithChannel));
console.log('[SharedWebRTC] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[SharedWebRTC] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[SharedWebRTC] 数据通道未准备就绪');
return false;
}
try {
dataChannel.send(data);
console.log('[SharedWebRTC] 发送数据:', data.byteLength, 'bytes');
return true;
} catch (error) {
console.error('[SharedWebRTC] 发送数据失败:', error);
return false;
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[SharedWebRTC] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[SharedWebRTC] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[SharedWebRTC] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[SharedWebRTC] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
return dcRef.current?.readyState || 'closed';
}, []);
// 检查是否已连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return currentRoom.current?.code === roomCode &&
currentRoom.current?.role === role &&
state.isConnected;
}, [state.isConnected]);
return {
// 状态
isConnected: state.isConnected,
isConnecting: state.isConnecting,
isWebSocketConnected: state.isWebSocketConnected,
error: state.error,
// 操作方法
connect,
disconnect,
sendMessage,
sendData,
// 处理器注册
registerMessageHandler,
registerDataHandler,
// 工具方法
getChannelState,
isConnectedToRoom,
// 当前房间信息
currentRoom: currentRoom.current,
};
}

View File

@@ -1,237 +1,166 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useWebRTCCore } from './useWebRTCCore';
import type { WebRTCConnection } from './useSharedWebRTCManager';
// 文传输状态
// 文传输状态
interface TextTransferState {
messages: Array<{
id: string;
text: string;
timestamp: Date;
sender: 'self' | 'peer';
}>;
isTyping: boolean;
error: string | null;
}
// 消息类型
interface TextMessage {
id: string;
text: string;
timestamp: string;
isConnecting: boolean;
isConnected: boolean;
isWebSocketConnected: boolean;
connectionError: string | null;
currentText: string; // 当前文本内容
isTyping: boolean; // 对方是否在输入
}
// 回调类型
type MessageReceivedCallback = (message: TextMessage) => void;
type TextSyncCallback = (text: string) => void;
type TypingStatusCallback = (isTyping: boolean) => void;
type RealTimeTextCallback = (text: string) => void;
const CHANNEL_NAME = 'text-transfer';
/**
* 文传输业务层
* 使用 WebRTC 核心连接逻辑实现实时文字传输功能
* 每个实例有独立的连接,但复用相同的连接建立逻辑
* 文传输业务层
* 必须传入共享的 WebRTC 连接
*/
export function useTextTransferBusiness() {
const webrtcCore = useWebRTCCore('text-transfer');
export function useTextTransferBusiness(connection: WebRTCConnection) {
const [state, setState] = useState<TextTransferState>({
messages: [],
isTyping: false,
error: null,
isConnecting: false,
isConnected: false,
isWebSocketConnected: false,
connectionError: null,
currentText: '',
isTyping: false
});
// 回调存储
const messageCallbacks = useRef<Set<MessageReceivedCallback>>(new Set());
const typingCallbacks = useRef<Set<TypingStatusCallback>>(new Set());
const realTimeTextCallbacks = useRef<Set<RealTimeTextCallback>>(new Set());
// 打字状态防抖
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 回调引用
const textSyncCallbackRef = useRef<TextSyncCallback | null>(null);
const typingCallbackRef = useRef<TypingStatusCallback | null>(null);
// 更新状态的辅助函数
const updateState = useCallback((updates: Partial<TextTransferState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 处理文字传输消息
// 消息处理器
const handleMessage = useCallback((message: any) => {
if (!message.type.startsWith('text-')) return;
console.log('文本传输收到消息:', message.type, message);
switch (message.type) {
case 'text-message':
const textMessage: TextMessage = message.payload;
console.log('收到文字消息:', textMessage.text);
setState(prev => ({
...prev,
messages: [...prev.messages, {
id: textMessage.id,
text: textMessage.text,
timestamp: new Date(textMessage.timestamp),
sender: 'peer'
}]
}));
// 触发回调
messageCallbacks.current.forEach(cb => cb(textMessage));
case 'text-sync':
// 实时文本同步 - 接收方看到发送方的实时编辑
if (message.payload && typeof message.payload.text === 'string') {
updateState({ currentText: message.payload.text });
// 触发文本同步回调
if (textSyncCallbackRef.current) {
textSyncCallbackRef.current(message.payload.text);
}
}
break;
case 'typing-status':
const { isTyping } = message.payload;
updateState({ isTyping });
typingCallbacks.current.forEach(cb => cb(isTyping));
case 'text-typing':
// 打字状态
if (typeof message.payload?.typing === 'boolean') {
updateState({ isTyping: message.payload.typing });
// 触发打字状态回调
if (typingCallbackRef.current) {
typingCallbackRef.current(message.payload.typing);
}
}
break;
case 'real-time-text':
const { text } = message.payload;
console.log('收到实时文本:', text);
realTimeTextCallbacks.current.forEach(cb => cb(text));
break;
case 'text-clear':
console.log('收到清空消息指令');
updateState({ messages: [] });
break;
default:
console.warn('未知的文本消息类型:', message.type);
}
}, [updateState]);
// 设置处理器
// 注册消息处理器
useEffect(() => {
webrtcCore.setMessageHandler(handleMessage);
// 文字传输不需要数据处理器
const unregister = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
return unregister;
}, [handleMessage]);
return () => {
webrtcCore.setMessageHandler(null);
};
}, [webrtcCore.setMessageHandler, handleMessage]);
// 监听连接状态变化 (直接使用 connection 的状态)
useEffect(() => {
// 这里我们直接依赖 connection 的状态变化
// 由于我们使用共享连接,状态会自动同步
}, []);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return webrtcCore.connect(roomCode, role);
}, [webrtcCore.connect]);
return connection.connect(roomCode, role);
}, [connection]);
// 发送文字消息
const sendMessage = useCallback((text: string) => {
if (webrtcCore.getChannelState() !== 'open') {
updateState({ error: '连接未就绪' });
return;
}
// 断开连接
const disconnect = useCallback(() => {
return connection.disconnect();
}, [connection]);
const message: TextMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
text,
timestamp: new Date().toISOString(),
// 发送实时文本同步 (替代原来的 sendMessage)
const sendTextSync = useCallback((text: string) => {
if (!connection) return;
const message = {
type: 'text-sync',
payload: { text }
};
console.log('发送文字消息:', text);
// 发送到对方
const success = webrtcCore.sendMessage({
type: 'text-message',
payload: message
});
if (success) {
// 添加到本地消息列表
setState(prev => ({
...prev,
messages: [...prev.messages, {
id: message.id,
text: message.text,
timestamp: new Date(message.timestamp),
sender: 'self'
}],
error: null
}));
} else {
updateState({ error: '发送消息失败' });
}
}, [webrtcCore.getChannelState, webrtcCore.sendMessage, updateState]);
connection.sendMessage(message, CHANNEL_NAME);
console.log('发送实时文本同步:', text.length, '字符');
}, [connection]);
// 发送打字状态
const sendTypingStatus = useCallback((isTyping: boolean) => {
if (webrtcCore.getChannelState() !== 'open') return;
webrtcCore.sendMessage({
type: 'typing-status',
payload: { isTyping }
});
// 如果开始打字,设置自动停止
if (isTyping) {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
sendTypingStatus(false);
}, 3000); // 3秒后自动停止打字状态
} else {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
}
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
// 发送实时文本
const sendRealTimeText = useCallback((text: string) => {
if (webrtcCore.getChannelState() !== 'open') return;
if (!connection) return;
webrtcCore.sendMessage({
type: 'real-time-text',
payload: { text }
});
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
const message = {
type: 'text-typing',
payload: { typing: isTyping }
};
connection.sendMessage(message, CHANNEL_NAME);
console.log('发送打字状态:', isTyping);
}, [connection]);
// 注册实时文本回调
const onRealTimeText = useCallback((callback: RealTimeTextCallback) => {
realTimeTextCallbacks.current.add(callback);
return () => { realTimeTextCallbacks.current.delete(callback); };
// 设置文本同步回调
const onTextSync = useCallback((callback: TextSyncCallback) => {
textSyncCallbackRef.current = callback;
// 返回清理函数
return () => {
textSyncCallbackRef.current = null;
};
}, []);
// 清空消息
const clearMessages = useCallback(() => {
updateState({ messages: [] });
// 通知对方也清空
if (webrtcCore.getChannelState() === 'open') {
webrtcCore.sendMessage({
type: 'text-clear',
payload: {}
});
}
}, [webrtcCore.getChannelState, webrtcCore.sendMessage, updateState]);
// 注册消息接收回调
const onMessageReceived = useCallback((callback: MessageReceivedCallback) => {
messageCallbacks.current.add(callback);
return () => { messageCallbacks.current.delete(callback); };
}, []);
// 注册打字状态回调
// 设置打字状态回调
const onTypingStatus = useCallback((callback: TypingStatusCallback) => {
typingCallbacks.current.add(callback);
return () => { typingCallbacks.current.delete(callback); };
typingCallbackRef.current = callback;
// 返回清理函数
return () => {
typingCallbackRef.current = null;
};
}, []);
return {
// 继承基础连接状态
isConnected: webrtcCore.isConnected,
isConnecting: webrtcCore.isConnecting,
isWebSocketConnected: webrtcCore.isWebSocketConnected,
connectionError: webrtcCore.error,
// 文字传输状态
...state,
// 状态 - 直接从 connection 获取
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error,
currentText: state.currentText,
isTyping: state.isTyping,
// 操作方法
connect,
disconnect: webrtcCore.disconnect,
sendMessage,
disconnect,
sendTextSync,
sendTypingStatus,
sendRealTimeText,
clearMessages,
// 回调注册
onMessageReceived,
onTypingStatus,
onRealTimeText,
// 回调设置
onTextSync,
onTypingStatus
};
}

View File

@@ -1,402 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { config } from '@/lib/config';
// 基础连接状态
interface WebRTCCoreState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
error: string | null;
}
// 消息类型
interface WebRTCMessage {
type: string;
payload: any;
}
// 消息处理器类型
type MessageHandler = (message: WebRTCMessage) => void;
type DataHandler = (data: ArrayBuffer) => void;
/**
* WebRTC 核心连接逻辑
* 提供可复用的连接建立逻辑,但每个业务模块独立使用
* 不共享连接实例,只共享连接逻辑
*/
export function useWebRTCCore(channelLabel: string = 'data') {
const [state, setState] = useState<WebRTCCoreState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 消息处理器存储
const messageHandlerRef = useRef<MessageHandler | null>(null);
const dataHandlerRef = useRef<DataHandler | null>(null);
// STUN 服务器配置
const STUN_SERVERS = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.miwifi.com' },
{ urls: 'stun:turn.cloudflare.com:3478' },
];
const updateState = useCallback((updates: Partial<WebRTCCoreState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 清理连接
const cleanup = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
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;
}
}, []);
// 创建 Offer
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
const offer = await pc.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
});
await pc.setLocalDescription(offer);
// 等待 ICE 候选收集完成或超时
const iceTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log(`[${channelLabel}] 发送 offer (超时发送)`);
}
}, 3000);
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log(`[${channelLabel}] 发送 offer (ICE收集完成)`);
}
} else {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log(`[${channelLabel}] 发送 offer (ICE收集完成)`);
}
}
};
}
} catch (error) {
console.error(`[${channelLabel}] 创建 offer 失败:`, error);
updateState({ error: '创建连接失败', isConnecting: false });
}
}, [channelLabel, updateState]);
// 处理数据通道消息
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data);
console.log(`[${channelLabel}] 收到消息:`, message.type);
if (messageHandlerRef.current) {
messageHandlerRef.current(message);
}
} catch (error) {
console.error(`[${channelLabel}] 解析消息失败:`, error);
}
} else if (event.data instanceof ArrayBuffer) {
console.log(`[${channelLabel}] 收到数据:`, event.data.byteLength, 'bytes');
if (dataHandlerRef.current) {
dataHandlerRef.current(event.data);
}
}
}, [channelLabel]);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log(`=== [${channelLabel}] WebRTC 连接开始 ===`);
console.log(`[${channelLabel}] 房间代码:`, roomCode, '角色:', role);
// 检查是否已经在连接中或已连接
if (state.isConnecting) {
console.warn(`[${channelLabel}] 正在连接中,跳过重复连接请求`);
return;
}
if (state.isConnected) {
console.warn(`[${channelLabel}] 已经连接,跳过重复连接请求`);
return;
}
// 清理之前的连接(如果存在)
cleanup();
updateState({ isConnecting: true, error: null });
// 设置连接超时
timeoutRef.current = setTimeout(() => {
console.warn(`[${channelLabel}] 连接超时`);
updateState({ error: '连接超时,请检查网络状况或重新尝试', isConnecting: false });
cleanup();
}, 30000);
try {
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: STUN_SERVERS,
iceCandidatePoolSize: 10,
});
pcRef.current = pc;
// 连接 WebSocket - 使用不同的标识来区分不同的业务连接
const wsUrl = config.api.wsUrl.replace('/ws/p2p', '/ws/webrtc');
const ws = new WebSocket(`${wsUrl}?code=${roomCode}&role=${role}&channel=${channelLabel}`);
wsRef.current = ws;
// WebSocket 事件处理
ws.onopen = () => {
console.log(`[${channelLabel}] WebSocket 连接已建立`);
updateState({ isWebSocketConnected: true });
if (role === 'sender') {
createOffer(pc, ws);
}
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log(`[${channelLabel}] 收到信令消息:`, message.type);
switch (message.type) {
case 'offer':
if (pc.signalingState === 'stable') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log(`[${channelLabel}] 发送 answer`);
}
break;
case 'answer':
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log(`[${channelLabel}] 处理 answer 完成`);
}
break;
case 'ice-candidate':
if (message.payload && pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
console.log(`[${channelLabel}] 添加 ICE 候选`);
}
break;
case 'error':
console.error(`[${channelLabel}] 信令错误:`, message.error);
updateState({ error: message.error, isConnecting: false });
break;
}
} catch (error) {
console.error(`[${channelLabel}] 处理信令消息失败:`, error);
}
};
ws.onerror = (error) => {
console.error(`[${channelLabel}] WebSocket 错误:`, error);
updateState({ error: 'WebSocket连接失败请检查网络连接', isConnecting: false });
};
ws.onclose = () => {
console.log(`[${channelLabel}] WebSocket 连接已关闭`);
updateState({ isWebSocketConnected: false });
};
// PeerConnection 事件处理
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice-candidate',
payload: event.candidate
}));
console.log(`[${channelLabel}] 发送 ICE 候选`);
}
};
pc.onconnectionstatechange = () => {
console.log(`[${channelLabel}] 连接状态变化:`, pc.connectionState);
switch (pc.connectionState) {
case 'connected':
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
updateState({ isConnected: true, isConnecting: false, error: null });
break;
case 'failed':
updateState({ error: 'WebRTC连接失败可能是网络防火墙阻止了连接', isConnecting: false, isConnected: false });
break;
case 'disconnected':
updateState({ isConnected: false });
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
break;
case 'closed':
updateState({ isConnected: false, isConnecting: false });
break;
}
};
// 数据通道处理
if (role === 'sender') {
const dataChannel = pc.createDataChannel(channelLabel, {
ordered: true,
maxRetransmits: 3
});
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log(`[${channelLabel}] 数据通道已打开 (发送方)`);
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error(`[${channelLabel}] 数据通道错误:`, error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
};
} else {
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log(`[${channelLabel}] 数据通道已打开 (接收方)`);
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error(`[${channelLabel}] 数据通道错误:`, error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
};
};
}
} catch (error) {
console.error(`[${channelLabel}] 连接失败:`, error);
updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false
});
}
}, [channelLabel, updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
// 断开连接
const disconnect = useCallback(() => {
console.log(`[${channelLabel}] 断开 WebRTC 连接`);
cleanup();
setState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
}, [channelLabel, cleanup]);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error(`[${channelLabel}] 数据通道未准备就绪`);
return false;
}
try {
dataChannel.send(JSON.stringify(message));
console.log(`[${channelLabel}] 发送消息:`, message.type);
return true;
} catch (error) {
console.error(`[${channelLabel}] 发送消息失败:`, error);
return false;
}
}, [channelLabel]);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error(`[${channelLabel}] 数据通道未准备就绪`);
return false;
}
try {
dataChannel.send(data);
console.log(`[${channelLabel}] 发送数据:`, data.byteLength, 'bytes');
return true;
} catch (error) {
console.error(`[${channelLabel}] 发送数据失败:`, error);
return false;
}
}, [channelLabel]);
// 设置消息处理器
const setMessageHandler = useCallback((handler: MessageHandler | null) => {
messageHandlerRef.current = handler;
}, []);
// 设置数据处理器
const setDataHandler = useCallback((handler: DataHandler | null) => {
dataHandlerRef.current = handler;
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
return dcRef.current?.readyState || 'closed';
}, []);
return {
// 状态
...state,
// 操作方法
connect,
disconnect,
sendMessage,
sendData,
// 处理器设置
setMessageHandler,
setDataHandler,
// 工具方法
getChannelState,
};
}