diff --git a/chuan-next/src/app/HomePage-new.tsx b/chuan-next/src/app/HomePage-new.tsx index e6dc99c..e340e0a 100644 --- a/chuan-next/src/app/HomePage-new.tsx +++ b/chuan-next/src/app/HomePage-new.tsx @@ -485,20 +485,36 @@ export default function HomePage() { showNotification('连接成功!', 'success'); // 注意:isConnecting状态会在WebSocket连接建立后自动重置 } else { - showNotification(data.message || '取件码无效或已过期', 'error'); + showNotification(data.message || '取件码不存在或已过期', 'error'); setIsConnecting(false); } } catch (error) { console.error('API调用失败:', error); - showNotification('连接失败,请检查网络连接', 'error'); + showNotification('取件码不存在或已过期', 'error'); setIsConnecting(false); } }, [connect, showNotification, isConnecting, isConnected, pickupCode]); - // 处理URL参数中的取件码 + // 处理URL参数中的取件码(仅在首次加载时) useEffect(() => { const code = searchParams.get('code'); - if (code && code.length === 6 && !isConnected && pickupCode !== code.toUpperCase()) { + 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'); handleJoinRoom(code.toUpperCase()); } @@ -743,7 +759,8 @@ export default function HomePage() { return ''; // 返回空字符串而不是抛出错误 } - showNotification('文字传输房间创建成功!', 'success'); + // 注释掉这里的成功提示,让 TextTransfer 组件来处理 + // showNotification('文字传输房间创建成功!', 'success'); return data.code; } catch (error) { console.error('创建文字传输房间失败:', error); diff --git a/chuan-next/src/app/globals.css b/chuan-next/src/app/globals.css index a4cb530..3e7fab7 100644 --- a/chuan-next/src/app/globals.css +++ b/chuan-next/src/app/globals.css @@ -194,3 +194,31 @@ body { .animate-slide-in-down { animation: slideInDown 0.3s ease-out; } + +.animate-fade-in { + animation: fadeIn 0.3s ease-out; +} + +.animate-scale-in { + animation: scaleIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/chuan-next/src/components/TextTransfer.tsx b/chuan-next/src/components/TextTransfer.tsx index 636bb5c..7dbfda1 100644 --- a/chuan-next/src/components/TextTransfer.tsx +++ b/chuan-next/src/components/TextTransfer.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { MessageSquare, Copy, Send, Download, Image, Users, Link } from 'lucide-react'; +import { MessageSquare, Copy, Send, Download, Image, Users, Link, Eye } from 'lucide-react'; import { useToast } from '@/components/ui/toast-simple'; interface TextTransferProps { @@ -36,75 +36,29 @@ export default function TextTransfer({ const [isRoomCreated, setIsRoomCreated] = useState(false); const [connectedUsers, setConnectedUsers] = useState(0); const [images, setImages] = useState([]); - const [hasAutoJoined, setHasAutoJoined] = useState(false); // 防止重复自动加入 + const [imagePreview, setImagePreview] = useState(null); // 图片预览状态 + const [previewImage, setPreviewImage] = useState(null); // 图片预览弹窗状态 const [hasShownJoinSuccess, setHasShownJoinSuccess] = useState(false); // 防止重复显示加入成功消息 const { showToast } = useToast(); const textareaRef = useRef(null); const updateTimeoutRef = useRef(null); const connectionTimeoutRef = useRef(null); // 连接超时定时器 - // 处理通过URL参数自动加入房间 - const handleJoinRoomWithCode = useCallback(async (code: string) => { - if (!code || code.length !== 6) return; - - setIsLoading(true); - try { - // 先查询房间信息,确认房间存在 - const roomInfoResponse = await fetch(`/api/room-info?code=${code}`); - const roomData = await roomInfoResponse.json(); - - if (!roomInfoResponse.ok || !roomData.success) { - showToast(roomData.message || '房间不存在或已过期', 'error'); - setIsLoading(false); - return; - } - - // 房间存在,创建WebSocket连接 - if (onCreateWebSocket) { - console.log('房间验证成功,自动加入房间:', code); - onCreateWebSocket(code, 'receiver'); - - // 设置连接超时,如果5秒内没有收到消息就认为连接失败 - connectionTimeoutRef.current = setTimeout(() => { - if (isLoading) { - setIsLoading(false); - showToast('连接超时,请重试', 'error'); - } - }, 5000); - } - } catch (error) { - console.error('自动加入房间失败:', error); - showToast('网络错误,请稍后重试', 'error'); - setIsLoading(false); - } - }, [onCreateWebSocket, showToast]); - - // 从URL参数中获取初始模式和房间码 + // 从URL参数中获取初始模式 useEffect(() => { const urlMode = searchParams.get('mode') as 'send' | 'receive'; const type = searchParams.get('type'); - const urlCode = searchParams.get('code'); if (type === 'text' && urlMode && ['send', 'receive'].includes(urlMode)) { setMode(urlMode); - // 如果URL中有房间码且是接收模式,自动填入房间码并尝试加入(只执行一次) - if (urlMode === 'receive' && urlCode && urlCode.length === 6 && !hasAutoJoined) { + // 如果是接收模式且URL中有房间码,只填入房间码,不自动连接 + const urlCode = searchParams.get('code'); + if (urlMode === 'receive' && urlCode && urlCode.length === 6) { setRoomCode(urlCode.toUpperCase()); - setHasAutoJoined(true); // 标记已自动加入,防止重复 - - // 自动尝试加入房间 - setTimeout(() => { - if (onCreateWebSocket) { - console.log('自动加入房间:', urlCode.toUpperCase()); - setIsLoading(true); - onCreateWebSocket(urlCode.toUpperCase(), 'receiver'); - // 这里不设置setIsLoading(false),因为会在WebSocket消息中处理 - } - }, 500); // 延迟500ms确保组件完全初始化 } } - }, [searchParams, onCreateWebSocket, hasAutoJoined]); + }, [searchParams]); // 监听WebSocket消息和连接事件 useEffect(() => { @@ -119,8 +73,8 @@ export default function TextTransfer({ setReceivedText(message.payload.text); if (currentRole === 'receiver') { setTextContent(message.payload.text); - // 只在第一次收到文字内容时显示成功消息 - if (!hasShownJoinSuccess) { + // 只在第一次收到文字内容且处于loading状态时显示成功消息 + if (!hasShownJoinSuccess && isLoading) { setHasShownJoinSuccess(true); showToast('成功加入文字房间!', 'success'); } @@ -130,7 +84,7 @@ export default function TextTransfer({ clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } - // 如果是自动加入触发的,结束loading状态 + // 结束loading状态 if (isLoading) { setIsLoading(false); } @@ -180,7 +134,7 @@ export default function TextTransfer({ if (isLoading) { setIsLoading(false); if (code !== 1000) { // 不是正常关闭 - showToast('连接失败,请检查房间码或网络', 'error'); + showToast('取件码不存在或已过期', 'error'); } } }; @@ -191,7 +145,7 @@ export default function TextTransfer({ // 如果是在loading状态下出现错误,结束loading并显示错误 if (isLoading) { setIsLoading(false); - showToast('连接失败,请稍后重试', 'error'); + showToast('取件码不存在或已过期', 'error'); } }; @@ -222,7 +176,7 @@ export default function TextTransfer({ // 发送实时文字更新 const sendTextUpdate = useCallback((text: string) => { - if (!websocket || !isConnected || !isRoomCreated) return; + if (!websocket || !isConnected) return; // 清除之前的定时器 if (updateTimeoutRef.current) { @@ -236,18 +190,18 @@ export default function TextTransfer({ payload: { text } })); }, 300); // 300ms防抖 - }, [websocket, isConnected, isRoomCreated]); + }, [websocket, isConnected]); // 处理文字输入 const handleTextChange = useCallback((e: React.ChangeEvent) => { const newText = e.target.value; setTextContent(newText); - // 如果是发送方且房间已创建,发送实时更新 - if (currentRole === 'sender' && isRoomCreated) { + // 如果有WebSocket连接,发送实时更新 + if (isConnected && websocket) { sendTextUpdate(newText); } - }, [currentRole, isRoomCreated, sendTextUpdate]); + }, [isConnected, websocket, sendTextUpdate]); // 创建文字传输房间 const handleCreateRoom = useCallback(async () => { @@ -310,13 +264,13 @@ export default function TextTransfer({ console.log('房间验证成功,手动加入房间:', roomCode); onCreateWebSocket(roomCode, 'receiver'); - // 设置连接超时,如果5秒内没有收到消息就认为连接失败 + // 设置连接超时,如果8秒内没有收到消息就认为连接失败 connectionTimeoutRef.current = setTimeout(() => { if (isLoading) { setIsLoading(false); - showToast('连接超时,请重试', 'error'); + showToast('取件码不存在或已过期', 'error'); } - }, 5000); + }, 8000); } } catch (error) { console.error('加入房间失败:', error); @@ -337,8 +291,74 @@ export default function TextTransfer({ showToast('文字已发送!', 'success'); }, [websocket, isConnected, textContent, showToast]); + // 压缩图片 + const compressImage = useCallback((file: File): Promise => { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = document.createElement('img'); + + if (!ctx) { + reject(new Error('无法创建Canvas上下文')); + return; + } + + img.onload = () => { + try { + // 设置最大尺寸 + const maxWidth = 800; + const maxHeight = 600; + let { width, height } = img; + + // 计算压缩比例 + if (width > height) { + if (width > maxWidth) { + height = (height * maxWidth) / width; + width = maxWidth; + } + } else { + if (height > maxHeight) { + width = (width * maxHeight) / height; + height = maxHeight; + } + } + + canvas.width = width; + canvas.height = height; + + // 设置白色背景,防止透明图片变成黑色 + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, width, height); + + // 绘制压缩后的图片 + ctx.drawImage(img, 0, 0, width, height); + + // 转为base64,质量为0.8 + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8); + resolve(compressedDataUrl); + } catch (error) { + reject(new Error('图片压缩失败: ' + error)); + } + }; + + img.onerror = () => reject(new Error('图片加载失败')); + + // 读取文件 + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + img.src = e.target.result as string; + } else { + reject(new Error('文件读取失败')); + } + }; + reader.onerror = () => reject(new Error('文件读取失败')); + reader.readAsDataURL(file); + }); + }, []); + // 处理图片粘贴 - const handlePaste = useCallback((e: React.ClipboardEvent) => { + const handlePaste = useCallback(async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; @@ -347,25 +367,27 @@ export default function TextTransfer({ if (item.type.indexOf('image') !== -1) { const file = item.getAsFile(); if (file) { - const reader = new FileReader(); - reader.onload = (event) => { - const imageData = event.target?.result as string; - setImages(prev => [...prev, imageData]); + try { + showToast('正在处理图片...', 'info'); + const compressedImageData = await compressImage(file); + setImages(prev => [...prev, compressedImageData]); // 发送图片给其他用户 if (websocket && isConnected) { websocket.send(JSON.stringify({ type: 'image-send', - payload: { imageData } + payload: { imageData: compressedImageData } })); showToast('图片已发送!', 'success'); } - }; - reader.readAsDataURL(file); + } catch (error) { + console.error('图片处理失败:', error); + showToast('图片处理失败,请重试', 'error'); + } } } } - }, [websocket, isConnected, showToast]); + }, [websocket, isConnected, showToast, compressImage]); const copyToClipboard = useCallback(async (text: string) => { try { @@ -383,6 +405,75 @@ export default function TextTransfer({ await copyToClipboard(transferLink); }, [copyToClipboard]); + // 下载图片 + const downloadImage = useCallback((imageData: string, index: number) => { + const link = document.createElement('a'); + link.download = `image_${index + 1}.jpg`; + link.href = imageData; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + showToast('图片已下载!', 'success'); + }, [showToast]); + + // 图片预览组件 + const ImagePreviewModal = ({ src, onClose }: { src: string; onClose: () => void }) => ( +
+
+
+ 预览 e.stopPropagation()} + onError={(e) => { + console.error('预览图片加载失败:', src); + }} + /> + + {/* 操作按钮栏 */} +
+
+

