diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 9c27fe6..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -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 }} diff --git a/chuan-next/src/app/HomePage.tsx b/chuan-next/src/app/HomePage.tsx index 9c039e9..171cb1e 100644 --- a/chuan-next/src/app/HomePage.tsx +++ b/chuan-next/src/app/HomePage.tsx @@ -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() { - + diff --git a/chuan-next/src/components/TabSwitchDialog.tsx b/chuan-next/src/components/TabSwitchDialog.tsx deleted file mode 100644 index 9453e25..0000000 --- a/chuan-next/src/components/TabSwitchDialog.tsx +++ /dev/null @@ -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 = ({ - open, - onOpenChange, - onConfirm, - onCancel, - description -}) => { - return ( - - - - 切换传输模式 - - {description} - - - - - - - - - ); -}; diff --git a/chuan-next/src/components/TextTransfer.tsx b/chuan-next/src/components/TextTransfer.tsx deleted file mode 100644 index d5b760c..0000000 --- a/chuan-next/src/components/TextTransfer.tsx +++ /dev/null @@ -1,1115 +0,0 @@ - "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, Link, Eye } from 'lucide-react'; -import { useToast } from '@/components/ui/toast-simple'; -import QRCodeDisplay from './QRCodeDisplay'; - -interface TextTransferProps { - onSendText?: (text: string) => Promise; // 返回取件码 - onReceiveText?: (code: string) => Promise; // 返回文本内容 - websocket?: WebSocket | null; - isConnected?: boolean; // WebRTC数据通道连接状态 - isWebSocketConnected?: boolean; // WebSocket信令连接状态 - currentRole?: 'sender' | 'receiver'; - pickupCode?: string; - onCreateWebSocket?: (code: string, role: 'sender' | 'receiver') => void; // 创建WebSocket连接 -} - -export default function TextTransfer({ - onSendText, - onReceiveText, - websocket, - isConnected = false, // WebRTC数据通道连接状态 - isWebSocketConnected = false, // WebSocket信令连接状态 - currentRole, - pickupCode, - onCreateWebSocket -}: 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 [sentImages, setSentImages] = useState([]); // 发送的图片 - const [receivedImages, setReceivedImages] = useState([]); // 接收的图片 - const [imagePreview, setImagePreview] = useState(null); // 图片预览状态 - const [currentWebSocketConnected, setCurrentWebSocketConnected] = useState(false); // 本地WebSocket连接状态 - const [previewImage, setPreviewImage] = useState(null); // 图片预览弹窗状态 - const [hasShownJoinSuccess, setHasShownJoinSuccess] = useState(false); // 防止重复显示加入成功消息 - const [lastToastMessage, setLastToastMessage] = useState(''); // 防止重复Toast - const [lastToastTime, setLastToastTime] = useState(0); // 上次Toast时间 - const { showToast } = useToast(); - const textareaRef = useRef(null); - const updateTimeoutRef = useRef(null); - const connectionTimeoutRef = useRef(null); // 连接超时定时器 - - // 优化的Toast显示函数,避免重复消息 - const showOptimizedToast = useCallback((message: string, type: 'success' | 'error' | 'info') => { - const now = Date.now(); - // 如果是相同消息且在3秒内,不重复显示 - if (lastToastMessage === message && now - lastToastTime < 3000) { - return; - } - setLastToastMessage(message); - setLastToastTime(now); - showToast(message, type); - }, [lastToastMessage, lastToastTime, showToast]); - - // 从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); - - // 如果是接收模式且URL中有房间码,只填入房间码,不自动连接 - const urlCode = searchParams.get('code'); - if (urlMode === 'receive' && urlCode && urlCode.length === 6) { - setRoomCode(urlCode.toUpperCase()); - } - } - }, [searchParams]); - - // 监听WebSocket消息和连接事件 - useEffect(() => { - const handleWebSocketMessage = (event: CustomEvent) => { - const message = event.detail; - console.log('TextTransfer收到消息:', message); - - switch (message.type) { - case 'websocket-signaling-connected': - console.log('收到WebSocket信令连接成功事件:', message); - - // 立即更新本地信令连接状态 - setCurrentWebSocketConnected(true); - - // 只对接收方显示信令连接提示,发送方不需要 - if (currentRole === 'receiver') { - showOptimizedToast('正在建立连接...', 'success'); - } - break; - - case 'webrtc-connecting': - console.log('收到WebRTC数据通道连接中事件:', message); - // 显示数据通道连接中状态 - break; - - case 'webrtc-connected': - console.log('收到WebRTC数据通道连接成功事件:', message); - - // 清除连接超时定时器 - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - - // 只显示一个简洁的连接成功提示 - showOptimizedToast('连接成功!', 'success'); - break; - - case 'text-content': - // 接收到文字房间的初始内容或同步内容 - if (message.payload?.text !== undefined) { - setReceivedText(message.payload.text); - if (currentRole === 'receiver') { - setTextContent(message.payload.text); - // 移除重复的成功消息,因为连接成功时已经显示了 - } - // 清除连接超时定时器 - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - // 结束loading状态 - if (isLoading) { - setIsLoading(false); - } - } - break; - - case 'text-update': - // 实时更新文字内容 - if (message.payload?.text !== undefined) { - setReceivedText(message.payload.text); - if (currentRole === 'receiver') { - setTextContent(message.payload.text); - } - } - break; - - case 'text-send': - // 接收到发送的文字,不显示Toast,因为UI已经更新了 - if (message.payload?.text) { - setReceivedText(message.payload.text); - } - break; - - case 'image-send': - // 接收到发送的图片 - if (message.payload?.imageData) { - console.log('接收到图片数据:', message.payload.imageData.substring(0, 100) + '...'); - // 验证图片数据格式 - if (message.payload.imageData.startsWith('data:image/')) { - setReceivedImages(prev => [...prev, message.payload.imageData]); - // 只在有实际图片时显示提示 - showOptimizedToast('收到图片', 'success'); - } else { - console.error('无效的图片数据格式:', message.payload.imageData.substring(0, 50)); - showOptimizedToast('图片格式错误', 'error'); - } - } - 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; - - case 'webrtc-error': - console.error('收到WebRTC错误事件:', message.payload); - // 清除连接超时定时器 - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - // 结束loading状态 - if (isLoading) { - setIsLoading(false); - } - // 显示错误消息 - if (message.payload?.message) { - showOptimizedToast(message.payload.message, 'error'); - } - break; - - case 'websocket-close': - console.log('收到WebSocket关闭事件:', message.payload); - // 更新本地连接状态 - setCurrentWebSocketConnected(false); - // 清除连接超时定时器 - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - // 结束loading状态 - if (isLoading) { - setIsLoading(false); - } - break; - } - }; - - const handleWebSocketClose = (event: CustomEvent) => { - const { code, reason } = event.detail; - console.log('WebSocket连接关闭:', code, reason); - - // 如果是在loading状态下连接关闭,说明连接失败 - if (isLoading) { - setIsLoading(false); - if (code !== 1000) { // 不是正常关闭 - showOptimizedToast('房间已关闭', 'error'); - } - } - }; - - const handleWebSocketConnecting = (event: CustomEvent) => { - console.log('WebSocket正在连接:', event.detail); - // 可以在这里显示连接中的状态 - }; - - const handleWebSocketError = (event: CustomEvent) => { - console.error('WebSocket连接错误:', event.detail); - - // 如果是在loading状态下出现错误,结束loading并显示错误 - if (isLoading) { - setIsLoading(false); - showOptimizedToast('连接失败', 'error'); - } - - // 清除连接超时定时器 - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - }; - - window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); - window.addEventListener('websocket-connecting', handleWebSocketConnecting as EventListener); - window.addEventListener('websocket-close', handleWebSocketClose as EventListener); - window.addEventListener('websocket-error', handleWebSocketError as EventListener); - - return () => { - window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); - window.removeEventListener('websocket-connecting', handleWebSocketConnecting as EventListener); - window.removeEventListener('websocket-close', handleWebSocketClose as EventListener); - window.removeEventListener('websocket-error', handleWebSocketError as EventListener); - - // 清理定时器 - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - } - }; - }, [currentRole, showOptimizedToast, hasShownJoinSuccess, isLoading]); - - // 更新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) => { - // 必须通过WebRTC数据通道发送,不能通过WebSocket信令 - if (!websocket || !isConnected) { - console.log('WebRTC数据通道未连接,无法发送实时更新。信令状态:', isWebSocketConnected, '数据通道状态:', isConnected); - return; - } - - // 清除之前的定时器 - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current); - } - - // 设置新的定时器,防抖动 - updateTimeoutRef.current = setTimeout(() => { - // 通过WebRTC数据通道发送实时更新 - websocket.send(JSON.stringify({ - type: 'text-update', - payload: { text } - })); - }, 300); // 300ms防抖 - }, [websocket, isConnected, isWebSocketConnected]); - - // 处理文字输入 - const handleTextChange = useCallback((e: React.ChangeEvent) => { - const newText = e.target.value; - setTextContent(newText); - - // 如果有WebSocket连接,发送实时更新 - if (isConnected && websocket) { - sendTextUpdate(newText); - } - }, [isConnected, websocket, sendTextUpdate]); - - // 创建文字传输房间 - const handleCreateRoom = useCallback(async () => { - setIsLoading(true); - try { - // 使用统一的API创建房间(不区分类型) - 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 || !data.success) { - throw new Error(data.message || '创建房间失败'); - } - - const code = data.code; - setRoomCode(code); - setIsRoomCreated(true); - setIsLoading(false); // 立即结束loading,显示UI - // 移除创建成功Toast,UI变化已经足够明显 - - // 立即创建WebSocket连接用于实时同步 - if (onCreateWebSocket) { - console.log('房间创建成功,立即建立WebRTC连接:', code); - onCreateWebSocket(code, 'sender'); - } - } catch (error) { - console.error('创建房间失败:', error); - showOptimizedToast(error instanceof Error ? error.message : '创建失败', 'error'); - setIsLoading(false); - } - }, [onCreateWebSocket, showOptimizedToast]); - - // 加入房间 - const handleJoinRoom = useCallback(async () => { - if (!roomCode.trim() || roomCode.length !== 6) { - showOptimizedToast('请输入6位房间码', 'error'); - return; - } - - // 防止重复加入 - if (isLoading) { - return; - } - - setIsLoading(true); - - try { - // 先查询房间信息,确认房间存在 - const roomInfoResponse = await fetch(`/api/room-info?code=${roomCode}`); - const roomData = await roomInfoResponse.json(); - - if (!roomInfoResponse.ok || !roomData.success) { - showOptimizedToast(roomData.message || '房间不存在', 'error'); - setIsLoading(false); - return; - } - - // 房间存在,立即显示界面和文本框 - setHasShownJoinSuccess(true); - setReceivedText(''); // 立即设置为空字符串以显示文本框 - setIsLoading(false); // 立即结束loading,显示UI - // 移除加入成功Toast,UI变化已经足够明显 - - // 创建WebSocket连接用于实时同步 - if (onCreateWebSocket) { - console.log('房间验证成功,开始建立WebRTC连接:', roomCode); - onCreateWebSocket(roomCode, 'receiver'); - } - } catch (error) { - console.error('加入房间失败:', error); - showOptimizedToast('网络错误', 'error'); - setIsLoading(false); - } - }, [roomCode, onCreateWebSocket, showOptimizedToast, isLoading]); - - // 压缩图片 - 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); - console.log('图片压缩完成,数据长度:', compressedDataUrl.length, '前100字符:', compressedDataUrl.substring(0, 100)); - 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(async (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) { - try { - showOptimizedToast('处理中...', 'info'); - const compressedImageData = await compressImage(file); - setSentImages(prev => [...prev, compressedImageData]); - - // 必须通过WebRTC数据通道发送图片 - if (websocket && isConnected) { - websocket.send(JSON.stringify({ - type: 'image-send', - payload: { imageData: compressedImageData } - })); - // 移除发送成功Toast,视觉反馈已经足够 - } else { - showOptimizedToast('连接断开', 'error'); - } - } catch (error) { - console.error('图片处理失败:', error); - showOptimizedToast('处理失败', 'error'); - } - } - } - } - }, [websocket, isConnected, showOptimizedToast, compressImage]); - - const copyToClipboard = useCallback(async (text: string) => { - try { - await navigator.clipboard.writeText(text); - showOptimizedToast('已复制', 'success'); - } catch (err) { - showOptimizedToast('复制失败', 'error'); - } - }, [showOptimizedToast]); - - // 复制传输链接 - const copyTransferLink = useCallback(async (code: string) => { - const currentUrl = window.location.origin + window.location.pathname; - const transferLink = `${currentUrl}?type=text&mode=receive&code=${code}`; - 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); - showOptimizedToast('已保存', 'success'); - }, [showOptimizedToast]); - - // 图片预览组件 - const ImagePreviewModal = ({ src, onClose }: { src: string; onClose: () => void }) => ( -
-
-
- 预览 e.stopPropagation()} - onError={(e) => { - console.error('预览图片加载失败:', src); - }} - /> - - {/* 操作按钮栏 */} -
-
-

