From ecde1b40c04de82d28072117dcf927993ed86082 Mon Sep 17 00:00:00 2001 From: MatrixSeven Date: Fri, 1 Aug 2025 18:38:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=87=E4=BB=B6=E4=BC=A0=E8=BE=93?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chuan-next/package-lock.json | 90 +++ chuan-next/src/app/HomePage-new.tsx | 83 ++- .../src/app/api/create-text-room/route.ts | 43 ++ .../src/app/api/get-text-content/route.ts | 41 ++ chuan-next/src/components/FileReceive.tsx | 44 ++ chuan-next/src/components/FileTransfer.tsx | 3 + chuan-next/src/components/FileUpload.tsx | 56 +- .../src/components/TextTransfer-new.tsx | 468 ++++++++++++++++ chuan-next/src/components/TextTransfer.tsx | 530 +++++++++++++++--- cmd/main.go | 4 + internal/handlers/handlers.go | 72 +++ internal/services/p2p_service.go | 106 +++- 12 files changed, 1421 insertions(+), 119 deletions(-) create mode 100644 chuan-next/src/app/api/create-text-room/route.ts create mode 100644 chuan-next/src/app/api/get-text-content/route.ts create mode 100644 chuan-next/src/components/TextTransfer-new.tsx diff --git a/chuan-next/package-lock.json b/chuan-next/package-lock.json index d72a680..ad47cbb 100644 --- a/chuan-next/package-lock.json +++ b/chuan-next/package-lock.json @@ -5844,6 +5844,96 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.4.4", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.4.tgz", + "integrity": "sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.4.4", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.4.tgz", + "integrity": "sha512-zqG+/8apsu49CltEj4NAmCGZvHcZbOOOsNoTVeIXphYWIbE4l6A/vuQHyqll0flU2o3dmYCXsBW5FmbrGDgljQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.4.4", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.4.tgz", + "integrity": "sha512-LRD4l2lq4R+2QCHBQVC0wjxxkLlALGJCwigaJ5FSRSqnje+MRKHljQNZgDCaKUZQzO/TXxlmUdkZP/X3KNGZaw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.4.4", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.4.tgz", + "integrity": "sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.4.4", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.4.tgz", + "integrity": "sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.4.4", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.4.tgz", + "integrity": "sha512-SE5pYNbn/xZKMy1RE3pAs+4xD32OI4rY6mzJa4XUkp/ItZY+OMjIgilskmErt8ls/fVJ+Ihopi2QIeW6O3TrMw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/chuan-next/src/app/HomePage-new.tsx b/chuan-next/src/app/HomePage-new.tsx index 1d27157..9f00721 100644 --- a/chuan-next/src/app/HomePage-new.tsx +++ b/chuan-next/src/app/HomePage-new.tsx @@ -537,7 +537,19 @@ export default function HomePage() { } }, [pickupCode, updateFileList]); - // 重置状态 + // 清空文件列表但保持房间连接 + const handleClearFiles = useCallback(() => { + setSelectedFiles([]); + setTransferProgresses([]); + setFileTransfers(new Map()); + // 保持 pickupCode, pickupLink, roomStatus 和 websocket 连接 + if (pickupCode) { + updateFileList([]); + showNotification('文件列表已清空,房间保持连接', 'success'); + } + }, [pickupCode, updateFileList, showNotification]); + + // 完全重置状态(关闭房间) const handleReset = useCallback(() => { setSelectedFiles([]); setPickupCode(''); @@ -547,7 +559,8 @@ export default function HomePage() { setRoomStatus(null); setFileTransfers(new Map()); disconnect(); - }, [disconnect]); + showNotification('已断开连接', 'info'); + }, [disconnect, showNotification]); // 复制到剪贴板 const copyToClipboard = useCallback(async (text: string, successMessage: string) => { @@ -628,6 +641,7 @@ export default function HomePage() { input.click(); }} onRemoveFile={handleRemoveFile} + onClearFiles={handleClearFiles} onReset={handleReset} onJoinRoom={handleJoinRoom} receiverFiles={receiverFiles} @@ -668,14 +682,67 @@ export default function HomePage() { { - // TODO: 实现文字传输功能 - showNotification('文字传输功能开发中', 'info'); - return 'ABC123'; // 模拟返回取件码 + try { + const response = await fetch('/api/create-text-room', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text }), + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data.error || '创建文字传输房间失败'; + showNotification(errorMessage, 'error'); + return ''; // 返回空字符串而不是抛出错误 + } + + showNotification('文字传输房间创建成功!', 'success'); + return data.code; + } catch (error) { + console.error('创建文字传输房间失败:', error); + showNotification('网络错误,请重试', 'error'); + return ''; // 返回空字符串而不是抛出错误 + } }} onReceiveText={async (code: string) => { - // TODO: 实现文字接收功能 - showNotification('文字传输功能开发中', 'info'); - return '示例文本内容'; // 模拟返回文本 + try { + const response = await fetch('/api/get-text-content', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data.error || '获取文字内容失败'; + showNotification(errorMessage, 'error'); + return ''; // 返回空字符串而不是抛出错误 + } + + return data.text; + } catch (error) { + console.error('获取文字内容失败:', error); + showNotification('网络错误,请重试', 'error'); + return ''; // 返回空字符串而不是抛出错误 + } + }} + websocket={websocket} + isConnected={isConnected} + currentRole={currentRole} + onCreateWebSocket={(code: string, role: 'sender' | 'receiver') => { + // 如果已有连接,先关闭 + if (websocket) { + disconnect(); + } + + // 创建新的WebSocket连接 + connect(code, role); }} /> diff --git a/chuan-next/src/app/api/create-text-room/route.ts b/chuan-next/src/app/api/create-text-room/route.ts new file mode 100644 index 0000000..4e19a15 --- /dev/null +++ b/chuan-next/src/app/api/create-text-room/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + try { + const { text } = await req.json(); + + if (!text || text.trim().length === 0) { + return NextResponse.json({ error: '文本内容不能为空' }, { status: 400 }); + } + + if (text.length > 50000) { + return NextResponse.json({ error: '文本内容过长,最大支持50,000字符' }, { status: 400 }); + } + + // 调用后端API创建文字传输房间 + const response = await fetch('http://localhost:8080/api/create-text-room', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + throw new Error('创建文字传输房间失败'); + } + + const data = await response.json(); + + return NextResponse.json({ + success: true, + code: data.code, + message: '文字传输房间创建成功' + }); + + } catch (error) { + console.error('创建文字传输房间错误:', error); + return NextResponse.json( + { error: '服务器错误,请重试' }, + { status: 500 } + ); + } +} diff --git a/chuan-next/src/app/api/get-text-content/route.ts b/chuan-next/src/app/api/get-text-content/route.ts new file mode 100644 index 0000000..7ed6333 --- /dev/null +++ b/chuan-next/src/app/api/get-text-content/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + try { + const { code } = await req.json(); + + if (!code || code.length !== 6) { + return NextResponse.json({ error: '请输入正确的6位房间码' }, { status: 400 }); + } + + // 调用后端API获取文字内容 + const response = await fetch(`http://localhost:8080/api/get-text-content/${code}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + return NextResponse.json({ error: '房间不存在或已过期' }, { status: 404 }); + } + throw new Error('获取文字内容失败'); + } + + const data = await response.json(); + + return NextResponse.json({ + success: true, + text: data.text, + message: '文字内容获取成功' + }); + + } catch (error) { + console.error('获取文字内容错误:', error); + return NextResponse.json( + { error: '服务器错误,请重试' }, + { status: 500 } + ); + } +} diff --git a/chuan-next/src/components/FileReceive.tsx b/chuan-next/src/components/FileReceive.tsx index 7f96467..edce1a4 100644 --- a/chuan-next/src/components/FileReceive.tsx +++ b/chuan-next/src/components/FileReceive.tsx @@ -57,6 +57,50 @@ export function FileReceive({ } }, []); + // 如果已经连接但没有文件,显示等待界面 + if ((isConnected || isConnecting) && files.length === 0) { + return ( +
+
+
+ +
+