图片预览

+
+ + +
+
+
+ + {/* 底部信息栏 */} +
+
+ 点击空白区域关闭预览 +
+
+
+
+
+ ); + return (
{/* 模式切换 */} @@ -537,14 +628,47 @@ export default function TextTransfer({
{images.map((img, index) => ( -
+
{`图片 window.open(img, '_blank')} + className="w-full h-24 object-cover rounded-lg border-2 border-slate-200 hover:border-blue-400 transition-all duration-200 cursor-pointer bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50" + onClick={() => setPreviewImage(img)} + onError={(e) => { + console.error('图片加载失败:', img); + e.currentTarget.style.display = 'none'; + }} />
+ + {/* 悬浮按钮组 */} +
+ + +
+ + {/* 图片序号 */} +
+ {index + 1} +
))}
@@ -655,14 +779,43 @@ export default function TextTransfer({
{images.map((img, index) => ( -
+
{`图片 window.open(img, '_blank')} + className="w-full h-24 object-cover rounded-lg border-2 border-slate-200 hover:border-emerald-400 transition-all duration-200 cursor-pointer bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50" + onClick={() => setPreviewImage(img)} />
+ + {/* 悬浮按钮组 */} +
+ + +
+ + {/* 图片序号 */} +
+ {index + 1} +
))}
@@ -671,6 +824,14 @@ export default function TextTransfer({
)} + + {/* 图片预览弹窗 */} + {previewImage && ( + setPreviewImage(null)} + /> + )}
); } diff --git a/internal/services/p2p_service.go b/internal/services/p2p_service.go index 62eb4ea..c30e583 100644 --- a/internal/services/p2p_service.go +++ b/internal/services/p2p_service.go @@ -662,13 +662,15 @@ func (p *P2PService) handleTextSend(room *FileTransferRoom, senderID string, msg func (p *P2PService) handleImageSend(room *FileTransferRoom, senderID string, msg models.VideoMessage) { log.Printf("处理图片发送: 来自客户端 %s", senderID) - // 转发图片发送给房间内所有客户端 + // 转发图片发送给房间内其他客户端(不包括发送者) room.mutex.RLock() defer room.mutex.RUnlock() - for _, client := range room.Clients { - if err := client.Connection.WriteJSON(msg); err != nil { - log.Printf("转发图片发送失败 %s: %v", client.ID, err) + for clientID, client := range room.Clients { + if clientID != senderID { // 不发送给发送者自己 + if err := client.Connection.WriteJSON(msg); err != nil { + log.Printf("转发图片发送失败 %s: %v", clientID, err) + } } } }