图片预览

-
- - -
-
-
- - {/* 底部信息栏 */} -
-
- 点击空白区域关闭预览 -
-
-
-
-
- ); - - return ( -
- {/* 模式切换 */} -
-
- - -
-
- - {mode === 'send' ? ( -
- {/* 功能标题和状态 */} -
-
-
- -
-
-

传送文字

-

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

-
-
- - {/* 竖线分割 */} -
- - {/* 状态显示 */} -
-
连接状态
-
- {/* WebSocket信令状态 */} -
- {isRoomCreated ? ( - isWebSocketConnected ? ( - <> -
- WS - - ) : ( - <> -
- WS - - ) - ) : ( - <> -
- WS - - )} -
- - {/* 分隔符 */} -
|
- - {/* WebRTC数据通道状态 */} -
- {isRoomCreated ? ( - isConnected ? ( - <> -
- RTC - - ) : ( - <> -
- RTC - - ) - ) : ( - <> -
- RTC - - )} -
-
- {connectedUsers > 0 && ( -
- {connectedUsers} 人在线 -
- )} -
-
- -
- - {!isRoomCreated ? ( -
-
- -
-

创建文字传输房间

-

创建房间后可以实时同步文字内容

- - -
- ) : ( -
- {/* 文字编辑区域 - 移到最上面 */} -
-
-

- - 文字内容 -

-
- {textContent.length} / 50,000 字符 - {isConnected && ( -
-
- WebRTC实时同步 -
- )} - {isWebSocketConnected && !isConnected && ( -
-
- 建立数据通道中 -
- )} -
-
- -
-