mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-13 00:24:44 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d163f80c | ||
|
|
e893ff4e2f | ||
|
|
4316b28b9f | ||
|
|
c6acbfd963 | ||
|
|
e606f4f030 | ||
|
|
550d051b9c | ||
|
|
2b671192f2 |
115
.github/workflows/go.yml
vendored
115
.github/workflows/go.yml
vendored
@@ -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
21
LICENSE
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
366
chuan-next/src/components/webrtc/WebRTCTextReceiver.tsx
Normal file
366
chuan-next/src/components/webrtc/WebRTCTextReceiver.tsx
Normal 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="等待对方发送文字内容... 💡 实时同步显示,对方的编辑会立即显示在这里"
|
||||
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>
|
||||
);
|
||||
};
|
||||
545
chuan-next/src/components/webrtc/WebRTCTextSender.tsx
Normal file
545
chuan-next/src/components/webrtc/WebRTCTextSender.tsx
Normal 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="在这里编辑文字内容... 💡 支持实时同步编辑,对方可以看到你的修改 💡 可以直接粘贴图片 (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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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,避免重复触发
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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: { ... }, // 视频传输
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
505
chuan-next/src/hooks/webrtc/useSharedWebRTCManager.ts
Normal file
505
chuan-next/src/hooks/webrtc/useSharedWebRTCManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user