From 2b5ba5fdc457bbfdf06c0ba79f48b5012f1ebbb5 Mon Sep 17 00:00:00 2001 From: MatrixSeven Date: Thu, 5 Mar 2026 14:27:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84WebRTC=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=92=8C=E5=9B=BE=E7=89=87=E4=BC=A0=E8=BE=93=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=96=B0=E5=A2=9EWebRTCChat=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E8=81=8A=E5=A4=A9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=95=B4=E5=90=88=E6=B6=88=E6=81=AF=E5=8F=91?= =?UTF-8?q?=E9=80=81=E5=92=8C=E6=8E=A5=E6=94=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/WebRTCTextImageTransfer.tsx | 102 +-- .../src/components/webrtc/WebRTCChat.tsx | 711 ++++++++++++++++++ chuan-next/src/hooks/text-transfer/index.ts | 2 + .../hooks/text-transfer/useChatBusiness.ts | 327 ++++++++ 4 files changed, 1043 insertions(+), 99 deletions(-) create mode 100644 chuan-next/src/components/webrtc/WebRTCChat.tsx create mode 100644 chuan-next/src/hooks/text-transfer/useChatBusiness.ts diff --git a/chuan-next/src/components/WebRTCTextImageTransfer.tsx b/chuan-next/src/components/WebRTCTextImageTransfer.tsx index ec6a9db..fb8e3dc 100644 --- a/chuan-next/src/components/WebRTCTextImageTransfer.tsx +++ b/chuan-next/src/components/WebRTCTextImageTransfer.tsx @@ -1,104 +1,8 @@ "use client"; -import React, { useState, useCallback } from 'react'; -import { useURLHandler } from '@/hooks/ui'; -import { useWebRTCStore } from '@/hooks/ui/webRTCStore'; -import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender'; -import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver'; -import { Button } from '@/components/ui/button'; -import { MessageSquare, Send, Download, X } from 'lucide-react'; +import React from 'react'; +import { WebRTCChat } from '@/components/webrtc/WebRTCChat'; export const WebRTCTextImageTransfer: React.FC = () => { - // 状态管理 - const [mode, setMode] = useState<'send' | 'receive'>('send'); - const [previewImage, setPreviewImage] = useState(null); - - // 使用全局WebRTC状态 - const webrtcState = useWebRTCStore(); - - // 使用统一的URL处理器 - const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({ - featureType: 'message', - onModeChange: setMode - }); - - // 重新开始函数 - const handleRestart = useCallback(() => { - setPreviewImage(null); - clearURLParams(); - }, [clearURLParams]); - - const code = getCurrentRoomCode(); - - // 连接状态变化处理 - 现在不需要了,因为使用全局状态 - const handleConnectionChange = useCallback((connection: any) => { - // 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它 - console.log('连接状态变化:', connection); - }, []); - - // 关闭图片预览 - const closePreview = useCallback(() => { - setPreviewImage(null); - }, []); - - return ( -
- {/* 模式切换 */} -
-
- - -
-
- -
- - - {mode === 'send' ? ( - - ) : ( - - )} -
- - {/* 图片预览模态框 */} - {previewImage && ( -
-
- 预览 - -
-
- )} -
- ); + return ; }; diff --git a/chuan-next/src/components/webrtc/WebRTCChat.tsx b/chuan-next/src/components/webrtc/WebRTCChat.tsx new file mode 100644 index 0000000..e160880 --- /dev/null +++ b/chuan-next/src/components/webrtc/WebRTCChat.tsx @@ -0,0 +1,711 @@ +"use client"; + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { useSharedWebRTCManager } from '@/hooks/connection'; +import { useChatBusiness, type ChatMessage } from '@/hooks/text-transfer'; +import { useURLHandler } from '@/hooks/ui'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useToast } from '@/components/ui/toast-simple'; +import { + MessageSquare, Image, Send, Copy, Check, Upload, + X, ImageIcon, Download +} from 'lucide-react'; +import RoomInfoDisplay from '@/components/RoomInfoDisplay'; +import { ConnectionStatus } from '@/components/ConnectionStatus'; +import { checkRoomStatus } from '@/lib/room-utils'; + +// ── 单条消息气泡组件 ── + +const ChatBubble: React.FC<{ + message: ChatMessage; + onPreviewImage?: (url: string) => void; +}> = ({ message, onPreviewImage }) => { + const [copied, setCopied] = useState(false); + const isMine = message.sender === 'me'; + + const handleCopy = async () => { + if (message.type !== 'text') return; + try { + await navigator.clipboard.writeText(message.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { /* ignore */ } + }; + + const timeStr = new Date(message.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + + return ( +
+
+ {/* 文本消息 */} + {message.type === 'text' && ( +
+
+              {message.content}
+            
+
+ )} + + {/* 图片消息 */} + {message.type === 'image' && ( +
+ {message.content ? ( + {message.fileName onPreviewImage?.(message.content)} + loading="lazy" + /> + ) : ( +
+
+ + 接收中... +
+
+ )} + {message.status === 'sending' && ( +
+
+
+ 发送中 +
+
+ )} +
+ )} + + {/* 时间 + 操作 */} +
+ + {timeStr} + + {message.status === 'failed' && ( + 发送失败 + )} + {/* 文本复制按钮 */} + {message.type === 'text' && ( + + )} + {/* 图片保存提示 */} + {message.type === 'image' && message.content && ( + + )} +
+
+
+ ); +}; + +// ── 打字指示器 ── + +const TypingIndicator: React.FC = () => ( +
+
+
+
+
+
+ 对方正在输入 +
+
+
+); + +// ── 主组件 ── + +export const WebRTCChat: React.FC = () => { + const { showToast } = useToast(); + + // 模式状态 + const [mode, setMode] = useState<'send' | 'receive'>('send'); + const [roomCode, setRoomCode] = useState(''); + const [inputCode, setInputCode] = useState(''); + const [inputText, setInputText] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [isJoining, setIsJoining] = useState(false); + const [previewImage, setPreviewImage] = useState(null); + + // Refs + const fileInputRef = useRef(null); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const hasAutoJoinedRef = useRef(false); + + // 连接 + 业务 + const connection = useSharedWebRTCManager(); + const chat = useChatBusiness(connection); + + // URL 参数处理 + const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({ + featureType: 'message', + onModeChange: setMode, + onAutoJoinRoom: (code: string) => { + if (!hasAutoJoinedRef.current) { + hasAutoJoinedRef.current = true; + setInputCode(code); + joinRoom(code); + } + }, + }); + + // 滚动到底部 + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }); + }, []); + + // 新消息时自动滚动 + useEffect(() => { + scrollToBottom(); + }, [chat.messages, chat.peerTyping, scrollToBottom]); + + // ── 创建房间 ── + + const createRoom = useCallback(async () => { + if (isCreating) return; + setIsCreating(true); + try { + const response = await fetch('/api/create-room', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || '创建房间失败'); + + const code = data.code; + setRoomCode(code); + await connection.connect(code, 'sender'); + showToast(`聊天房间已创建,取件码: ${code}`, 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : '创建房间失败', 'error'); + } finally { + setIsCreating(false); + } + }, [isCreating, connection, showToast]); + + // ── 加入房间 ── + + const joinRoom = useCallback(async (code: string) => { + const finalCode = code || inputCode; + if (!finalCode || finalCode.length !== 6 || isJoining) return; + + setIsJoining(true); + try { + const result = await checkRoomStatus(finalCode); + if (!result.success) { + showToast(result.error || '加入房间失败', 'error'); + return; + } + setRoomCode(finalCode); + await connection.connect(finalCode, 'receiver'); + } catch (error) { + showToast(error instanceof Error ? error.message : '加入房间失败', 'error'); + } finally { + setIsJoining(false); + } + }, [inputCode, isJoining, connection, showToast]); + + // ── 重新开始 ── + + const restart = useCallback(() => { + chat.clearMessages(); + connection.disconnect(); + setRoomCode(''); + setInputCode(''); + setInputText(''); + setPreviewImage(null); + hasAutoJoinedRef.current = false; + clearURLParams(); + }, [chat, connection, clearURLParams]); + + // ── 发送消息 ── + + const handleSend = useCallback(() => { + const text = inputText.trim(); + if (!text || !connection.isPeerConnected) return; + chat.sendTextMessage(text); + setInputText(''); + + // 重置 textarea 高度 + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }, [inputText, connection.isPeerConnected, chat]); + + // ── 文本输入 ── + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setInputText(e.target.value); + chat.sendTypingStatus(); + + // 自动调整高度 + const ta = e.target; + ta.style.height = 'auto'; + ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`; + }, [chat]); + + // ── 键盘快捷键 ── + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, [handleSend]); + + // ── 图片处理 ── + + const handleImageSelect = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith('image/')) { + showToast('请选择图片文件', 'error'); + return; + } + if (file.size > 5 * 1024 * 1024) { + showToast('图片不能超过 5MB', 'error'); + return; + } + if (!connection.isPeerConnected) { + showToast('等待对方加入后才能发送图片', 'error'); + return; + } + chat.sendImage(file); + e.target.value = ''; + }, [connection.isPeerConnected, chat, showToast]); + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith('image/')) { + e.preventDefault(); + const file = items[i].getAsFile(); + if (file) { + if (file.size > 5 * 1024 * 1024) { + showToast('图片不能超过 5MB', 'error'); + return; + } + if (!connection.isPeerConnected) { + showToast('等待对方加入后才能发送图片', 'error'); + return; + } + chat.sendImage(file); + } + break; + } + } + }, [connection.isPeerConnected, chat, showToast]); + + // ── 复制分享链接 ── + + const copyShareLink = useCallback(() => { + const baseUrl = window.location.origin + window.location.pathname; + const link = `${baseUrl}?type=message&mode=receive&code=${roomCode}`; + navigator.clipboard.writeText(link).then( + () => showToast('分享链接已复制', 'success'), + () => showToast('复制失败', 'error'), + ); + }, [roomCode, showToast]); + + const copyCode = useCallback(() => { + navigator.clipboard.writeText(roomCode); + showToast('取件码已复制', 'success'); + }, [roomCode, showToast]); + + const pickupLink = roomCode + ? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=message&mode=receive&code=${roomCode}` + : ''; + + // 判断阶段 + const isConnected = connection.isConnected || connection.isPeerConnected; + const isSetup = !roomCode; + + // ───────────────────────────────────── + // 渲染 + // ───────────────────────────────────── + + return ( +
+ {/* 模式切换 - 与文件传输/桌面共享统一风格 */} + {isSetup && ( +
+
+ + +
+
+ )} + + {/* ── 阶段 1: 创建/加入房间 ── */} + {isSetup && ( +
+ {mode === 'send' ? ( + /* ── 创建房间 ── */ +
+ {/* 功能标题 + 状态栏 */} +
+
+
+ +
+
+

双向消息

+

创建房间后双方可以互相发送文字和图片

+
+
+ +
+ +
+
+ +
+

创建聊天房间

+

创建房间后双方可以互相发送文字和图片

+ +
+
+ ) : ( + /* ── 加入房间 ── */ +
+
+
+
+ +
+
+

加入聊天房间

+

输入 6 位取件码加入房间

+
+
+ +
+ +
{ e.preventDefault(); joinRoom(inputCode); }} + className="space-y-4" + > +
+ + setInputCode( + e.target.value.replace( + /[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, + '', + ), + ) + } + 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" + maxLength={6} + disabled={isJoining || connection.isConnecting} + /> +

+ {inputCode.length}/6 位 +

+
+ + +
+
+ )} +
+ )} + + {/* ── 阶段 2: 房间已创建/加入 ── */} + {!isSetup && ( +
+ {!connection.isPeerConnected ? ( + /* ── 等待对方加入: loading + QR 一体卡片 ── */ +
+ {/* 标题栏 */} +
+
+
+ +
+
+

双向消息

+

房间代码: {roomCode}

+
+
+
+ + +
+
+ + {/* Loading 等待 */} +
+
+ +
+
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ 等待对方加入中... +
+

+ 对方加入后即可开始聊天 +

+
+ + {/* QR / 取件码 - 与 loading 同一卡片内 */} + {roomCode && mode === 'send' && ( +
+ +
+ )} +
+ ) : ( + /* ── 已连接: 聊天窗口 ── */ +
+ {/* 功能标题和状态 */} +
+
+
+ +
+
+

双向消息

+

房间代码: {roomCode}

+
+
+
+ + +
+
+ + {/* 聊天区域 */} +
+ {/* 消息列表 */} +
+ {chat.messages.length === 0 ? ( +
+ +

+ 连接已建立,开始发送消息吧! +

+

+ 双方都可以发送文字和图片 +

+
+ ) : ( + chat.messages.map((msg) => ( + + )) + )} + + {/* 打字指示器 */} + {chat.peerTyping && } + +
+
+ + {/* 输入栏 */} +
+
+ {/* 图片按钮 */} + + + {/* 文本输入 */} +