等待文件

+

+ {isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'} +

+ + {/* 连接状态指示器 */} +
+
+
+ + {isConnected ? '连接已建立' : '连接中...'} + +
+
+ + {/* 等待动画 */} +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ +
+

+ 💡 提示:房间已连接,发送方清空文件列表后您会看到此界面,等待对方重新选择文件 +

+
+
+
+ ); + } + // 如果已经连接并且有文件列表,显示文件列表 if (files.length > 0) { return ( diff --git a/chuan-next/src/components/FileTransfer.tsx b/chuan-next/src/components/FileTransfer.tsx index 231a256..9796573 100644 --- a/chuan-next/src/components/FileTransfer.tsx +++ b/chuan-next/src/components/FileTransfer.tsx @@ -19,6 +19,7 @@ interface FileTransferProps { onCopyLink: () => void; onAddMoreFiles: () => void; onRemoveFile: (updatedFiles: File[]) => void; + onClearFiles?: () => void; onReset: () => void; // 接收方相关 @@ -43,6 +44,7 @@ export default function FileTransfer({ onCopyLink, onAddMoreFiles, onRemoveFile, + onClearFiles, onReset, onJoinRoom, receiverFiles, @@ -111,6 +113,7 @@ export default function FileTransfer({ onCopyLink={onCopyLink} onAddMoreFiles={onAddMoreFiles} onRemoveFile={onRemoveFile} + onClearFiles={onClearFiles} onReset={onReset} disabled={disabled} /> diff --git a/chuan-next/src/components/FileUpload.tsx b/chuan-next/src/components/FileUpload.tsx index 3315eda..364e619 100644 --- a/chuan-next/src/components/FileUpload.tsx +++ b/chuan-next/src/components/FileUpload.tsx @@ -15,6 +15,7 @@ interface FileUploadProps { onCopyLink?: () => void; onAddMoreFiles?: () => void; onRemoveFile?: (updatedFiles: File[]) => void; + onClearFiles?: () => void; onReset?: () => void; disabled?: boolean; } @@ -45,8 +46,9 @@ export default function FileUpload({ onCopyLink, onAddMoreFiles, onRemoveFile, + onClearFiles, onReset, - disabled = false, + disabled = false }: FileUploadProps) { const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); @@ -66,7 +68,6 @@ export default function FileUpload({ const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); - const files = Array.from(e.dataTransfer.files); if (files.length > 0) { onFilesChange([...selectedFiles, ...files]); @@ -81,22 +82,24 @@ export default function FileUpload({ }, [selectedFiles, onFilesChange]); const removeFile = useCallback((index: number) => { - const newFiles = selectedFiles.filter((_, i) => i !== index); - onFilesChange(newFiles); - // 如果已经生成了取件码,同步删除操作到接收端 + const updatedFiles = selectedFiles.filter((_, i) => i !== index); + onFilesChange(updatedFiles); if (onRemoveFile) { - onRemoveFile(newFiles); + onRemoveFile(updatedFiles); } }, [selectedFiles, onFilesChange, onRemoveFile]); const handleClick = useCallback(() => { - fileInputRef.current?.click(); + if (fileInputRef.current) { + fileInputRef.current.click(); + } }, []); - if (selectedFiles.length === 0) { + // 如果没有选择文件,显示上传区域 + if (selectedFiles.length === 0 && !pickupCode) { return ( -
-
+
+
@@ -220,23 +223,36 @@ export default function FileUpload({ )} {pickupCode && ( - + <> + + + {selectedFiles.length > 0 && onClearFiles && ( + + )} + )}
diff --git a/chuan-next/src/components/TextTransfer-new.tsx b/chuan-next/src/components/TextTransfer-new.tsx new file mode 100644 index 0000000..f58c91a --- /dev/null +++ b/chuan-next/src/components/TextTransfer-new.tsx @@ -0,0 +1,468 @@ +"use client"; + +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 } from 'lucide-react'; +import { useToast } from '@/components/ui/toast-simple'; + +interface TextTransferProps { + onSendText?: (text: string) => Promise; // 返回取件码 + onReceiveText?: (code: string) => Promise; // 返回文本内容 + websocket?: WebSocket | null; + isConnected?: boolean; + currentRole?: 'sender' | 'receiver'; + pickupCode?: string; +} + +export default function TextTransfer({ + onSendText, + onReceiveText, + websocket, + isConnected = false, + currentRole, + pickupCode +}: TextTransferProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + const [mode, setMode] = useState<'send' | 'receive'>('send'); + const [textContent, setTextContent] = useState(''); + const [roomCode, setRoomCode] = useState(''); + const [receivedText, setReceivedText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRoomCreated, setIsRoomCreated] = useState(false); + const [connectedUsers, setConnectedUsers] = useState(0); + const [images, setImages] = useState([]); + const { showToast } = useToast(); + const textareaRef = useRef(null); + const updateTimeoutRef = useRef(null); + + // 从URL参数中获取初始模式 + useEffect(() => { + const urlMode = searchParams.get('mode') as 'send' | 'receive'; + const type = searchParams.get('type'); + + if (type === 'text' && urlMode && ['send', 'receive'].includes(urlMode)) { + setMode(urlMode); + } + }, [searchParams]); + + // 监听WebSocket消息 + useEffect(() => { + if (!websocket) return; + + const handleMessage = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data); + console.log('TextTransfer收到消息:', message); + + switch (message.type) { + case 'text-update': + // 实时更新文字内容 + if (message.payload?.text !== undefined) { + setReceivedText(message.payload.text); + if (currentRole === 'receiver') { + setTextContent(message.payload.text); + } + } + break; + + case 'text-send': + // 接收到发送的文字 + if (message.payload?.text) { + setReceivedText(message.payload.text); + showToast('收到新的文字内容!', 'success'); + } + break; + + case 'image-send': + // 接收到发送的图片 + if (message.payload?.imageData) { + setImages(prev => [...prev, message.payload.imageData]); + showToast('收到新的图片!', 'success'); + } + break; + + case 'room-status': + // 更新房间状态 + if (message.payload?.sender_count !== undefined && message.payload?.receiver_count !== undefined) { + setConnectedUsers(message.payload.sender_count + message.payload.receiver_count); + } + break; + } + } catch (error) { + console.error('解析WebSocket消息失败:', error); + } + }; + + websocket.addEventListener('message', handleMessage); + return () => websocket.removeEventListener('message', handleMessage); + }, [websocket, currentRole, showToast]); + + // 更新URL参数 + const updateMode = useCallback((newMode: 'send' | 'receive') => { + setMode(newMode); + const params = new URLSearchParams(searchParams.toString()); + params.set('type', 'text'); + params.set('mode', newMode); + router.push(`?${params.toString()}`, { scroll: false }); + }, [searchParams, router]); + + // 发送实时文字更新 + const sendTextUpdate = useCallback((text: string) => { + if (!websocket || !isConnected || !isRoomCreated) return; + + // 清除之前的定时器 + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + + // 设置新的定时器,防抖动 + updateTimeoutRef.current = setTimeout(() => { + websocket.send(JSON.stringify({ + type: 'text-update', + payload: { text } + })); + }, 300); // 300ms防抖 + }, [websocket, isConnected, isRoomCreated]); + + // 处理文字输入 + const handleTextChange = useCallback((e: React.ChangeEvent) => { + const newText = e.target.value; + setTextContent(newText); + + // 如果是发送方且房间已创建,发送实时更新 + if (currentRole === 'sender' && isRoomCreated) { + sendTextUpdate(newText); + } + }, [currentRole, isRoomCreated, sendTextUpdate]); + + // 创建文字传输房间 + const handleCreateRoom = useCallback(async () => { + if (!textContent.trim()) { + showToast('请输入要传输的文字内容', 'error'); + return; + } + + setIsLoading(true); + try { + if (onSendText) { + const code = await onSendText(textContent); + setRoomCode(code); + setIsRoomCreated(true); + showToast('房间创建成功!', 'success'); + } + } catch (error) { + console.error('创建房间失败:', error); + showToast('创建房间失败,请重试', 'error'); + } finally { + setIsLoading(false); + } + }, [textContent, onSendText, showToast]); + + // 加入房间 + const handleJoinRoom = useCallback(async () => { + if (!roomCode.trim() || roomCode.length !== 6) { + showToast('请输入正确的6位房间码', 'error'); + return; + } + + setIsLoading(true); + try { + if (onReceiveText) { + const text = await onReceiveText(roomCode); + setReceivedText(text); + showToast('成功加入房间!', 'success'); + } + } catch (error) { + console.error('加入房间失败:', error); + showToast('加入房间失败,请检查房间码', 'error'); + } finally { + setIsLoading(false); + } + }, [roomCode, onReceiveText, showToast]); + + // 发送文字 + const handleSendText = useCallback(() => { + if (!websocket || !isConnected || !textContent.trim()) return; + + websocket.send(JSON.stringify({ + type: 'text-send', + payload: { text: textContent } + })); + + showToast('文字已发送!', 'success'); + }, [websocket, isConnected, textContent, showToast]); + + // 处理图片粘贴 + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + 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]); + + // 发送图片给其他用户 + if (websocket && isConnected) { + websocket.send(JSON.stringify({ + type: 'image-send', + payload: { imageData } + })); + showToast('图片已发送!', 'success'); + } + }; + reader.readAsDataURL(file); + } + } + } + }, [websocket, isConnected, showToast]); + + const copyToClipboard = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + showToast('已复制到剪贴板!', 'success'); + } catch (err) { + showToast('复制失败', 'error'); + } + }, [showToast]); + + return ( +
+ {/* 模式切换 */} +
+
+ + +
+
+ + {mode === 'send' ? ( +
+
+
+ +
+

传送文字

+

+ {isRoomCreated ? '实时编辑,对方可以同步看到' : '输入要传输的文本内容'} +

+ {connectedUsers > 1 && ( +
+ + {connectedUsers} 人在线 +
+ )} +
+ +
+
+