7 Commits

Author SHA1 Message Date
MatrixSeven
055deea67a feat:docker镜像构建发布 2025-08-28 18:55:22 +08:00
MatrixSeven
0fd8899fc6 feat添加切换tab提示 2025-08-28 16:45:27 +08:00
MatrixSeven
4bf0ce447d feat:拆分hooks 2025-08-28 16:19:09 +08:00
MatrixSeven
bc01224c11 feat:shareConnect拆分|处理effect竞争引发的Bug 2025-08-28 15:31:21 +08:00
MatrixSeven
63e6e956e4 feat:webrtc支持检测|房间检测|UI状态优化 2025-08-26 18:52:29 +08:00
MatrixSeven
301434fd4c feat:更新文档,移除QR 2025-08-25 11:34:04 +08:00
MatrixSeven
fbb5135eed feat: bug 反馈 QR 2025-08-25 10:16:45 +08:00
56 changed files with 4894 additions and 1031 deletions

91
.dockerignore Normal file
View File

@@ -0,0 +1,91 @@
# ==============================================
# Docker 忽略文件
# 优化构建上下文,减少构建时间
# ==============================================
# Git 相关
.git
.gitignore
.gitattributes
# 文档
README.md
*.md
docs/
# 开发配置
.env
.env.local
.env.development
.env.test
.env.production
.envrc
# IDE 和编辑器
.vscode/
.idea/
*.swp
*.swo
*~
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
.yarn-integrity
# Next.js
chuan-next/.next/
chuan-next/out/
chuan-next/.env*
chuan-next/build.log
# Go 相关
# *.sum # 注释掉这行,因为需要 go.sum 文件
vendor/
# 构建输出
dist/
build/
*.exe
*.exe~
# 日志文件
*.log
logs/
# 临时文件
tmp/
temp/
.tmp
# OS 生成文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Docker 相关
Dockerfile*
docker-compose*
.dockerignore
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
# 测试
coverage/
.nyc_output/
.coverage
# 其他构建工具
.sass-cache/
.cache/

129
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: 🐳 Build and Push Docker Image
on:
# 手动触发
workflow_dispatch:
inputs:
version:
description: '版本号 (例如: v1.0.5)'
required: true
default: 'v1.0.5'
type: string
push_to_hub:
description: '推送到 Docker Hub'
required: true
default: true
type: boolean
build_multiarch:
description: '构建多架构镜像 (amd64 + arm64)'
required: true
default: true
type: boolean
# 推送标签时触发
push:
tags:
- 'v*.*.*'
# PR 时构建测试(不推送)
pull_request:
branches: [ main ]
env:
REGISTRY: docker.io
IMAGE_NAME: matrixseven/file-transfer-go
jobs:
build:
name: 🏗️ Build & Push Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🏷️ Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
- name: 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: 🔑 Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: 🐳 Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: ${{ (github.event_name == 'workflow_dispatch' && inputs.build_multiarch == true) || github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
push: ${{ github.event_name != 'pull_request' && (inputs.push_to_hub != false) }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: 📊 Image digest
if: github.event_name != 'pull_request'
run: echo ${{ steps.build.outputs.digest }}
- name: 🎉 Build Summary
if: always()
run: |
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
echo "- **Registry**: ${{ env.REGISTRY }}" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: ${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "- **Platforms**: ${{ (github.event_name == 'workflow_dispatch' && inputs.build_multiarch == true) || github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🚀 Usage" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "docker run -d -p 8080:8080 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version || 'latest' }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
security-scan:
name: 🔍 Security Scan
runs-on: ubuntu-latest
needs: build
if: github.event_name != 'pull_request'
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🔍 Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest'
format: 'sarif'
output: 'trivy-results.sarif'
- name: 📤 Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'

65
Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
# ==============================================
# 最简 Dockerfile - 专为 Docker 环境优化
# ==============================================
FROM node:20-alpine AS builder
# 国内镜像源优化
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
npm config set registry https://registry.npmmirror.com
# 安装必要工具
RUN apk add --no-cache bash git curl wget make ca-certificates tzdata
# 安装 Go
ENV GO_VERSION=1.21.5
RUN wget https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-amd64.tar.gz && \
tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz && \
rm go${GO_VERSION}.linux-amd64.tar.gz
# Go 环境
ENV PATH=/usr/local/go/bin:$PATH
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /app
# Go 依赖
COPY go.mod go.sum ./
RUN go mod download
# 前端依赖和构建
COPY chuan-next/package.json ./chuan-next/
RUN cd chuan-next && npm install
COPY chuan-next/ ./chuan-next/
# 临时移除 API 目录进行 SSG 构建(模仿 build-fullstack.sh
RUN cd chuan-next && \
if [ -d "src/app/api" ]; then mv src/app/api /tmp/api-backup; fi && \
NEXT_EXPORT=true npm run build && \
if [ -d "/tmp/api-backup" ]; then mv /tmp/api-backup src/app/api; fi
# Go 源码和构建
COPY cmd/ ./cmd/
COPY internal/ ./internal/
# 嵌入前端文件
RUN mkdir -p internal/web/frontend && \
cp -r chuan-next/out/* internal/web/frontend/
# 构建 Go 应用
RUN CGO_ENABLED=0 go build -ldflags='-w -s' -o server ./cmd
# ==============================================
FROM alpine:3.18
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk add --no-cache ca-certificates tzdata && \
adduser -D -s /bin/sh appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/server ./
USER appuser
EXPOSE 8080
CMD ["./server"]

View File

@@ -3,7 +3,7 @@
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
[在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
## [在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
![项目演示](img.png)
@@ -31,6 +31,12 @@
## 🔄 最近更新日志
### 2025-08-28
-**完善Docker部署支持** - 优化Docker配置支持一键部署和多环境配置
-**优化README文档** - 更新项目说明,完善部署指南和技术栈信息
-**改进UI用户体验** - 优化界面细节,完善错误提示和加载状态
-**重构Hooks架构** - 拆分复杂hooks提高代码复用性和可维护性
### 2025-08-24
-**文件传输 ACK 确认支持** - 实现了可靠的数据传输机制,每个数据块都需要接收方确认
-**修复组件渲染后重复注册/解绑 bug** - 解决了 React 组件重复渲染导致的处理器反复注册问题
@@ -44,14 +50,50 @@
## 🚀 技术栈
### 前端技术栈
- **Next.js 15** - React全栈框架支持SSR/SSG
- **React 18** - 现代化UI组件库
- **TypeScript 5** - 类型安全的JavaScript超集
- **Tailwind CSS 3.4** - 实用优先的CSS框架
- **Radix UI** - 无障碍访问的组件库
- **Zustand** - 轻量级状态管理
- **Lucide React** - 现代化图标库
### 后端技术栈
- **Go 1.22** - 高性能编程语言
- **WebSocket** - 实时双向通信
- **内存存储** - 轻量级数据存储
- **标准库** - 原生HTTP服务器
### 传输协议
- **WebRTC DataChannel** - 端到端数据传输
- **P2P直连** - 点对点连接,无需中转
- **ICE框架** - 网络连接协商
- **STUN/TURN** - NAT穿透支持
**前端** - Next.js 15 + React 18 + TypeScript + Tailwind CSS
**后端** - Go + WebSocket + 内存存储
**传输** - WebRTC DataChannel + P2P直连
### 架构特点
- **微服务架构** - 前后端分离
- **实时通信** - WebSocket + WebRTC
- **响应式设计** - 移动端适配
- **容器化** - Docker部署支持
## 📦 快速部署
### 方式一Docker 一键部署(推荐)
```bash
# 使用 Docker Compose最简单
git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
docker-compose up -d
# 或者直接使用 Docker 镜像
docker run -d -p 8080:8080 --name file-transfer-go matrixseven/file-transfer:latest
```
### 方式二:本地构建部署
```bash
git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
@@ -61,6 +103,44 @@ cd file-transfer-go
访问 http://localhost:8080 开始使用
### 方式三:开发环境部署
```bash
# 后端服务
make dev
# 前端服务(新终端)
cd chuan-next && yarn && yarn dev
```
### 部署配置说明
#### 环境变量配置
- `NODE_ENV`: 运行环境development/production
- `PORT`: 服务端口默认8080
- `GO_BACKEND_URL`: 后端服务地址
#### Docker 配置选项
```yaml
# docker-compose.yml 可配置项
environment:
- NODE_ENV=production
- PORT=8080
ports:
- "8080:8080"
restart: unless-stopped
```
#### 多架构支持
项目支持多架构Docker镜像
- `linux/amd64` - x86_64 架构
- `linux/arm64` - ARM 64位架构
#### 镜像版本
- `latest` - 最新稳定版本
- `v1.0.x` - 特定版本号
- `dev` - 开发版本
## 🎯 使用方法
### 发送文件

View File

@@ -1,46 +1,57 @@
"use client";
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Upload, MessageSquare, Monitor, TestTube } from 'lucide-react';
import { Upload, MessageSquare, Monitor, Users } from 'lucide-react';
import Hero from '@/components/Hero';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
import DesktopShare from '@/components/DesktopShare';
import WeChatGroup from '@/components/WeChatGroup';
import { WebRTCUnsupportedModal } from '@/components/WebRTCUnsupportedModal';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useWebRTCSupport } from '@/hooks/connection';
import { useTabNavigation, TabType } from '@/hooks/ui';
export default function HomePage() {
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('webrtc');
const [hasInitialized, setHasInitialized] = useState(false);
// 使用tab导航hook
const {
activeTab,
handleTabChange,
getConnectionInfo,
hasInitialized,
confirmDialogState,
closeConfirmDialog
} = useTabNavigation();
// 根据URL参数设置初始标签仅首次加载时
useEffect(() => {
if (!hasInitialized) {
const urlType = searchParams.get('type');
console.log('=== HomePage URL处理 ===');
console.log('URL type参数:', urlType);
console.log('所有搜索参数:', Object.fromEntries(searchParams.entries()));
// 将旧的text类型重定向到message
if (urlType === 'text') {
console.log('检测到text类型重定向到message标签页');
setActiveTab('message');
} else if (urlType === 'webrtc') {
// webrtc类型对应文件传输标签页
console.log('检测到webrtc类型切换到webrtc标签页文件传输');
setActiveTab('webrtc');
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
console.log('切换到对应标签页:', urlType);
setActiveTab(urlType);
} else {
console.log('没有有效的type参数使用默认标签页webrtc文件传输');
}
setHasInitialized(true);
}
}, [searchParams, hasInitialized]);
// WebRTC 支持检测
const {
webrtcSupport,
isSupported,
isChecked,
showUnsupportedModal,
closeUnsupportedModal,
showUnsupportedModalManually,
} = useWebRTCSupport();
// 桌面共享功能的占位符函数(保持向后兼容)
const handleStartSharing = async () => {
console.log('开始桌面共享');
};
const handleStopSharing = async () => {
console.log('停止桌面共享');
};
const handleJoinSharing = async (code: string) => {
console.log('加入桌面共享:', code);
};
// 处理Tabs组件的字符串参数
const handleTabChangeWrapper = (value: string) => {
// 类型转换并调用实际的处理函数
handleTabChange(value as TabType);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
@@ -50,56 +61,137 @@ export default function HomePage() {
<Hero />
</div>
{/* Main Content */}
<div className="max-w-4xl mx-auto">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
{/* Tabs Navigation - 横向布局 */}
<div className="mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
<TabsTrigger
value="webrtc"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger
value="message"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
>
<MessageSquare className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger
value="desktop"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600"
>
<Monitor className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* WebRTC 支持检测加载状态 */}
{!isChecked && (
<div className="max-w-4xl mx-auto text-center py-8">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-48 mx-auto mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<p className="mt-4 text-gray-600">...</p>
</div>
)}
{/* Tab Content */}
<div>
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
<WebRTCFileTransfer />
</TabsContent>
{/* 主要内容 - 只有在检测完成后才显示 */}
{isChecked && (
<div className="max-w-4xl mx-auto">
{/* WebRTC 不支持时的警告横幅 */}
{!isSupported && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-red-700 font-medium">
WebRTC使
</span>
</div>
<button
onClick={showUnsupportedModalManually}
className="text-red-600 hover:text-red-800 text-sm underline"
>
</button>
</div>
</div>
)}
<TabsContent value="message" className="mt-0 animate-fade-in-up">
<WebRTCTextImageTransfer />
</TabsContent>
<Tabs value={activeTab} onValueChange={handleTabChangeWrapper} className="w-full">
{/* Tabs Navigation - 横向布局 */}
<div className="mb-6">
<TabsList className="grid w-full grid-cols-4 max-w-2xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
<TabsTrigger
value="webrtc"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
disabled={!isSupported}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="message"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
disabled={!isSupported}
>
<MessageSquare className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="desktop"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600"
disabled={!isSupported}
>
<Monitor className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="wechat"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-green-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-green-600"
>
<Users className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* WebRTC 不支持时的提示 */}
{!isSupported && (
<p className="text-center text-xs text-gray-500 mt-2">
* WebRTC 使
</p>
)}
</div>
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
<DesktopShare />
</TabsContent>
</div>
</Tabs>
</div>
{/* Tab Content */}
<div>
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
<WebRTCFileTransfer />
</TabsContent>
<TabsContent value="message" className="mt-0 animate-fade-in-up">
<WebRTCTextImageTransfer />
</TabsContent>
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
<DesktopShare />
</TabsContent>
<TabsContent value="wechat" className="mt-0 animate-fade-in-up">
<WeChatGroup />
</TabsContent>
</div>
</Tabs>
</div>
)}
</div>
{/* WebRTC 不支持提示模态框 */}
{webrtcSupport && (
<WebRTCUnsupportedModal
isOpen={showUnsupportedModal}
onClose={closeUnsupportedModal}
webrtcSupport={webrtcSupport}
/>
)}
{/* 自定义确认对话框 */}
{confirmDialogState && (
<ConfirmDialog
isOpen={confirmDialogState.isOpen}
onClose={closeConfirmDialog}
onConfirm={confirmDialogState.onConfirm}
title={confirmDialogState.title}
message={confirmDialogState.message}
confirmText={confirmDialogState.confirmText}
cancelText={confirmDialogState.cancelText}
type={confirmDialogState.type}
/>
)}
</div>
);
}

View File

@@ -6,14 +6,14 @@ export async function POST(request: NextRequest) {
try {
console.log('API Route: Creating room, proxying to:', `${GO_BACKEND_URL}/api/create-room`);
const body = await request.json();
// 不再需要解析和转发请求体,因为后端会忽略它们
const response = await fetch(`${GO_BACKEND_URL}/api/create-room`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
// 发送空body即可
body: JSON.stringify({}),
});
const data = await response.json();

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
import { useWebRTCStore } from '@/hooks/index';
interface ConnectionStatusProps {
// 房间信息 - 只需要这个基本信息

View File

@@ -1,13 +1,12 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import React, { useState, useCallback } from 'react';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
import { Share, Monitor } from 'lucide-react';
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
import { useWebRTCStore } from '@/hooks/index';
interface DesktopShareProps {
@@ -22,55 +21,28 @@ export default function DesktopShare({
onStopSharing,
onJoinSharing
}: DesktopShareProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [mode, setMode] = useState<'share' | 'view'>('share');
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 从URL参数中获取初始模式和房间代码
useEffect(() => {
const urlMode = searchParams.get('mode');
const type = searchParams.get('type');
const urlCode = searchParams.get('code');
if (type === 'desktop' && urlMode) {
if (urlMode === 'send') {
setMode('share');
} else if (urlMode === 'receive') {
setMode('view');
// 如果URL中有房间代码将在DesktopShareReceiver组件中自动加入
}
// 使用统一的URL处理器带模式转换
const { updateMode, getCurrentRoomCode } = useURLHandler({
featureType: 'desktop',
onModeChange: setMode,
onAutoJoinRoom: onJoinSharing,
modeConverter: {
fromURL: (urlMode) => urlMode === 'send' ? 'share' : 'view',
toURL: (componentMode) => componentMode === 'share' ? 'send' : 'receive'
}
}, [searchParams]);
// 更新URL参数
const updateMode = useCallback((newMode: 'share' | 'view') => {
setMode(newMode);
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('type', 'desktop');
currentUrl.searchParams.set('mode', newMode === 'share' ? 'send' : 'receive');
// 清除代码参数,避免模式切换时的混乱
currentUrl.searchParams.delete('code');
router.replace(currentUrl.pathname + currentUrl.search);
}, [router]);
});
// 获取初始房间代码(用于接收者模式)
const getInitialCode = useCallback(() => {
const urlMode = searchParams.get('mode');
const type = searchParams.get('type');
const code = searchParams.get('code');
console.log('[DesktopShare] getInitialCode 调用, URL参数:', { type, urlMode, code });
if (type === 'desktop' && urlMode === 'receive') {
const result = code || '';
console.log('[DesktopShare] getInitialCode 返回:', result);
return result;
}
console.log('[DesktopShare] getInitialCode 返回空字符串');
return '';
}, [searchParams]);
const code = getCurrentRoomCode();
console.log('[DesktopShare] getInitialCode 返回:', code);
return code;
}, [getCurrentRoomCode]);
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {

View File

@@ -0,0 +1,68 @@
"use client";
import React from 'react';
import { Users } from 'lucide-react';
export default function WeChatGroup() {
return (
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-6 sm:p-8 animate-fade-in-up">
<div className="text-center">
{/* 标题 */}
<div className="mb-6">
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-500 rounded-xl flex items-center justify-center mx-auto mb-4">
<Users className="w-6 h-6 text-white" />
</div>
<h2 className="text-2xl font-bold text-slate-800 mb-2"></h2>
<p className="text-slate-600 text-lg">
//bug反馈或者奇思妙想想来交流
</p>
</div>
{/* 二维码区域 */}
<div className="flex justify-center mb-6">
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
{/* 微信群二维码 - 请将此区域替换为实际的二维码图片 */}
<div className="relative">
<img
src="https://cdn-img.luxika.cc//i/2025/08/25/68abd75c363a6.png"
alt="微信群二维码"
className="w-64 h-64 rounded-xl"
/>
</div>
</div>
</div>
{/* 说明文字 */}
<div className="bg-green-50 rounded-xl p-6 border border-green-200">
<div className="text-sm text-green-700 space-y-2">
<p className="text-base font-semibold text-green-800 mb-3">🎉 </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-left">
<div className="flex items-center space-x-2">
<span>💬</span>
<span>使</span>
</div>
<div className="flex items-center space-x-2">
<span>🐛</span>
<span>bug</span>
</div>
<div className="flex items-center space-x-2">
<span>💡</span>
<span></span>
</div>
<div className="flex items-center space-x-2">
<span>🤝</span>
<span></span>
</div>
</div>
</div>
</div>
{/* 额外信息 */}
<div className="mt-4 text-xs text-slate-500">
<p>广</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import React from 'react';
import { AlertCircle, Wifi, WifiOff, Loader2, RotateCcw } from 'lucide-react';
import { WebRTCConnection } from '@/hooks/connection/useSharedWebRTCManager';
interface Props {
webrtc: WebRTCConnection;
className?: string;
}
/**
* WebRTC连接状态显示组件
* 显示详细的连接状态、错误信息和重试按钮
*/
export function WebRTCConnectionStatus({ webrtc, className = '' }: Props) {
const {
isConnected,
isConnecting,
isWebSocketConnected,
isPeerConnected,
error,
canRetry,
retry
} = webrtc;
// 状态图标
const getStatusIcon = () => {
if (isConnecting) {
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
}
if (error) {
// 区分信息提示和错误
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
return <WifiOff className="h-4 w-4 text-yellow-500" />;
}
return <AlertCircle className="h-4 w-4 text-red-500" />;
}
if (isPeerConnected) {
return <Wifi className="h-4 w-4 text-green-500" />;
}
if (isWebSocketConnected) {
return <Wifi className="h-4 w-4 text-yellow-500" />;
}
return <WifiOff className="h-4 w-4 text-gray-400" />;
};
// 状态文本
const getStatusText = () => {
if (error) {
return error;
}
if (isConnecting) {
return '正在连接...';
}
if (isPeerConnected) {
return 'P2P连接已建立';
}
if (isWebSocketConnected) {
return '信令服务器已连接';
}
return '未连接';
};
// 状态颜色
const getStatusColor = () => {
if (error) {
// 区分信息提示和错误
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
return 'text-yellow-600';
}
return 'text-red-600';
}
if (isConnecting) return 'text-blue-600';
if (isPeerConnected) return 'text-green-600';
if (isWebSocketConnected) return 'text-yellow-600';
return 'text-gray-600';
};
const handleRetry = async () => {
try {
await retry();
} catch (error) {
console.error('重试连接失败:', error);
}
};
return (
<div className={`flex items-center justify-between p-3 bg-white border rounded-lg ${className}`}>
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className={`text-sm font-medium ${getStatusColor()}`}>
{getStatusText()}
</span>
</div>
{/* 连接详细状态指示器 */}
<div className="flex items-center gap-1">
{/* WebSocket状态 */}
<div
className={`w-2 h-2 rounded-full ${
isWebSocketConnected ? 'bg-green-400' : 'bg-gray-300'
}`}
title={isWebSocketConnected ? 'WebSocket已连接' : 'WebSocket未连接'}
/>
{/* P2P状态 */}
<div
className={`w-2 h-2 rounded-full ${
isPeerConnected ? 'bg-green-400' : 'bg-gray-300'
}`}
title={isPeerConnected ? 'P2P连接已建立' : 'P2P连接未建立'}
/>
{/* 重试按钮 */}
{canRetry && (
<button
onClick={handleRetry}
disabled={isConnecting}
className="ml-2 p-1 text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
title="重试连接"
>
<RotateCcw className={`h-3 w-3 ${isConnecting ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
);
}
/**
* 简单的连接状态指示器(用于空间受限的地方)
*/
export function WebRTCStatusIndicator({ webrtc, className = '' }: Props) {
const { isPeerConnected, isConnecting, error } = webrtc;
if (error) {
// 区分信息提示和错误
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-yellow-400 rounded-full" />
<span className="text-xs text-yellow-600"></span>
</div>
);
}
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-red-400 rounded-full animate-pulse" />
<span className="text-xs text-red-600"></span>
</div>
);
}
if (isConnecting) {
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
<span className="text-xs text-blue-600"></span>
</div>
);
}
if (isPeerConnected) {
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-green-400 rounded-full" />
<span className="text-xs text-green-600"></span>
</div>
);
}
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-gray-300 rounded-full" />
<span className="text-xs text-gray-600"></span>
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client";
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
import { useSharedWebRTCManager, useConnectionState, useRoomConnection } from '@/hooks/connection';
import { useFileTransferBusiness, useFileListSync, useFileStateManager } from '@/hooks/file-transfer';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
@@ -20,29 +20,20 @@ interface FileInfo {
}
export const WebRTCFileTransfer: React.FC = () => {
const searchParams = useSearchParams();
const router = useRouter();
const { showToast } = useToast();
// 独立的文件状态
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [fileList, setFileList] = useState<FileInfo[]>([]);
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
// 基础状态
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [pickupCode, setPickupCode] = useState('');
const [currentTransferFile, setCurrentTransferFile] = useState<{
fileId: string;
fileName: string;
progress: number;
} | null>(null);
// 房间状态
const [pickupCode, setPickupCode] = useState('');
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
const [isJoiningRoom, setIsJoiningRoom] = useState(false); // 添加加入房间状态
const urlProcessedRef = useRef(false); // 使用 ref 防止重复处理 URL
const fileInputRef = useRef<HTMLInputElement>(null);
// 创建共享连接 - 使用 useMemo 稳定引用
// 创建共享连接
const connection = useSharedWebRTCManager();
const stableConnection = useMemo(() => connection, [connection.isConnected, connection.isConnecting, connection.isWebSocketConnected, connection.error]);
@@ -63,241 +54,70 @@ export const WebRTCFileTransfer: React.FC = () => {
onFileProgress
} = useFileTransferBusiness(stableConnection);
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
// 使用自定义 hooks
const { syncFileListToReceiver } = useFileListSync({
sendFileList,
mode,
pickupCode,
isConnected,
isPeerConnected: connection.isPeerConnected,
getChannelState: connection.getChannelState
});
const {
selectedFiles,
setSelectedFiles,
fileList,
setFileList,
downloadedFiles,
setDownloadedFiles,
handleFileSelect,
clearFiles,
resetFiles,
updateFileStatus,
updateFileProgress
} = useFileStateManager({
mode,
pickupCode,
syncFileListToReceiver,
isPeerConnected: connection.isPeerConnected
});
const { joinRoom: originalJoinRoom, isJoiningRoom } = useRoomConnection({
connect,
isConnecting,
isConnected
});
// 包装joinRoom函数以便设置pickupCode
const joinRoom = useCallback(async (code: string) => {
console.log('=== 加入房间 ===');
console.log('取件码:', code);
const trimmedCode = code.trim();
// 检查取件码格式
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
setPickupCode(code);
await originalJoinRoom(code);
}, [originalJoinRoom]);
// 防止重复调用 - 检查是否已经在连接或已连接
if (isConnecting || isConnected || isJoiningRoom) {
console.log('已在连接中或已连接,跳过重复的房间状态检查');
return;
}
setIsJoiningRoom(true);
try {
// 先检查房间状态
console.log('检查房间状态...');
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
}
const result = await response.json();
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
showToast(errorMessage, "error");
return;
}
// 检查发送方是否在线 (使用新的字段名)
if (!result.sender_online) {
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
return;
}
console.log('房间状态检查通过,开始连接...');
setPickupCode(trimmedCode);
connect(trimmedCode, 'receiver');
showToast(`正在连接到房间: ${trimmedCode}`, "success");
} catch (error) {
console.error('检查房间状态失败:', error);
let errorMessage = '检查房间状态失败';
if (error instanceof Error) {
if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查网络状况';
} else if (error.message.includes('timeout')) {
errorMessage = '请求超时,请重试';
} else if (error.message.includes('HTTP 404')) {
errorMessage = '房间不存在,请检查取件码';
} else if (error.message.includes('HTTP 500')) {
errorMessage = '服务器错误,请稍后重试';
} else {
errorMessage = error.message;
}
}
showToast(errorMessage, "error");
} finally {
setIsJoiningRoom(false); // 重置加入房间状态
}
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
const { updateMode } = useURLHandler({
featureType: 'webrtc',
onModeChange: setMode,
onAutoJoinRoom: joinRoom
});
// 从URL参数中获取初始模式仅在首次加载时处理
useEffect(() => {
// 使用 ref 确保只处理一次,避免严格模式的重复调用
if (urlProcessedRef.current) {
console.log('URL已处理过跳过重复处理');
return;
}
const urlMode = searchParams.get('mode') as 'send' | 'receive';
const type = searchParams.get('type');
const code = searchParams.get('code');
// 只在首次加载且URL中有webrtc类型时处理
if (!hasProcessedInitialUrl && type === 'webrtc' && urlMode && ['send', 'receive'].includes(urlMode)) {
console.log('=== 处理初始URL参数 ===');
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
// 立即标记为已处理,防止重复
urlProcessedRef.current = true;
setMode(urlMode);
setHasProcessedInitialUrl(true);
if (code && urlMode === 'receive') {
console.log('URL中有取件码自动加入房间');
// 防止重复调用 - 检查连接状态和加入房间状态
if (!isConnecting && !isConnected && !isJoiningRoom) {
// 直接调用异步函数,不依赖 joinRoom
const autoJoinRoom = async () => {
const trimmedCode = code.trim();
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
setIsJoiningRoom(true);
try {
console.log('检查房间状态...');
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
}
const result = await response.json();
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
showToast(errorMessage, "error");
return;
}
if (!result.sender_online) {
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
return;
}
console.log('房间状态检查通过,开始连接...');
setPickupCode(trimmedCode);
connect(trimmedCode, 'receiver');
showToast(`正在连接到房间: ${trimmedCode}`, "success");
} catch (error) {
console.error('检查房间状态失败:', error);
let errorMessage = '检查房间状态失败';
if (error instanceof Error) {
if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查网络状况';
} else if (error.message.includes('timeout')) {
errorMessage = '请求超时,请重试';
} else if (error.message.includes('HTTP 404')) {
errorMessage = '房间不存在,请检查取件码';
} else if (error.message.includes('HTTP 500')) {
errorMessage = '服务器错误,请稍后重试';
} else {
errorMessage = error.message;
}
}
showToast(errorMessage, "error");
} finally {
setIsJoiningRoom(false);
}
};
autoJoinRoom();
} else {
console.log('已在连接中或加入房间中,跳过重复处理');
}
}
}
}, [searchParams, hasProcessedInitialUrl, isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
// 更新URL参数
const updateMode = useCallback((newMode: 'send' | 'receive') => {
console.log('=== 手动切换模式 ===');
console.log('新模式:', newMode);
setMode(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'webrtc');
params.set('mode', newMode);
// 如果切换到发送模式移除code参数
if (newMode === 'send') {
params.delete('code');
}
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
useConnectionState({
isWebSocketConnected,
isConnected,
isConnecting,
error: error || '',
pickupCode,
fileListLength: fileList.length,
currentTransferFile,
setCurrentTransferFile,
updateFileListStatus: setFileList
});
// 生成文件ID
const generateFileId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
};
// 文件选择处理
const handleFileSelect = (files: File[]) => {
console.log('=== 文件选择 ===');
console.log('新文件:', files.map(f => f.name));
// 更新选中的文件
setSelectedFiles(prev => [...prev, ...files]);
// 创建对应的文件信息
const newFileInfos: FileInfo[] = files.map(file => ({
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready',
progress: 0
}));
setFileList(prev => {
const updatedList = [...prev, ...newFileInfos];
console.log('更新后的文件列表:', updatedList);
// 如果P2P连接已建立立即同步文件列表
if (isConnected && connection.isPeerConnected && pickupCode) {
console.log('立即同步文件列表到对端');
setTimeout(() => sendFileList(updatedList), 100);
}
return updatedList;
});
};
// 创建房间 (发送模式)
const generateCode = async () => {
if (selectedFiles.length === 0) {
@@ -309,21 +129,14 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('=== 创建房间 ===');
console.log('选中文件数:', selectedFiles.length);
// 创建后端房间
// 创建后端房间 - 简化版本,不发送无用的文件信息
const response = await fetch('/api/create-room', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'file',
files: selectedFiles.map(file => ({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
}))
}),
// 不再发送文件列表,因为后端不使用这些信息
body: JSON.stringify({}),
});
const data = await response.json();
@@ -370,23 +183,17 @@ export const WebRTCFileTransfer: React.FC = () => {
// 清空状态
setPickupCode('');
setFileList([]);
setDownloadedFiles(new Map());
resetFiles();
// 如果是接收模式更新URL移除code参数
if (mode === 'receive') {
const params = new URLSearchParams(searchParams.toString());
params.delete('code');
router.push(`?${params.toString()}`, { scroll: false });
}
// 如果是接收模式,需要手动更新URL
// URL处理逻辑已经移到 hook 中
};
// 处理文件列表更新
// 处理文件列表更新
useEffect(() => {
const cleanup = onFileListReceived((fileInfos: FileInfo[]) => {
console.log('=== 收到文件列表更新 ===');
console.log('文件列表:', fileInfos);
console.log('当前模式:', mode);
if (mode === 'receive') {
setFileList(fileInfos);
@@ -396,6 +203,99 @@ export const WebRTCFileTransfer: React.FC = () => {
return cleanup;
}, [onFileListReceived, mode]);
// 处理文件接收
useEffect(() => {
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
console.log('=== 接收到文件 ===');
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
// 更新下载的文件
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
// 更新文件状态
updateFileStatus(fileData.id, 'completed', 100);
});
return cleanup;
}, [onFileReceived, updateFileStatus]);
// 监听文件级别的进度更新
useEffect(() => {
const cleanup = onFileProgress((progressInfo) => {
// 检查连接状态,如果连接断开则忽略进度更新
if (!isConnected || error) {
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
return;
}
console.log('=== 文件进度更新 ===');
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
// 更新当前传输文件信息
setCurrentTransferFile({
fileId: progressInfo.fileId,
fileName: progressInfo.fileName,
progress: progressInfo.progress
});
// 更新文件进度
updateFileProgress(progressInfo.fileId, progressInfo.fileName, progressInfo.progress);
// 当传输完成时清理
if (progressInfo.progress >= 100 && mode === 'send') {
setCurrentTransferFile(null);
}
});
return cleanup;
}, [onFileProgress, mode, isConnected, error, updateFileProgress]);
// 处理文件请求(发送方监听)
useEffect(() => {
const cleanup = onFileRequested((fileId: string, fileName: string) => {
console.log('=== 收到文件请求 ===');
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
if (mode === 'send') {
// 检查连接状态
if (!isConnected || error) {
console.log('连接已断开,无法发送文件');
showToast('连接已断开,无法发送文件', "error");
return;
}
// 在发送方的selectedFiles中查找对应文件
const file = selectedFiles.find(f => f.name === fileName);
if (!file) {
console.error('找不到匹配的文件:', fileName);
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading
updateFileStatus(fileId, 'downloading', 0);
// 发送文件
try {
sendFile(file, fileId);
} catch (sendError) {
console.error('发送文件失败:', sendError);
showToast(`发送文件失败: ${fileName}`, "error");
// 重置文件状态
updateFileStatus(fileId, 'ready', 0);
}
} else {
console.warn('接收模式下收到文件请求,忽略');
}
});
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error, showToast, updateFileStatus]);
// 处理连接错误
const [lastError, setLastError] = useState<string>('');
useEffect(() => {
@@ -462,52 +362,6 @@ export const WebRTCFileTransfer: React.FC = () => {
return cleanup;
}, [onFileReceived]);
// 监听文件级别的进度更新
useEffect(() => {
const cleanup = onFileProgress((progressInfo) => {
// 检查连接状态,如果连接断开则忽略进度更新
if (!isConnected || error) {
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
return;
}
console.log('=== 文件进度更新 ===');
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
// 更新当前传输文件信息
setCurrentTransferFile({
fileId: progressInfo.fileId,
fileName: progressInfo.fileName,
progress: progressInfo.progress
});
// 更新文件列表中对应文件的进度
setFileList(prev => prev.map(item => {
if (item.id === progressInfo.fileId || item.name === progressInfo.fileName) {
const newProgress = progressInfo.progress;
const newStatus = newProgress >= 100 ? 'completed' as const : 'downloading' as const;
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${newProgress}`);
return { ...item, progress: newProgress, status: newStatus };
}
return item;
}));
// 当传输完成时显示提示
if (progressInfo.progress >= 100 && mode === 'send') {
// 移除不必要的Toast - 传输完成状态在UI中已经显示
setCurrentTransferFile(null);
}
});
return cleanup;
}, [onFileProgress, mode, isConnected, error]);
// 实时更新传输进度(旧逻辑 - 删除)
// useEffect(() => {
// ...已删除的旧代码...
// }, [...]);
// 处理文件请求(发送方监听)
useEffect(() => {
const cleanup = onFileRequested((fileId: string, fileName: string) => {
@@ -567,29 +421,46 @@ export const WebRTCFileTransfer: React.FC = () => {
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error]);
// 监听WebSocket连接状态变化
// 监听连接状态变化和清理传输状态
useEffect(() => {
console.log('=== WebSocket状态变化 ===');
console.log('=== 连接状态变化 ===');
console.log('WebSocket连接状态:', isWebSocketConnected);
console.log('WebRTC连接状态:', isConnected);
console.log('连接中状态:', isConnecting);
// 只有在之前已经建立过连接,现在断开的情况下才显示断开提示
// 避免在初始连接时误报断开
if (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) {
// 增加额外检查:只有在之前曾经连接成功过的情况下才显示断开提示
// 通过检查是否有文件列表来判断是否曾经连接过
if (fileList.length > 0 || currentTransferFile) {
showToast('与服务器的连接已断开,请重新连接', "error");
// 当连接断开或有错误时,清理所有传输状态
const shouldCleanup = (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) ||
((!isConnected && !isConnecting) || error);
if (shouldCleanup) {
const hasCurrentTransfer = !!currentTransferFile;
const hasFileList = fileList.length > 0;
// 只有在之前有连接活动时才显示断开提示和清理状态
if (hasFileList || hasCurrentTransfer) {
if (!isWebSocketConnected && pickupCode) {
showToast('与服务器的连接已断开,请重新连接', "error");
}
// 清理传输状态
console.log('WebSocket断开清理传输状态');
setCurrentTransferFile(null);
setFileList(prev => prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
));
console.log('连接断开,清理传输状态');
if (currentTransferFile) {
setCurrentTransferFile(null);
}
// 重置所有正在下载的文件状态
setFileList(prev => {
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
if (hasDownloadingFiles) {
console.log('重置正在传输的文件状态');
return prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
);
}
return prev;
});
}
}
@@ -598,34 +469,9 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('WebSocket已连接正在建立P2P连接...');
}
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, showToast, fileList.length, currentTransferFile]);
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileList.length]);
// 监听连接状态变化,清理传输状态
useEffect(() => {
// 当连接断开或有错误时,清理所有传输状态
if ((!isConnected && !isConnecting) || error) {
if (currentTransferFile) {
console.log('连接断开,清理当前传输文件状态:', currentTransferFile.fileName);
setCurrentTransferFile(null);
}
// 重置所有正在下载的文件状态
setFileList(prev => {
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
if (hasDownloadingFiles) {
console.log('重置正在传输的文件状态');
return prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
);
}
return prev;
});
}
}, [isConnected, isConnecting, error, currentTransferFile]);
// 监听连接状态变化并提供用户反馈
// 监听连接状态变化并提供日志
useEffect(() => {
console.log('=== WebRTC连接状态变化 ===');
console.log('连接状态:', {
@@ -637,65 +483,66 @@ export const WebRTCFileTransfer: React.FC = () => {
selectedFilesCount: selectedFiles.length,
fileListCount: fileList.length
});
// 连接成功时的提示
if (isConnected && !isConnecting) {
if (mode === 'send') {
// 移除不必要的Toast - 连接状态在UI中已经显示
} else {
// 移除不必要的Toast - 连接状态在UI中已经显示
}
}
// 连接中的状态
if (isConnecting && pickupCode) {
console.log('正在建立WebRTC连接...');
}
// 只有在P2P连接建立且没有错误时才发送文件列表
if (isConnected && connection.isPeerConnected && !error && pickupCode && mode === 'send' && selectedFiles.length > 0) {
// 确保有文件列表
if (fileList.length === 0) {
console.log('创建文件列表并发送...');
const newFileInfos: FileInfo[] = selectedFiles.map(file => ({
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready',
progress: 0
}));
setFileList(newFileInfos);
// 延迟发送,确保数据通道已准备好
setTimeout(() => {
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
sendFileList(newFileInfos);
}
}, 500);
} else if (fileList.length > 0) {
console.log('发送现有文件列表...');
// 延迟发送,确保数据通道已准备好
setTimeout(() => {
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
sendFileList(fileList);
}
}, 500);
}
}
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, error]);
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, fileList.length]);
// 监听P2P连接建立,自动发送文件列表
// 监听P2P连接建立时的状态变化
useEffect(() => {
if (connection.isPeerConnected && mode === 'send' && fileList.length > 0) {
console.log('P2P连接已建立发送文件列表...');
// 稍微延迟一下,确保数据通道完全准备好
setTimeout(() => {
if (connection.isPeerConnected && connection.getChannelState() === 'open') {
sendFileList(fileList);
}
}, 200);
console.log('P2P连接已建立数据通道首次打开,初始化文件列表');
// 数据通道第一次打开时进行初始化
syncFileListToReceiver(fileList, '数据通道初始化');
}
}, [connection.isPeerConnected, mode, fileList.length, sendFileList]);
}, [connection.isPeerConnected, mode, syncFileListToReceiver]);
// 监听fileList大小变化并同步
useEffect(() => {
if (connection.isPeerConnected && mode === 'send' && pickupCode) {
console.log('fileList大小变化同步到接收方:', fileList.length);
syncFileListToReceiver(fileList, 'fileList大小变化');
}
}, [fileList.length, connection.isPeerConnected, mode, pickupCode, syncFileListToReceiver]);
// 监听selectedFiles变化同步更新fileList并发送给接收方
useEffect(() => {
// 只有在发送模式下且已有房间时才处理文件列表同步
if (mode !== 'send' || !pickupCode) return;
console.log('=== selectedFiles变化同步文件列表 ===', {
selectedFilesCount: selectedFiles.length,
fileListCount: fileList.length,
selectedFileNames: selectedFiles.map(f => f.name)
});
// 根据selectedFiles创建新的文件信息列表
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
// 尝试找到现有的文件信息,保持已有的状态
const existingFileInfo = fileList.find(info => info.name === file.name && info.size === file.size);
return existingFileInfo || {
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready' as const,
progress: 0
};
});
// 检查文件列表是否真正发生变化
const fileListChanged =
newFileInfos.length !== fileList.length ||
newFileInfos.some(newFile =>
!fileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
);
if (fileListChanged) {
console.log('文件列表发生变化,更新:', {
before: fileList.map(f => f.name),
after: newFileInfos.map(f => f.name)
});
setFileList(newFileInfos);
}
}, [selectedFiles, mode, pickupCode]);
// 请求下载文件(接收方调用)
const requestFile = (fileId: string) => {
@@ -779,17 +626,6 @@ export const WebRTCFileTransfer: React.FC = () => {
fileInputRef.current?.click();
};
// 清空文件
const clearFiles = () => {
console.log('=== 清空文件 ===');
setSelectedFiles([]);
setFileList([]);
// 只有在P2P连接建立且数据通道准备好时才发送清空消息
if (isConnected && connection.isPeerConnected && connection.getChannelState() === 'open' && pickupCode) {
sendFileList([]);
}
};
// 下载文件到本地
const downloadFile = (fileId: string) => {
const file = downloadedFiles.get(fileId);
@@ -879,6 +715,7 @@ export const WebRTCFileTransfer: React.FC = () => {
downloadedFiles={downloadedFiles}
error={error}
onReset={resetConnection}
pickupCode={pickupCode}
/>
</div>
)}

View File

@@ -1,68 +1,34 @@
"use client";
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Send, Download, X } from 'lucide-react';
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 { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
import { Button } from '@/components/ui/button';
import { MessageSquare, Send, Download, X } from 'lucide-react';
export const WebRTCTextImageTransfer: React.FC = () => {
const searchParams = useSearchParams();
const router = useRouter();
// 状态管理
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 从URL参数中获取初始模式
useEffect(() => {
const urlMode = searchParams.get('mode') as 'send' | 'receive';
const type = searchParams.get('type');
const code = searchParams.get('code');
if (!hasProcessedInitialUrl && type === 'message' && urlMode && ['send', 'receive'].includes(urlMode)) {
console.log('=== 处理初始URL参数 ===');
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
setMode(urlMode);
setHasProcessedInitialUrl(true);
}
}, [searchParams, hasProcessedInitialUrl]);
// 更新URL参数
const updateMode = useCallback((newMode: 'send' | 'receive') => {
console.log('=== 切换模式 ===', newMode);
setMode(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'message');
params.set('mode', newMode);
if (newMode === 'send') {
params.delete('code');
}
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
// 使用统一的URL处理器
const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({
featureType: 'message',
onModeChange: setMode
});
// 重新开始函数
const handleRestart = useCallback(() => {
setPreviewImage(null);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'message');
params.set('mode', mode);
params.delete('code');
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, mode, router]);
clearURLParams();
}, [clearURLParams]);
const code = searchParams.get('code') || '';
const code = getCurrentRoomCode();
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { AlertTriangle, Download, X, Chrome, Monitor } from 'lucide-react';
import { WebRTCSupport, getBrowserInfo, getRecommendedBrowsers } from '@/lib/webrtc-support';
interface Props {
isOpen: boolean;
onClose: () => void;
webrtcSupport: WebRTCSupport;
}
/**
* WebRTC 不支持提示模态框
*/
export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props) {
const browserInfo = getBrowserInfo();
const recommendedBrowsers = getRecommendedBrowsers();
if (!isOpen) return null;
const handleBrowserDownload = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
<h2 className="text-xl font-semibold text-gray-900">
WebRTC
</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* 内容 */}
<div className="p-6 space-y-6">
{/* 当前浏览器信息 */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="font-medium text-red-800 mb-2"></h3>
<div className="space-y-2 text-sm text-red-700">
<div>
<strong>:</strong> {browserInfo.name} {browserInfo.version}
</div>
<div>
<strong>WebRTC :</strong>
<span className="ml-1 px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
</span>
</div>
</div>
</div>
{/* 缺失的功能 */}
<div className="space-y-3">
<h3 className="font-medium text-gray-900"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{webrtcSupport.missing.map((feature, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-gray-600">
<div className="w-2 h-2 bg-red-400 rounded-full"></div>
{feature}
</div>
))}
</div>
</div>
{/* 功能说明 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-medium text-blue-800 mb-2"> WebRTC</h3>
<div className="space-y-2 text-sm text-blue-700">
<div className="flex items-start gap-2">
<Monitor className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<strong>:</strong>
</div>
</div>
<div className="flex items-start gap-2">
<Download className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<strong>:</strong>
</div>
</div>
<div className="flex items-start gap-2">
<Chrome className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<strong>:</strong>
</div>
</div>
</div>
</div>
{/* 浏览器推荐 */}
<div className="space-y-3">
<h3 className="font-medium text-gray-900">使</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendedBrowsers.map((browser, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors cursor-pointer"
onClick={() => handleBrowserDownload(browser.downloadUrl)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-gray-900">{browser.name}</h4>
<p className="text-sm text-gray-600"> {browser.minVersion}</p>
</div>
<Download className="h-5 w-5 text-blue-500" />
</div>
</div>
))}
</div>
</div>
{/* 浏览器特定建议 */}
{browserInfo.recommendations && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="font-medium text-yellow-800 mb-2"></h3>
<ul className="space-y-1 text-sm text-yellow-700">
{browserInfo.recommendations.map((recommendation, index) => (
<li key={index} className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-yellow-400 rounded-full mt-2 flex-shrink-0"></div>
{recommendation}
</li>
))}
</ul>
</div>
)}
{/* 技术详情(可折叠) */}
<details className="border border-gray-200 rounded-lg">
<summary className="p-3 cursor-pointer font-medium text-gray-900 hover:bg-gray-50">
</summary>
<div className="p-3 border-t border-gray-200 space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<strong>RTCPeerConnection:</strong>
<span className={`ml-2 px-2 py-1 rounded text-xs ${
webrtcSupport.details.rtcPeerConnection
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{webrtcSupport.details.rtcPeerConnection ? '支持' : '不支持'}
</span>
</div>
<div>
<strong>DataChannel:</strong>
<span className={`ml-2 px-2 py-1 rounded text-xs ${
webrtcSupport.details.dataChannel
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{webrtcSupport.details.dataChannel ? '支持' : '不支持'}
</span>
</div>
</div>
</div>
</details>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 p-6 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
</button>
<button
onClick={() => handleBrowserDownload('https://www.google.com/chrome/')}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Chrome
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,79 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,106 @@
"use client";
import React from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Wifi, WifiOff } from 'lucide-react';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'warning' | 'danger' | 'info';
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = '确认',
cancelText = '取消',
type = 'warning'
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
const handleCancel = () => {
onClose();
};
const getIcon = () => {
switch (type) {
case 'danger':
return <WifiOff className="w-6 h-6 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-6 h-6 text-yellow-500" />;
case 'info':
return <Wifi className="w-6 h-6 text-blue-500" />;
default:
return <AlertTriangle className="w-6 h-6 text-yellow-500" />;
}
};
const getButtonStyles = () => {
switch (type) {
case 'danger':
return 'bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700';
case 'warning':
return 'bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600';
case 'info':
return 'bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600';
default:
return 'bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600';
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 max-w-md w-full mx-4 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center space-x-4 p-6 pb-4">
<div className="flex-shrink-0">
{getIcon()}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800">
{title}
</h3>
</div>
</div>
{/* Content */}
<div className="px-6 pb-6">
<p className="text-slate-600 leading-relaxed">
{message}
</p>
</div>
{/* Actions */}
<div className="flex items-center justify-end space-x-3 px-6 pb-6">
<Button
variant="outline"
onClick={handleCancel}
className="px-6 py-2 border-slate-200 text-slate-600 hover:text-slate-800 hover:border-slate-300 rounded-lg"
>
{cancelText}
</Button>
<Button
onClick={handleConfirm}
className={`px-6 py-2 text-white font-medium rounded-lg shadow-lg transition-all duration-200 hover:shadow-xl ${getButtonStyles()}`}
>
{confirmText}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,122 +0,0 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -1,24 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Monitor, Square } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
import DesktopViewer from '@/components/DesktopViewer';
import { ConnectionStatus } from '@/components/ConnectionStatus';
@@ -18,7 +18,7 @@ interface WebRTCDesktopReceiverProps {
export default function WebRTCDesktopReceiver({ className, initialCode, onConnectionChange }: WebRTCDesktopReceiverProps) {
const [inputCode, setInputCode] = useState(initialCode || '');
const [isLoading, setIsLoading] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [isJoiningRoom, setIsJoiningRoom] = useState(false); // 添加加入房间状态
const hasTriedAutoJoin = React.useRef(false); // 添加 ref 来跟踪是否已尝试自动加入
const { showToast } = useToast();
@@ -34,27 +34,82 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
// 加入观看
const handleJoinViewing = useCallback(async () => {
if (!inputCode.trim()) {
showToast('请输入房间代码', 'error');
const trimmedCode = inputCode.trim();
// 检查房间代码格式
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位房间代码', "error");
return;
}
// 防止重复调用 - 检查是否已经在连接或已连接
if (desktopShare.isConnecting || desktopShare.isViewing || isJoiningRoom) {
console.log('已在连接中或已连接,跳过重复的房间状态检查');
return;
}
setIsJoiningRoom(true);
try {
setIsLoading(true);
console.log('[DesktopShareReceiver] 用户加入观看房间:', inputCode);
console.log('[DesktopShareReceiver] 开始验证房间状态...');
await desktopShare.joinSharing(inputCode.trim().toUpperCase());
// 先检查房间状态
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
}
const result = await response.json();
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查房间代码是否正确';
}
showToast(errorMessage, "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
return;
}
console.log('[DesktopShareReceiver] 房间状态检查通过,开始连接...');
setIsLoading(true);
await desktopShare.joinSharing(trimmedCode.toUpperCase());
console.log('[DesktopShareReceiver] 加入观看成功');
showToast('已加入桌面共享', 'success');
} catch (error) {
console.error('[DesktopShareReceiver] 加入观看失败:', error);
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
let errorMessage = '加入观看失败';
if (error instanceof Error) {
if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查网络状况';
} else if (error.message.includes('timeout')) {
errorMessage = '请求超时,请重试';
} else if (error.message.includes('HTTP 404')) {
errorMessage = '房间不存在,请检查房间代码';
} else if (error.message.includes('HTTP 500')) {
errorMessage = '服务器错误,请稍后重试';
} else {
errorMessage = error.message;
}
}
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
setIsJoiningRoom(false); // 重置加入房间状态
}
}, [desktopShare, inputCode, showToast]);
}, [desktopShare, inputCode, isJoiningRoom, showToast]);
// 停止观看
const handleStopViewing = useCallback(async () => {
@@ -77,38 +132,94 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
initialCode,
isViewing: desktopShare.isViewing,
isConnecting: desktopShare.isConnecting,
isJoiningRoom,
hasTriedAutoJoin: hasTriedAutoJoin.current
});
const autoJoin = async () => {
if (initialCode && !desktopShare.isViewing && !desktopShare.isConnecting && !hasTriedAutoJoin.current) {
if (initialCode && !desktopShare.isViewing && !desktopShare.isConnecting && !isJoiningRoom && !hasTriedAutoJoin.current) {
hasTriedAutoJoin.current = true;
console.log('[WebRTCDesktopReceiver] 检测到初始代码,自动加入观看:', initialCode);
const trimmedCode = initialCode.trim();
// 检查房间代码格式
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('房间代码格式不正确', "error");
return;
}
setIsJoiningRoom(true);
console.log('[WebRTCDesktopReceiver] 检测到初始代码,开始验证并自动加入:', trimmedCode);
try {
// 先检查房间状态
console.log('[WebRTCDesktopReceiver] 验证房间状态...');
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
}
const result = await response.json();
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查房间代码是否正确';
}
showToast(errorMessage, "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
return;
}
console.log('[WebRTCDesktopReceiver] 房间验证通过,开始自动连接...');
setIsLoading(true);
await desktopShare.joinSharing(initialCode.trim().toUpperCase());
await desktopShare.joinSharing(trimmedCode.toUpperCase());
console.log('[WebRTCDesktopReceiver] 自动加入观看成功');
showToast('已加入桌面共享', 'success');
} catch (error) {
console.error('[WebRTCDesktopReceiver] 自动加入观看失败:', error);
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
let errorMessage = '自动加入观看失败';
if (error instanceof Error) {
if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查网络状况';
} else if (error.message.includes('timeout')) {
errorMessage = '请求超时,请重试';
} else if (error.message.includes('HTTP 404')) {
errorMessage = '房间不存在,请检查房间代码';
} else if (error.message.includes('HTTP 500')) {
errorMessage = '服务器错误,请稍后重试';
} else {
errorMessage = error.message;
}
}
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
setIsJoiningRoom(false);
}
} else {
console.log('[WebRTCDesktopReceiver] 不满足自动加入条件:', {
hasInitialCode: !!initialCode,
notViewing: !desktopShare.isViewing,
notConnecting: !desktopShare.isConnecting,
notJoiningRoom: !isJoiningRoom,
notTriedBefore: !hasTriedAutoJoin.current
});
}
};
autoJoin();
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting]); // 移除了 desktopShare.joinSharing 和 showToast
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting, isJoiningRoom]); // 添加isJoiningRoom依赖
return (
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
@@ -138,11 +249,11 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
<div className="relative">
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
onChange={(e) => 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-purple-500 focus:ring-purple-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isLoading}
disabled={isLoading || isJoiningRoom}
/>
</div>
<p className="text-center text-xs sm:text-sm text-slate-500">
@@ -153,10 +264,15 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
<div className="flex justify-center">
<Button
type="submit"
disabled={inputCode.length !== 6 || isLoading}
disabled={inputCode.length !== 6 || isLoading || isJoiningRoom}
className="w-full h-10 sm:h-12 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-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"
>
{isLoading ? (
{isJoiningRoom ? (
<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>
) : isLoading ? (
<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>

View File

@@ -2,9 +2,9 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Share, Monitor, Copy, Play, Square, Repeat, Users, Wifi, WifiOff } from 'lucide-react';
import { Share, Monitor, Play, Square, Repeat } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';

View File

@@ -42,6 +42,7 @@ interface WebRTCFileReceiveProps {
downloadedFiles?: Map<string, File>;
error?: string | null;
onReset?: () => void;
pickupCode?: string;
}
export function WebRTCFileReceive({
@@ -53,12 +54,16 @@ export function WebRTCFileReceive({
isWebSocketConnected = false,
downloadedFiles,
error = null,
onReset
onReset,
pickupCode: propPickupCode
}: WebRTCFileReceiveProps) {
const [pickupCode, setPickupCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const { showToast } = useToast();
// 使用传入的取件码或本地状态的取件码
const displayPickupCode = propPickupCode || pickupCode;
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
@@ -110,7 +115,7 @@ export function WebRTCFileReceive({
}, [pickupCode, onJoinRoom]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
const value = e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, '');
if (value.length <= 6) {
setPickupCode(value);
}
@@ -141,13 +146,13 @@ export function WebRTCFileReceive({
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {pickupCode}</p>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
currentRoom={displayPickupCode ? { code: displayPickupCode, role: 'receiver' } : null}
/>
<Button
@@ -202,14 +207,14 @@ export function WebRTCFileReceive({
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {pickupCode}</p>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={{ code: pickupCode, role: 'receiver' }}
currentRoom={{ code: displayPickupCode, role: 'receiver' }}
/>
</div>

View File

@@ -1,9 +1,9 @@
"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 { useSharedWebRTCManager } from '@/hooks/connection';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
@@ -209,7 +209,7 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
<div className="relative">
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
onChange={(e) => 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 pb-2 sm:pb-4"
maxLength={6}
@@ -297,12 +297,14 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
)}
</div>
<div className="min-h-[200px] bg-slate-50/50 rounded-xl p-4 border border-slate-100">
<div className="min-h-[200px] bg-slate-50/50 rounded-xl p-4 border border-slate-100 overflow-hidden">
{receivedText ? (
<div className="space-y-2">
<pre className="whitespace-pre-wrap text-slate-700 text-sm leading-relaxed font-sans">
{receivedText}
</pre>
<div className="space-y-2 h-full">
<div className="overflow-auto max-h-[180px]">
<pre className="whitespace-pre-wrap break-words text-slate-700 text-sm leading-relaxed font-sans">
{receivedText}
</pre>
</div>
{isTyping && (
<div className="flex items-center space-x-2 text-slate-500 text-sm">
<div className="flex space-x-1">
@@ -364,4 +366,4 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
)}
</div>
);
};
};

View File

@@ -1,9 +1,9 @@
"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 { useSharedWebRTCManager } from '@/hooks/connection';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
@@ -115,21 +115,14 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
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
}
}),
// 不再发送文本内容,因为后端不使用这些信息
body: JSON.stringify({}),
});
const data = await response.json();

View File

@@ -0,0 +1,6 @@
// 连接相关的hooks
export { useConnectionState } from './useConnectionState';
export { useRoomConnection } from './useRoomConnection';
export { useSharedWebRTCManager } from './useSharedWebRTCManager';
export { useWebRTCManager } from './useWebRTCManager';
export { useWebRTCSupport } from './useWebRTCSupport';

View File

@@ -0,0 +1,137 @@
import { useState, useEffect, useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
interface UseConnectionStateProps {
isWebSocketConnected: boolean;
isConnected: boolean;
isConnecting: boolean;
error: string;
pickupCode: string;
fileListLength: number;
currentTransferFile: any;
setCurrentTransferFile: (file: any) => void;
updateFileListStatus: (callback: (prev: any[]) => any[]) => void;
}
export const useConnectionState = ({
isWebSocketConnected,
isConnected,
isConnecting,
error,
pickupCode,
fileListLength,
currentTransferFile,
setCurrentTransferFile,
updateFileListStatus
}: UseConnectionStateProps) => {
const { showToast } = useToast();
const [lastError, setLastError] = useState<string>('');
// 处理连接错误
useEffect(() => {
if (error && error !== lastError) {
console.log('=== 连接错误处理 ===');
console.log('错误信息:', error);
// 根据错误类型显示不同的提示
let errorMessage = error;
if (error.includes('WebSocket')) {
errorMessage = '服务器连接失败,请检查网络连接或稍后重试';
} else if (error.includes('数据通道')) {
errorMessage = '数据通道连接失败,请重新尝试连接';
} else if (error.includes('连接超时')) {
errorMessage = '连接超时,请检查网络状况或重新尝试';
} else if (error.includes('连接失败')) {
errorMessage = 'WebRTC连接失败可能是网络环境限制请尝试刷新页面';
} else if (error.includes('信令错误')) {
errorMessage = '信令服务器错误,请稍后重试';
} else if (error.includes('创建连接失败')) {
errorMessage = '无法建立P2P连接请检查网络设置';
}
// 显示错误提示
showToast(errorMessage, "error");
setLastError(error);
// 如果是严重连接错误,清理传输状态
if (error.includes('连接失败') || error.includes('数据通道连接失败') || error.includes('WebSocket')) {
console.log('严重连接错误,清理传输状态');
setCurrentTransferFile(null);
// 重置所有正在传输的文件状态
updateFileListStatus((prev: any[]) => prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
));
}
}
}, [error, lastError, showToast, setCurrentTransferFile, updateFileListStatus]);
// 监听连接状态变化和清理传输状态
useEffect(() => {
console.log('=== 连接状态变化 ===');
console.log('WebSocket连接状态:', isWebSocketConnected);
console.log('WebRTC连接状态:', isConnected);
console.log('连接中状态:', isConnecting);
// 当连接断开或有错误时,清理所有传输状态
const shouldCleanup = (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) ||
((!isConnected && !isConnecting) || error);
if (shouldCleanup) {
const hasCurrentTransfer = !!currentTransferFile;
const hasFileList = fileListLength > 0;
// 只有在之前有连接活动时才显示断开提示和清理状态
if (hasFileList || hasCurrentTransfer) {
if (!isWebSocketConnected && pickupCode) {
showToast('与服务器的连接已断开,请重新连接', "error");
}
console.log('连接断开,清理传输状态');
if (currentTransferFile) {
setCurrentTransferFile(null);
}
// 重置所有正在下载的文件状态
updateFileListStatus((prev: any[]) => {
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
if (hasDownloadingFiles) {
console.log('重置正在传输的文件状态');
return prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
);
}
return prev;
});
}
}
// WebSocket连接成功时的提示
if (isWebSocketConnected && isConnecting && !isConnected) {
console.log('WebSocket已连接正在建立P2P连接...');
}
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileListLength, setCurrentTransferFile, updateFileListStatus]);
// 监听连接状态变化并提供日志
useEffect(() => {
console.log('=== WebRTC连接状态变化 ===');
console.log('连接状态:', {
isConnected,
isConnecting,
isWebSocketConnected,
pickupCode,
fileListLength
});
}, [isConnected, isConnecting, isWebSocketConnected, pickupCode, fileListLength]);
return {
lastError
};
};

View File

@@ -0,0 +1,103 @@
import { useState, useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
interface UseRoomConnectionProps {
connect: (code: string, role: 'sender' | 'receiver') => void;
isConnecting: boolean;
isConnected: boolean;
}
export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoomConnectionProps) => {
const { showToast } = useToast();
const [isJoiningRoom, setIsJoiningRoom] = useState(false);
const validateRoomCode = (code: string): string | null => {
const trimmedCode = code.trim();
if (!trimmedCode || trimmedCode.length !== 6) {
return '请输入正确的6位取件码';
}
return null;
};
const checkRoomStatus = async (code: string) => {
const response = await fetch(`/api/room-info?code=${code}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
}
const result = await response.json();
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
throw new Error(errorMessage);
}
if (!result.sender_online) {
throw new Error('发送方不在线,请确认取件码是否正确或联系发送方');
}
return result;
};
const handleNetworkError = (error: Error): string => {
if (error.message.includes('network') || error.message.includes('fetch')) {
return '网络连接失败,请检查网络状况';
} else if (error.message.includes('timeout')) {
return '请求超时,请重试';
} else if (error.message.includes('HTTP 404')) {
return '房间不存在,请检查取件码';
} else if (error.message.includes('HTTP 500')) {
return '服务器错误,请稍后重试';
} else {
return error.message;
}
};
// 加入房间 (接收模式)
const joinRoom = useCallback(async (code: string) => {
console.log('=== 加入房间 ===');
console.log('取件码:', code);
// 验证输入
const validationError = validateRoomCode(code);
if (validationError) {
showToast(validationError, "error");
return;
}
// 防止重复调用
if (isConnecting || isConnected || isJoiningRoom) {
console.log('已在连接中或已连接,跳过重复的房间状态检查');
return;
}
setIsJoiningRoom(true);
try {
console.log('检查房间状态...');
await checkRoomStatus(code.trim());
console.log('房间状态检查通过,开始连接...');
connect(code.trim(), 'receiver');
showToast(`正在连接到房间: ${code.trim()}`, "success");
} catch (error) {
console.error('检查房间状态失败:', error);
const errorMessage = error instanceof Error ? handleNetworkError(error) : '检查房间状态失败';
showToast(errorMessage, "error");
} finally {
setIsJoiningRoom(false);
}
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]);
return {
joinRoom,
isJoiningRoom
};
};

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from 'react';
import { getWsUrl } from '@/lib/config';
import { useWebRTCStore } from './webRTCStore';
import { useWebRTCStore } from '../ui/webRTCStore';
// 基础连接状态
interface WebRTCState {
@@ -9,6 +9,7 @@ interface WebRTCState {
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
canRetry: boolean; // 新增:是否可以重试
}
// 消息类型
@@ -30,10 +31,12 @@ export interface WebRTCConnection {
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
canRetry: boolean; // 新增:是否可以重试
// 操作方法
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
disconnect: () => void;
retry: () => Promise<void>; // 新增:重试连接方法
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
sendData: (data: ArrayBuffer) => boolean;
@@ -71,6 +74,9 @@ export function useSharedWebRTCManager(): WebRTCConnection {
// 当前连接的房间信息
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
// 用于跟踪是否是用户主动断开连接
const isUserDisconnecting = useRef<boolean>(false);
// 多通道消息处理器
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
@@ -112,6 +118,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
}
currentRoom.current = null;
isUserDisconnecting.current = false; // 重置主动断开标志
}, []);
// 创建 Offer
@@ -153,7 +160,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 创建 offer 失败:', error);
updateState({ error: '创建连接失败', isConnecting: false });
updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
}
}, [updateState]);
@@ -209,6 +216,9 @@ export function useSharedWebRTCManager(): WebRTCConnection {
currentRoom.current = { code: roomCode, role };
webrtcStore.setCurrentRoom({ code: roomCode, role });
updateState({ isConnecting: true, error: null });
// 重置主动断开标志
isUserDisconnecting.current = false;
// 注意不在这里设置超时因为WebSocket连接很快
// WebRTC连接的建立是在后续添加轨道时进行的
@@ -326,7 +336,27 @@ export function useSharedWebRTCManager(): WebRTCConnection {
case 'error':
console.error('[SharedWebRTC] ❌ 信令服务器错误:', message.error);
updateState({ error: message.error, isConnecting: false });
updateState({ error: message.error, isConnecting: false, canRetry: true });
break;
case 'disconnection':
console.log('[SharedWebRTC] 🔌 对方主动断开连接');
// 对方断开连接的处理
updateState({
isPeerConnected: false,
isConnected: false, // 添加这个状态
error: '对方已离开房间',
canRetry: true
});
// 清理P2P连接但保持WebSocket连接允许重新连接
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
}
break;
default:
@@ -334,20 +364,29 @@ export function useSharedWebRTCManager(): WebRTCConnection {
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 处理信令消息失败:', error);
updateState({ error: '信令处理失败: ' + error, isConnecting: false });
updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
}
};
ws.onerror = (error) => {
console.error('[SharedWebRTC] ❌ WebSocket 错误:', error);
updateState({ error: 'WebSocket连接失败请检查服务器是否运行在8080端口', isConnecting: false });
updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
};
ws.onclose = (event) => {
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
updateState({ isWebSocketConnected: false });
// 检查是否是用户主动断开
if (isUserDisconnecting.current) {
console.log('[SharedWebRTC] ✅ 用户主动断开,正常关闭');
// 用户主动断开时不显示错误消息
return;
}
// 只有在非正常关闭且不是用户主动断开时才显示错误
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '未知原因'}`, isConnecting: false });
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
}
};
@@ -376,7 +415,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
break;
case 'failed':
console.error('[SharedWebRTC] ❌ ICE连接失败');
updateState({ error: 'ICE连接失败可能是网络防火墙阻止了连接', isConnecting: false });
updateState({ error: 'ICE连接失败可能是网络防火墙阻止了连接', isConnecting: false, canRetry: true });
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 ICE连接断开');
@@ -396,14 +435,14 @@ export function useSharedWebRTCManager(): WebRTCConnection {
break;
case 'connected':
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立可以进行媒体传输');
updateState({ isPeerConnected: true, error: null });
updateState({ isPeerConnected: true, error: null, canRetry: false });
break;
case 'failed':
// 只有在数据通道也未打开的情况下才认为连接真正失败
const currentDc = dcRef.current;
if (!currentDc || currentDc.readyState !== 'open') {
console.error('[SharedWebRTC] ❌ WebRTC连接失败数据通道未建立');
updateState({ error: 'WebRTC连接失败请检查网络设置或重试', isPeerConnected: false });
updateState({ error: 'WebRTC连接失败请检查网络设置或重试', isPeerConnected: false, canRetry: true });
} else {
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed但数据通道正常忽略此状态');
}
@@ -429,14 +468,59 @@ export function useSharedWebRTCManager(): WebRTCConnection {
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false });
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
// 获取更详细的错误信息
let errorMessage = '数据通道连接失败';
let shouldRetry = false;
// 根据数据通道状态提供更具体的错误信息
switch (dataChannel.readyState) {
case 'connecting':
errorMessage = '数据通道正在连接中,请稍候...';
shouldRetry = true;
break;
case 'closing':
errorMessage = '数据通道正在关闭,连接即将断开';
break;
case 'closed':
errorMessage = '数据通道已关闭P2P连接失败';
shouldRetry = true;
break;
default:
// 检查PeerConnection状态
const pc = pcRef.current;
if (pc) {
switch (pc.connectionState) {
case 'failed':
errorMessage = 'P2P连接失败可能是网络防火墙阻止了连接请尝试切换网络或使用VPN';
shouldRetry = true;
break;
case 'disconnected':
errorMessage = 'P2P连接已断开网络可能不稳定';
shouldRetry = true;
break;
default:
errorMessage = '数据通道连接失败,可能是网络环境受限';
shouldRetry = true;
}
}
}
console.error(`[SharedWebRTC] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
canRetry: shouldRetry // 设置是否可以重试
});
};
} else {
pc.ondatachannel = (event) => {
@@ -445,14 +529,59 @@ export function useSharedWebRTCManager(): WebRTCConnection {
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false });
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
console.error('[SharedWebRTC] 数据通道错误 (接收方):', error);
// 获取更详细的错误信息
let errorMessage = '数据通道连接失败';
let shouldRetry = false;
// 根据数据通道状态提供更具体的错误信息
switch (dataChannel.readyState) {
case 'connecting':
errorMessage = '数据通道正在连接中,请稍候...';
shouldRetry = true;
break;
case 'closing':
errorMessage = '数据通道正在关闭,连接即将断开';
break;
case 'closed':
errorMessage = '数据通道已关闭P2P连接失败';
shouldRetry = true;
break;
default:
// 检查PeerConnection状态
const pc = pcRef.current;
if (pc) {
switch (pc.connectionState) {
case 'failed':
errorMessage = 'P2P连接失败可能是网络防火墙阻止了连接请尝试切换网络或使用VPN';
shouldRetry = true;
break;
case 'disconnected':
errorMessage = 'P2P连接已断开网络可能不稳定';
shouldRetry = true;
break;
default:
errorMessage = '数据通道连接失败,可能是网络环境受限';
shouldRetry = true;
}
}
}
console.error(`[SharedWebRTC] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
canRetry: shouldRetry // 设置是否可以重试
});
};
};
}
@@ -474,18 +603,58 @@ export function useSharedWebRTCManager(): WebRTCConnection {
console.error('[SharedWebRTC] 连接失败:', error);
updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false
isConnecting: false,
canRetry: true
});
}
}, [updateState, cleanup, createOffer, handleDataChannelMessage, webrtcStore.isConnecting, webrtcStore.isConnected]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[SharedWebRTC] 断开连接');
console.log('[SharedWebRTC] 主动断开连接');
// 设置主动断开标志
isUserDisconnecting.current = true;
// 在断开之前通知对方
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({
type: 'disconnection',
payload: { reason: '用户主动断开' }
}));
console.log('[SharedWebRTC] 📤 已通知对方断开连接');
} catch (error) {
console.warn('[SharedWebRTC] 发送断开通知失败:', error);
}
}
// 清理连接
cleanup();
webrtcStore.reset();
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
webrtcStore.resetToInitial();
}, [cleanup, webrtcStore]);
// 重试连接
const retry = useCallback(async () => {
const room = currentRoom.current;
if (!room) {
console.warn('[SharedWebRTC] 没有当前房间信息,无法重试');
updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
return;
}
console.log('[SharedWebRTC] 🔄 重试连接到房间:', room.code, room.role);
// 清理当前连接
cleanup();
// 重新连接
await connect(room.code, room.role);
}, [cleanup, connect, updateState]);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
const dataChannel = dcRef.current;
@@ -593,15 +762,32 @@ export function useSharedWebRTCManager(): WebRTCConnection {
const pc = pcRef.current;
if (!pc) {
console.warn('[SharedWebRTC] PeerConnection 尚未准备就绪将在连接建立后设置onTrack');
// 检查WebSocket连接状态只有连接后才尝试设置
if (!webrtcStore.isWebSocketConnected) {
console.log('[SharedWebRTC] WebSocket未连接等待连接建立...');
return;
}
// 延迟设置等待PeerConnection准备就绪
let retryCount = 0;
const maxRetries = 30; // 最多重试30次即3秒
const checkAndSetTrackHandler = () => {
const currentPc = pcRef.current;
if (currentPc) {
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪设置onTrack处理器');
currentPc.ontrack = handler;
} else {
console.log('[SharedWebRTC] ⏳ 等待PeerConnection准备就绪...');
setTimeout(checkAndSetTrackHandler, 100);
retryCount++;
if (retryCount < maxRetries) {
// 只在偶数次重试时输出日志,减少日志数量
if (retryCount % 2 === 0) {
console.log(`[SharedWebRTC] ⏳ 等待PeerConnection准备就绪... (尝试: ${retryCount}/${maxRetries})`);
}
setTimeout(checkAndSetTrackHandler, 100);
} else {
console.error('[SharedWebRTC] ❌ PeerConnection 长时间未准备就绪,停止重试');
}
}
};
checkAndSetTrackHandler();
@@ -610,7 +796,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
pc.ontrack = handler;
}, []);
}, [webrtcStore.isWebSocketConnected]);
// 获取PeerConnection实例
const getPeerConnection = useCallback(() => {
@@ -642,10 +828,12 @@ export function useSharedWebRTCManager(): WebRTCConnection {
isWebSocketConnected: webrtcStore.isWebSocketConnected,
isPeerConnected: webrtcStore.isPeerConnected,
error: webrtcStore.error,
canRetry: webrtcStore.canRetry,
// 操作方法
connect,
disconnect,
retry,
sendMessage,
sendData,

View File

@@ -0,0 +1,195 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { WebRTCManager } from '../webrtc/WebRTCManager';
import { WebRTCConnectionState, WebRTCMessage, MessageHandler, DataHandler } from '../webrtc/types';
import { WebRTCConnection } from './useSharedWebRTCManager';
interface WebRTCManagerConfig {
dataChannelName?: string;
enableLogging?: boolean;
iceServers?: RTCIceServer[];
iceCandidatePoolSize?: number;
chunkSize?: number;
maxRetries?: number;
retryDelay?: number;
ackTimeout?: number;
}
/**
* 新的 WebRTC 管理器 Hook
* 替代原有的 useSharedWebRTCManager提供更好的架构和错误处理
*/
export function useWebRTCManager(config: WebRTCManagerConfig = {}): WebRTCConnection {
const managerRef = useRef<WebRTCManager | null>(null);
const [state, setState] = useState<WebRTCConnectionState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false,
currentRoom: null,
});
// 初始化管理器
useEffect(() => {
if (!managerRef.current) {
managerRef.current = new WebRTCManager(config);
// 监听状态变化
managerRef.current.on('state-change', (event: any) => {
setState(prev => ({ ...prev, ...event.state }));
});
}
return () => {
if (managerRef.current) {
managerRef.current.disconnect();
managerRef.current = null;
}
};
}, [config]);
// 连接
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
if (!managerRef.current) {
throw new Error('WebRTC 管理器未初始化');
}
return managerRef.current.connect(roomCode, role);
}, []);
// 断开连接
const disconnect = useCallback(() => {
if (!managerRef.current) return;
managerRef.current.disconnect();
}, []);
// 重试连接
const retry = useCallback(async () => {
if (!managerRef.current) {
throw new Error('WebRTC 管理器未初始化');
}
return managerRef.current.retry();
}, []);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
if (!managerRef.current) return false;
return managerRef.current.sendMessage(message, channel);
}, []);
// 发送数据
const sendData = useCallback((data: ArrayBuffer) => {
if (!managerRef.current) return false;
return managerRef.current.sendData(data);
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
if (!managerRef.current) return () => {};
return managerRef.current.registerMessageHandler(channel, handler);
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
if (!managerRef.current) return () => {};
return managerRef.current.registerDataHandler(channel, handler);
}, []);
// 添加媒体轨道
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
if (!managerRef.current) return null;
return managerRef.current.addTrack(track, stream);
}, []);
// 移除媒体轨道
const removeTrack = useCallback((sender: RTCRtpSender) => {
if (!managerRef.current) return;
managerRef.current.removeTrack(sender);
}, []);
// 设置轨道处理器
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
if (!managerRef.current) return;
managerRef.current.onTrack(handler);
}, []);
// 获取 PeerConnection
const getPeerConnection = useCallback(() => {
if (!managerRef.current) return null;
return managerRef.current.getPeerConnection();
}, []);
// 立即创建 offer
const createOfferNow = useCallback(async () => {
if (!managerRef.current) return false;
return managerRef.current.createOfferNow();
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
if (!managerRef.current) return 'closed';
return managerRef.current.getChannelState();
}, []);
// 检查是否已连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
if (!managerRef.current) return false;
return managerRef.current.isConnectedToRoom(roomCode, role);
}, []);
return {
// 状态
isConnected: state.isConnected,
isConnecting: state.isConnecting,
isWebSocketConnected: state.isWebSocketConnected,
isPeerConnected: state.isPeerConnected,
error: state.error,
canRetry: state.canRetry,
// 操作方法
connect,
disconnect,
retry,
sendMessage,
sendData,
// 处理器注册
registerMessageHandler,
registerDataHandler,
// 工具方法
getChannelState,
isConnectedToRoom,
// 媒体轨道方法
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
// 当前房间信息
currentRoom: state.currentRoom,
};
}
/**
* 迁移辅助 Hook - 提供向后兼容性
* 可以逐步将现有代码迁移到新的架构
*/
export function useWebRTCMigration() {
const newManager = useWebRTCManager();
// 注意:这里需要先创建一个包装器来兼容旧的接口
// 暂时注释掉,避免循环依赖
// const oldManager = useSharedWebRTCManager();
return {
newManager,
// oldManager, // 暂时禁用
// 可以添加迁移工具函数
migrateState: () => {
// 将旧状态迁移到新状态
console.log('状态迁移功能待实现');
},
};
}

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react';
import { detectWebRTCSupport, WebRTCSupport } from '@/lib/webrtc-support';
/**
* WebRTC 支持检测 Hook
*/
export function useWebRTCSupport() {
const [webrtcSupport, setWebrtcSupport] = useState<WebRTCSupport | null>(null);
const [showUnsupportedModal, setShowUnsupportedModal] = useState(false);
const [isChecked, setIsChecked] = useState(false);
useEffect(() => {
// 页面加载时检测WebRTC支持
const support = detectWebRTCSupport();
setWebrtcSupport(support);
setIsChecked(true);
// 如果不支持,自动显示模态框
if (!support.isSupported) {
setShowUnsupportedModal(true);
}
}, []);
const closeUnsupportedModal = () => {
setShowUnsupportedModal(false);
};
const showUnsupportedModalManually = () => {
setShowUnsupportedModal(true);
};
return {
webrtcSupport,
isSupported: webrtcSupport?.isSupported ?? false,
isChecked,
showUnsupportedModal,
closeUnsupportedModal,
showUnsupportedModalManually,
};
}

View File

@@ -0,0 +1,2 @@
// 桌面共享相关的hooks
export { useDesktopShareBusiness } from './useDesktopShareBusiness';

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useSharedWebRTCManager } from './useSharedWebRTCManager';
import { useSharedWebRTCManager } from '../connection/useSharedWebRTCManager';
interface DesktopShareState {
isSharing: boolean;
@@ -60,16 +60,6 @@ export function useDesktopShareBusiness() {
});
}, [webRTC, handleRemoteStream]);
// 生成6位房间代码
const generateRoomCode = useCallback(() => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}, []);
// 获取桌面共享流
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
try {
@@ -172,13 +162,32 @@ export function useDesktopShareBusiness() {
};
}, [webRTC]);
// 创建房间 - 统一使用后端生成房间码
const createRoomFromBackend = useCallback(async (): Promise<string> => {
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 || '创建房间失败');
}
return data.code;
}, []);
// 创建房间(只建立连接,等待对方加入)
const createRoom = useCallback(async (): Promise<string> => {
try {
updateState({ error: null, isWaitingForPeer: false });
// 生成房间代码
const roomCode = generateRoomCode();
// 从后端获取房间代码
const roomCode = await createRoomFromBackend();
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
// 建立WebRTC连接作为发送方
@@ -199,7 +208,7 @@ export function useDesktopShareBusiness() {
updateState({ error: errorMessage, connectionCode: '', isWaitingForPeer: false });
throw error;
}
}, [webRTC, generateRoomCode, updateState]);
}, [webRTC, createRoomFromBackend, updateState]);
// 开始桌面共享(在接收方加入后)
const startSharing = useCallback(async (): Promise<void> => {

View File

@@ -0,0 +1,4 @@
// 文件传输相关的hooks
export { useFileTransferBusiness } from './useFileTransferBusiness';
export { useFileStateManager } from './useFileStateManager';
export { useFileListSync } from './useFileListSync';

View File

@@ -0,0 +1,65 @@
import { useRef, useCallback, useEffect } from 'react';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
interface UseFileListSyncProps {
sendFileList: (fileInfos: FileInfo[]) => void;
mode: 'send' | 'receive';
pickupCode: string;
isConnected: boolean;
isPeerConnected: boolean;
getChannelState: () => string;
}
export const useFileListSync = ({
sendFileList,
mode,
pickupCode,
isConnected,
isPeerConnected,
getChannelState
}: UseFileListSyncProps) => {
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 统一的文件列表同步函数,带防抖功能
const syncFileListToReceiver = useCallback((fileInfos: FileInfo[], reason: string) => {
// 只有在发送模式、连接已建立且有房间时才发送文件列表
if (mode !== 'send' || !pickupCode || !isConnected || !isPeerConnected) {
console.log('跳过文件列表同步:', { mode, pickupCode: !!pickupCode, isConnected, isPeerConnected });
return;
}
// 清除之前的延时发送
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// 延时发送,避免频繁发送
syncTimeoutRef.current = setTimeout(() => {
if (isPeerConnected && getChannelState() === 'open') {
console.log(`发送文件列表到接收方 (${reason}):`, fileInfos.map(f => f.name));
sendFileList(fileInfos);
}
}, 150);
}, [mode, pickupCode, isConnected, isPeerConnected, getChannelState, sendFileList]);
// 清理防抖定时器
useEffect(() => {
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, []);
return {
syncFileListToReceiver
};
};

View File

@@ -0,0 +1,166 @@
import { useState, useCallback, useEffect } from 'react';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
interface UseFileStateManagerProps {
mode: 'send' | 'receive';
pickupCode: string;
syncFileListToReceiver: (fileInfos: FileInfo[], reason: string) => void;
isPeerConnected: boolean;
}
export const useFileStateManager = ({
mode,
pickupCode,
syncFileListToReceiver,
isPeerConnected
}: UseFileStateManagerProps) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [fileList, setFileList] = useState<FileInfo[]>([]);
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
// 生成文件ID
const generateFileId = useCallback(() => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}, []);
// 文件选择处理
const handleFileSelect = useCallback((files: File[]) => {
console.log('=== 文件选择 ===');
console.log('新文件:', files.map(f => f.name));
// 更新选中的文件
setSelectedFiles(prev => [...prev, ...files]);
// 创建对应的文件信息
const newFileInfos: FileInfo[] = files.map(file => ({
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready',
progress: 0
}));
setFileList(prev => {
const updatedList = [...prev, ...newFileInfos];
console.log('更新后的文件列表:', updatedList);
return updatedList;
});
}, [generateFileId]);
// 清空文件
const clearFiles = useCallback(() => {
console.log('=== 清空文件 ===');
setSelectedFiles([]);
setFileList([]);
}, []);
// 重置状态
const resetFiles = useCallback(() => {
console.log('=== 重置文件状态 ===');
setSelectedFiles([]);
setFileList([]);
setDownloadedFiles(new Map());
}, []);
// 更新文件状态
const updateFileStatus = useCallback((fileId: string, status: FileInfo['status'], progress?: number) => {
setFileList(prev => prev.map(item =>
item.id === fileId
? { ...item, status, progress: progress ?? item.progress }
: item
));
}, []);
// 更新文件进度
const updateFileProgress = useCallback((fileId: string, fileName: string, progress: number) => {
const newStatus = progress >= 100 ? 'completed' as const : 'downloading' as const;
setFileList(prev => prev.map(item => {
if (item.id === fileId || item.name === fileName) {
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${progress}`);
return { ...item, progress, status: newStatus };
}
return item;
}));
}, []);
// 数据通道第一次打开时初始化
useEffect(() => {
if (isPeerConnected && mode === 'send' && fileList.length > 0) {
console.log('P2P连接已建立数据通道首次打开初始化文件列表');
syncFileListToReceiver(fileList, '数据通道初始化');
}
}, [isPeerConnected, mode, syncFileListToReceiver]);
// 监听fileList大小变化并同步
useEffect(() => {
if (isPeerConnected && mode === 'send' && pickupCode) {
console.log('fileList大小变化同步到接收方:', fileList.length);
syncFileListToReceiver(fileList, 'fileList大小变化');
}
}, [fileList.length, isPeerConnected, mode, pickupCode, syncFileListToReceiver]);
// 监听selectedFiles变化同步更新fileList
useEffect(() => {
// 只有在发送模式下且已有房间时才处理文件列表同步
if (mode !== 'send' || !pickupCode) return;
console.log('=== selectedFiles变化同步文件列表 ===', {
selectedFilesCount: selectedFiles.length,
fileListCount: fileList.length,
selectedFileNames: selectedFiles.map(f => f.name)
});
// 根据selectedFiles创建新的文件信息列表
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
// 尝试找到现有的文件信息,保持已有的状态
const existingFileInfo = fileList.find(info => info.name === file.name && info.size === file.size);
return existingFileInfo || {
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready' as const,
progress: 0
};
});
// 检查文件列表是否真正发生变化
const fileListChanged =
newFileInfos.length !== fileList.length ||
newFileInfos.some(newFile =>
!fileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
);
if (fileListChanged) {
console.log('文件列表发生变化,更新:', {
before: fileList.map(f => f.name),
after: newFileInfos.map(f => f.name)
});
setFileList(newFileInfos);
}
}, [selectedFiles, mode, pickupCode, fileList, generateFileId]);
return {
selectedFiles,
setSelectedFiles,
fileList,
setFileList,
downloadedFiles,
setDownloadedFiles,
handleFileSelect,
clearFiles,
resetFiles,
updateFileStatus,
updateFileProgress
};
};

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from './useSharedWebRTCManager';
import type { WebRTCConnection } from '../connection/useSharedWebRTCManager';
// 文件传输状态
interface FileTransferState {
@@ -17,7 +17,6 @@ interface FileTransferState {
interface FileReceiveProgress {
fileId: string;
fileName: string;
receivedChunks: number;
totalChunks: number;
progress: number;
}
@@ -87,14 +86,14 @@ const ACK_TIMEOUT = 5000; // 确认超时(毫秒)
function calculateChecksum(data: ArrayBuffer): string {
const buffer = new Uint8Array(data);
let crc = 0xFFFFFFFF;
for (let i = 0; i < buffer.length; i++) {
crc ^= buffer[i];
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
}
}
return (crc ^ 0xFFFFFFFF).toString(16).padStart(8, '0');
}
@@ -104,11 +103,11 @@ function calculateChecksum(data: ArrayBuffer): string {
function simpleChecksum(data: ArrayBuffer): string {
const buffer = new Uint8Array(data);
let sum = 0;
for (let i = 0; i < Math.min(buffer.length, 1000); i++) {
sum += buffer[i];
}
return sum.toString(16);
}
@@ -161,12 +160,12 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 消息处理器
const handleMessage = useCallback((message: any) => {
if (!message.type.startsWith('file-')) return;
console.log('文件传输收到消息:', message.type, message); switch (message.type) {
console.log('文件传输收到消息:', message.type, message); switch (message.type) {
case 'file-metadata':
const metadata: FileMetadata = message.payload;
console.log('开始接收文件:', metadata.name);
receivingFiles.current.set(metadata.id, {
metadata,
chunks: [],
@@ -178,11 +177,10 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
receiveProgress.current.set(metadata.id, {
fileId: metadata.id,
fileName: metadata.name,
receivedChunks: 0,
totalChunks,
progress: 0
});
// 设置当前活跃的接收文件
activeReceiveFile.current = metadata.id;
updateState({ isTransferring: true, progress: 0 });
@@ -196,16 +194,16 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
case 'file-complete':
const { fileId } = message.payload;
const fileInfo = receivingFiles.current.get(fileId);
if (fileInfo) {
// 组装文件
const blob = new Blob(fileInfo.chunks, { type: fileInfo.metadata.type });
const file = new File([blob], fileInfo.metadata.name, {
type: fileInfo.metadata.type
const file = new File([blob], fileInfo.metadata.name, {
type: fileInfo.metadata.type
});
console.log('文件接收完成:', file.name);
setState(prev => ({
...prev,
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
@@ -216,7 +214,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
receivingFiles.current.delete(fileId);
receiveProgress.current.delete(fileId);
// 清除活跃文件
if (activeReceiveFile.current === fileId) {
activeReceiveFile.current = null;
@@ -238,7 +236,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
case 'file-chunk-ack':
const ack: ChunkAck = message.payload;
console.log('收到块确认:', ack);
// 清除超时定时器
const chunkKey = `${ack.fileId}-${ack.chunkIndex}`;
const timeout = pendingChunks.current.get(chunkKey);
@@ -277,15 +275,15 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
const { fileId, chunkIndex, totalChunks, checksum: expectedChecksum } = expectedChunk.current;
const fileInfo = receivingFiles.current.get(fileId);
if (fileInfo) {
// 验证数据完整性
const actualChecksum = calculateChecksum(data);
const isValid = !expectedChecksum || actualChecksum === expectedChecksum;
if (!isValid) {
console.warn(`文件块校验失败: 期望 ${expectedChecksum}, 实际 ${actualChecksum}`);
// 发送失败确认
connection.sendMessage({
type: 'file-chunk-ack',
@@ -296,27 +294,33 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
checksum: actualChecksum
}
}, CHANNEL_NAME);
expectedChunk.current = null;
return;
}
// 检查是否已经接收过这个块,避免重复计数
const alreadyReceived = fileInfo.chunks[chunkIndex] !== undefined;
// 数据有效,保存到缓存
fileInfo.chunks[chunkIndex] = data;
fileInfo.receivedChunks++;
// 只有在首次接收时才增加计数
if (!alreadyReceived) {
fileInfo.receivedChunks++;
}
// 更新接收进度跟踪
// 更新接收进度跟踪 - 使用 fileInfo 的计数,避免双重计数
const progressInfo = receiveProgress.current.get(fileId);
if (progressInfo) {
progressInfo.receivedChunks++;
progressInfo.progress = progressInfo.totalChunks > 0 ?
(progressInfo.receivedChunks / progressInfo.totalChunks) * 100 : 0;
progressInfo.progress = progressInfo.totalChunks > 0 ?
(fileInfo.receivedChunks / progressInfo.totalChunks) * 100 : 0;
// 只有当这个文件是当前活跃文件时才更新全局进度
if (activeReceiveFile.current === fileId) {
updateState({ progress: progressInfo.progress });
}
// 触发进度回调
fileProgressCallbacks.current.forEach(cb => cb({
fileId: fileId,
@@ -326,7 +330,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
console.log(`文件 ${progressInfo.fileName} 接收进度: ${progressInfo.progress.toFixed(1)}%`);
}
// 发送成功确认
connection.sendMessage({
type: 'file-chunk-ack',
@@ -337,22 +341,26 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
checksum: actualChecksum
}
}, CHANNEL_NAME);
expectedChunk.current = null;
}
}, [updateState, connection]);
// 设置处理器 - 使用稳定的引用避免反复注册
const connectionRef = useRef(connection);
useEffect(() => {
connectionRef.current = connection;
}, [connection]);
useEffect(() => {
// 使用共享连接的注册方式
const unregisterMessage = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
const unregisterData = connection.registerDataHandler(CHANNEL_NAME, handleData);
const unregisterMessage = connectionRef.current.registerMessageHandler(CHANNEL_NAME, handleMessage);
const unregisterData = connectionRef.current.registerDataHandler(CHANNEL_NAME, handleData);
return () => {
unregisterMessage();
unregisterData();
};
}, [connection]); // 只依赖 connection 对象,不依赖处理函数
}, []); // 只依赖 connection 对象,不依赖处理函数
// 监听连接状态变化 (直接使用 connection 的状态)
useEffect(() => {
@@ -379,8 +387,21 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
retryCount = 0
): Promise<boolean> => {
return new Promise((resolve) => {
// 主要检查数据通道状态,因为数据通道是文件传输的实际通道
const channelState = connection.getChannelState();
if (channelState === 'closed') {
console.warn(`数据通道已关闭,停止发送文件块 ${chunkIndex}`);
resolve(false);
return;
}
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
if (!connection.isConnected && channelState === 'connecting') {
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
}
const chunkKey = `${fileId}-${chunkIndex}`;
// 设置确认回调
const ackCallback = (ack: ChunkAck) => {
if (ack.success) {
@@ -468,6 +489,18 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
let retryCount = 0;
while (!success && retryCount <= MAX_RETRIES) {
// 检查数据通道状态,这是文件传输的实际通道
const channelState = connection.getChannelState();
if (channelState === 'closed') {
console.warn(`数据通道已关闭,停止文件传输`);
throw new Error('数据通道已关闭');
}
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
if (!connection.isConnected && channelState === 'connecting') {
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
}
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
@@ -483,7 +516,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
status.sentChunks.add(chunkIndex);
status.acknowledgedChunks.add(chunkIndex);
status.failedChunks.delete(chunkIndex);
// 计算传输速度
const now = Date.now();
const timeDiff = (now - status.lastChunkTime) / 1000; // 秒
@@ -495,12 +528,12 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
} else {
retryCount++;
status.retryCount.set(chunkIndex, retryCount);
if (retryCount > MAX_RETRIES) {
status.failedChunks.add(chunkIndex);
throw new Error(`文件块 ${chunkIndex} 发送失败,超过最大重试次数`);
}
// 指数退避
const delay = Math.min(RETRY_DELAY * Math.pow(2, retryCount - 1), 10000);
console.log(`等待 ${delay}ms 后重试文件块 ${chunkIndex}`);
@@ -508,10 +541,10 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
}
}
// 更新进度
const progress = (status.acknowledgedChunks.size / totalChunks) * 100;
// 更新进度 - 基于已发送的块数,这样与接收方的进度更同步
const progress = ((chunkIndex + 1) / totalChunks) * 100;
updateState({ progress });
fileProgressCallbacks.current.forEach(cb => cb({
fileId: actualFileId,
fileName: file.name,
@@ -524,7 +557,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
const expectedTime = (chunkSize / 1024) / status.averageSpeed;
const actualTime = Date.now() - status.lastChunkTime;
const delay = Math.max(0, expectedTime - actualTime);
if (delay > 10) {
await new Promise(resolve => setTimeout(resolve, Math.min(delay, 100)));
}
@@ -548,9 +581,9 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
} catch (error) {
console.error('安全发送文件失败:', error);
updateState({
updateState({
error: error instanceof Error ? error.message : '发送失败',
isTransferring: false
isTransferring: false
});
transferStatus.current.delete(actualFileId);
}
@@ -567,17 +600,17 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
const channelState = connection.getChannelState();
const peerConnected = connection.isPeerConnected;
console.log('发送文件列表检查:', {
channelState,
peerConnected,
fileListLength: fileList.length
});
// 如果数据通道已打开或者 P2P 已连接,就可以发送文件列表
if (channelState === 'open' || peerConnected) {
console.log('发送文件列表:', fileList);
connection.sendMessage({
type: 'file-list',
payload: fileList
@@ -595,7 +628,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
}
console.log('请求文件:', fileName, fileId);
connection.sendMessage({
type: 'file-request',
payload: { fileId, fileName }

View File

@@ -0,0 +1,19 @@
// 按功能分类的hooks导出
// 连接相关
export * from './connection';
// 文件传输相关
export * from './file-transfer';
// 桌面共享相关
export * from './desktop-share';
// 文本传输相关
export * from './text-transfer';
// UI状态管理相关
export * from './ui';
// 核心WebRTC功能
export * from './webrtc';

View File

@@ -0,0 +1,2 @@
// 文本传输相关的hooks
export { useTextTransferBusiness } from './useTextTransferBusiness';

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from './useSharedWebRTCManager';
import type { WebRTCConnection } from '../connection/useSharedWebRTCManager';
// 文本传输状态
interface TextTransferState {

View File

@@ -0,0 +1,5 @@
// UI状态管理相关的hooks
export { useURLHandler } from './useURLHandler';
export { useWebRTCStore } from './webRTCStore';
export { useTabNavigation } from './useTabNavigation';
export type { TabType } from './useTabNavigation';

View File

@@ -0,0 +1,52 @@
import { useState, useCallback } from 'react';
export interface ConfirmDialogOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'warning' | 'danger' | 'info';
}
export interface ConfirmDialogState extends ConfirmDialogOptions {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export const useConfirmDialog = () => {
const [dialogState, setDialogState] = useState<ConfirmDialogState | null>(null);
const showConfirmDialog = useCallback((options: ConfirmDialogOptions): Promise<boolean> => {
return new Promise((resolve) => {
const handleConfirm = () => {
setDialogState(null);
resolve(true);
};
const handleCancel = () => {
setDialogState(null);
resolve(false);
};
setDialogState({
...options,
isOpen: true,
onConfirm: handleConfirm,
onCancel: handleCancel,
});
});
}, []);
const closeDialog = useCallback(() => {
if (dialogState) {
dialogState.onCancel();
}
}, [dialogState]);
return {
dialogState,
showConfirmDialog,
closeDialog,
};
};

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useURLHandler, FeatureType } from './useURLHandler';
import { useWebRTCStore } from './webRTCStore';
import { useConfirmDialog } from './useConfirmDialog';
// Tab类型定义包括非WebRTC功能
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat';
// Tab显示名称
const TAB_NAMES: Record<TabType, string> = {
webrtc: '文件传输',
message: '文字传输',
desktop: '桌面共享',
wechat: '微信群'
};
// WebRTC功能的映射
const WEBRTC_FEATURES: Record<string, FeatureType> = {
webrtc: 'webrtc',
message: 'message',
desktop: 'desktop'
};
export const useTabNavigation = () => {
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<TabType>('webrtc');
const [hasInitialized, setHasInitialized] = useState(false);
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
// 获取WebRTC全局状态
const {
isConnected,
isConnecting,
isPeerConnected,
currentRoom,
reset: resetWebRTCState
} = useWebRTCStore();
// 创建一个通用的URL处理器用于断开连接
const { hasActiveConnection } = useURLHandler({
featureType: 'webrtc', // 默认值,实际使用时会被覆盖
onModeChange: () => {},
});
// 根据URL参数设置初始标签仅首次加载时
useEffect(() => {
if (!hasInitialized) {
const urlType = searchParams.get('type');
console.log('=== HomePage URL处理 ===');
console.log('URL type参数:', urlType);
console.log('所有搜索参数:', Object.fromEntries(searchParams.entries()));
// 将旧的text类型重定向到message
if (urlType === 'text') {
console.log('检测到text类型重定向到message标签页');
setActiveTab('message');
} else if (urlType === 'webrtc') {
console.log('检测到webrtc类型切换到webrtc标签页文件传输');
setActiveTab('webrtc');
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
console.log('切换到对应标签页:', urlType);
setActiveTab(urlType as TabType);
} else {
console.log('没有有效的type参数使用默认标签页webrtc文件传输');
// 保持默认的webrtc标签
}
setHasInitialized(true);
}
}, [searchParams, hasInitialized]);
// 处理tab切换
const handleTabChange = useCallback(async (newTab: TabType) => {
console.log('=== Tab切换 ===');
console.log('当前tab:', activeTab, '目标tab:', newTab);
// 如果切换到wechat tab非WebRTC功能可以直接切换
if (newTab === 'wechat') {
// 如果有活跃连接,需要确认
if (hasActiveConnection()) {
const currentTabName = TAB_NAMES[activeTab];
const confirmed = await showConfirmDialog({
title: '切换功能确认',
message: `切换到微信群功能需要关闭当前的${currentTabName}连接,是否继续?`,
confirmText: '确认切换',
cancelText: '取消',
type: 'warning'
});
if (!confirmed) {
return false;
}
// 断开连接并清除状态
resetWebRTCState();
console.log('已清除WebRTC连接状态切换到微信群');
}
setActiveTab(newTab);
// 清除URL参数
const newUrl = new URL(window.location.href);
newUrl.search = '';
window.history.pushState({}, '', newUrl.toString());
return true;
}
// 如果有活跃连接且切换到不同的WebRTC功能需要确认
if (hasActiveConnection() && newTab !== activeTab && WEBRTC_FEATURES[newTab]) {
const currentTabName = TAB_NAMES[activeTab];
const targetTabName = TAB_NAMES[newTab];
const confirmed = await showConfirmDialog({
title: '切换功能确认',
message: `切换到${targetTabName}功能需要关闭当前的${currentTabName}连接,是否继续?`,
confirmText: '确认切换',
cancelText: '取消',
type: 'warning'
});
if (!confirmed) {
return false;
}
// 用户确认后重置WebRTC状态
resetWebRTCState();
console.log(`已断开${currentTabName}连接,切换到${targetTabName}`);
}
// 执行tab切换
setActiveTab(newTab);
// 更新URL对于WebRTC功能
if (WEBRTC_FEATURES[newTab]) {
const params = new URLSearchParams();
params.set('type', WEBRTC_FEATURES[newTab]);
params.set('mode', 'send'); // 默认模式
const newUrl = `?${params.toString()}`;
window.history.pushState({}, '', newUrl);
} else {
// 非WebRTC功能清除URL参数
const newUrl = new URL(window.location.href);
newUrl.search = '';
window.history.pushState({}, '', newUrl.toString());
}
return true;
}, [activeTab, hasActiveConnection, resetWebRTCState]);
// 获取连接状态信息
const getConnectionInfo = useCallback(() => {
return {
hasConnection: hasActiveConnection(),
currentRoom: currentRoom,
isConnected,
isConnecting,
isPeerConnected
};
}, [hasActiveConnection, currentRoom, isConnected, isConnecting, isPeerConnected]);
return {
activeTab,
handleTabChange,
getConnectionInfo,
hasInitialized,
// 导出确认对话框状态
confirmDialogState: dialogState,
closeConfirmDialog: closeDialog
};
};

View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useToast } from '@/components/ui/toast-simple';
import { useWebRTCStore } from './webRTCStore';
import { useConfirmDialog } from './useConfirmDialog';
// 支持的功能类型
export type FeatureType = 'webrtc' | 'message' | 'desktop';
// 功能类型的显示名称
const FEATURE_NAMES: Record<FeatureType, string> = {
webrtc: '文件传输',
message: '文字传输',
desktop: '桌面共享'
};
interface UseURLHandlerProps<T = string> {
featureType: FeatureType;
onModeChange: (mode: T) => void;
onAutoJoinRoom?: (code: string) => void;
onDisconnect?: () => void; // 新增:断开连接的回调
modeConverter?: {
// 将URL模式转换为组件内部模式
fromURL: (urlMode: 'send' | 'receive') => T;
// 将组件内部模式转换为URL模式
toURL: (componentMode: T) => 'send' | 'receive';
};
}
export const useURLHandler = <T = 'send' | 'receive'>({
featureType,
onModeChange,
onAutoJoinRoom,
onDisconnect,
modeConverter
}: UseURLHandlerProps<T>) => {
const searchParams = useSearchParams();
const router = useRouter();
const { showToast } = useToast();
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
const urlProcessedRef = useRef(false);
// 获取WebRTC全局状态
const {
isConnected,
isConnecting,
isPeerConnected,
currentRoom,
reset: resetWebRTCState
} = useWebRTCStore();
// 检查是否有活跃连接
const hasActiveConnection = useCallback(() => {
return isConnected || isConnecting || isPeerConnected;
}, [isConnected, isConnecting, isPeerConnected]);
// 功能切换确认
const switchToFeature = useCallback(async (targetFeatureType: FeatureType, mode?: 'send' | 'receive', code?: string) => {
// 如果是同一个功能,直接切换
if (targetFeatureType === featureType) {
if (mode) {
const params = new URLSearchParams(searchParams.toString());
params.set('type', targetFeatureType);
params.set('mode', mode);
if (code) {
params.set('code', code);
} else if (mode === 'send') {
params.delete('code');
}
router.push(`?${params.toString()}`, { scroll: false });
}
return true;
}
// 如果有活跃连接,需要确认
if (hasActiveConnection()) {
const currentFeatureName = FEATURE_NAMES[featureType];
const targetFeatureName = FEATURE_NAMES[targetFeatureType];
const confirmed = await showConfirmDialog({
title: '切换功能确认',
message: `切换到${targetFeatureName}功能需要关闭当前的${currentFeatureName}连接,是否继续?`,
confirmText: '确认切换',
cancelText: '取消',
type: 'warning'
});
if (!confirmed) {
return false;
}
// 用户确认后,断开当前连接
try {
if (onDisconnect) {
await onDisconnect();
}
resetWebRTCState();
showToast(`已断开${currentFeatureName}连接`, 'info');
} catch (error) {
console.error('断开连接时出错:', error);
showToast('断开连接时出错,但将继续切换功能', 'error');
}
}
// 切换到新功能
const params = new URLSearchParams();
params.set('type', targetFeatureType);
if (mode) {
params.set('mode', mode);
}
if (code) {
params.set('code', code);
}
router.push(`?${params.toString()}`, { scroll: false });
return true;
}, [featureType, hasActiveConnection, onDisconnect, resetWebRTCState, showToast, router, searchParams, showConfirmDialog]);
// 从URL参数中获取初始模式仅在首次加载时处理
useEffect(() => {
// 使用 ref 确保只处理一次,避免严格模式的重复调用
if (urlProcessedRef.current) {
console.log('URL已处理过跳过重复处理');
return;
}
const urlMode = searchParams.get('mode') as 'send' | 'receive';
const type = searchParams.get('type') as FeatureType;
const code = searchParams.get('code');
// 只在首次加载且URL中有对应功能类型时处理
if (!hasProcessedInitialUrl && type === featureType && urlMode && ['send', 'receive'].includes(urlMode)) {
console.log(`=== 处理初始URL参数 [${featureType}] ===`);
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
// 立即标记为已处理,防止重复
urlProcessedRef.current = true;
// 转换模式(如果有转换器的话)
const componentMode = modeConverter ? modeConverter.fromURL(urlMode) : urlMode as T;
onModeChange(componentMode);
setHasProcessedInitialUrl(true);
// 自动加入房间只在receive模式且有code时
if (code && urlMode === 'receive' && onAutoJoinRoom) {
console.log('URL中有取件码自动加入房间');
onAutoJoinRoom(code);
}
}
}, [searchParams, hasProcessedInitialUrl, featureType, onModeChange, onAutoJoinRoom, modeConverter]);
// 更新URL参数
const updateMode = useCallback((newMode: T) => {
console.log(`=== 手动切换模式 [${featureType}] ===`);
console.log('新模式:', newMode);
onModeChange(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', featureType);
// 转换模式(如果有转换器的话)
const urlMode = modeConverter ? modeConverter.toURL(newMode) : newMode as string;
params.set('mode', urlMode);
// 如果切换到发送模式移除code参数
if (urlMode === 'send') {
params.delete('code');
}
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router, featureType, onModeChange, modeConverter]);
// 更新URL中的房间代码
const updateRoomCode = useCallback((code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('type', featureType);
params.set('code', code);
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router, featureType]);
// 获取当前URL中的房间代码
const getCurrentRoomCode = useCallback(() => {
return searchParams.get('code') || '';
}, [searchParams]);
// 清除URL参数
const clearURLParams = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete('type');
params.delete('mode');
params.delete('code');
const newURL = params.toString() ? `?${params.toString()}` : '/';
router.push(newURL, { scroll: false });
}, [searchParams, router]);
return {
updateMode,
updateRoomCode,
getCurrentRoomCode,
clearURLParams,
switchToFeature,
hasActiveConnection,
// 导出对话框状态供组件使用
confirmDialogState: dialogState,
closeConfirmDialog: closeDialog
};
};

View File

@@ -6,6 +6,7 @@ interface WebRTCState {
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
canRetry: boolean; // 新增:是否可以重试
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
}
@@ -13,6 +14,7 @@ interface WebRTCStore extends WebRTCState {
updateState: (updates: Partial<WebRTCState>) => void;
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
reset: () => void;
resetToInitial: () => void; // 新增:完全重置到初始状态
}
const initialState: WebRTCState = {
@@ -21,6 +23,7 @@ const initialState: WebRTCState = {
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false, // 初始状态下不需要重试
currentRoom: null,
};
@@ -38,4 +41,6 @@ export const useWebRTCStore = create<WebRTCStore>((set) => ({
})),
reset: () => set(initialState),
resetToInitial: () => set(initialState), // 完全重置到初始状态
}));

View File

@@ -0,0 +1,227 @@
import { EventEmitter } from 'events';
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler, MessageHandler, DataHandler } from './types';
interface DataChannelManagerConfig {
channelName: string;
onMessage?: MessageHandler;
onData?: DataHandler;
ordered?: boolean;
maxRetransmits?: number;
}
export class DataChannelManager extends EventEmitter {
private dataChannel: RTCDataChannel | null = null;
private config: DataChannelManagerConfig;
private messageQueue: WebRTCMessage[] = [];
private dataQueue: ArrayBuffer[] = [];
private isReady = false;
constructor(config: DataChannelManagerConfig) {
super();
this.config = {
ordered: true,
maxRetransmits: 3,
...config
};
}
initializeDataChannel(dataChannel: RTCDataChannel): void {
this.dataChannel = dataChannel;
this.setupEventHandlers();
}
createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
if (this.dataChannel) {
throw new WebRTCError('DC_ALREADY_EXISTS', '数据通道已存在', false);
}
try {
this.dataChannel = pc.createDataChannel(this.config.channelName, {
ordered: this.config.ordered,
maxRetransmits: this.config.maxRetransmits,
});
this.setupEventHandlers();
return this.dataChannel;
} catch (error) {
throw new WebRTCError('DC_CREATE_FAILED', '创建数据通道失败', false, error as Error);
}
}
private setupEventHandlers(): void {
if (!this.dataChannel) return;
this.dataChannel.onopen = () => {
console.log(`[DataChannel] 数据通道已打开: ${this.config.channelName}`);
this.isReady = true;
this.flushQueues();
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
};
this.dataChannel.onmessage = (event) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as WebRTCMessage;
console.log(`[DataChannel] 收到消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
if (this.config.onMessage) {
this.config.onMessage(message);
}
} catch (error) {
console.error('[DataChannel] 解析消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
});
}
} else if (event.data instanceof ArrayBuffer) {
console.log(`[DataChannel] 收到数据: ${event.data.byteLength} bytes`);
if (this.config.onData) {
this.config.onData(event.data);
}
}
};
this.dataChannel.onerror = (error) => {
console.error(`[DataChannel] 数据通道错误: ${this.config.channelName}`, error);
const errorMessage = this.getDetailedErrorMessage();
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_ERROR', errorMessage, true)
});
};
this.dataChannel.onclose = () => {
console.log(`[DataChannel] 数据通道已关闭: ${this.config.channelName}`);
this.isReady = false;
this.emit('disconnected', {
type: 'disconnected',
reason: `数据通道关闭: ${this.config.channelName}`
});
};
}
private getDetailedErrorMessage(): string {
if (!this.dataChannel) return '数据通道不可用';
switch (this.dataChannel.readyState) {
case 'connecting':
return '数据通道正在连接中,请稍候...';
case 'closing':
return '数据通道正在关闭,连接即将断开';
case 'closed':
return '数据通道已关闭P2P连接失败';
default:
return '数据通道连接失败,可能是网络环境受限';
}
}
sendMessage(message: WebRTCMessage): boolean {
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
this.messageQueue.push(message);
return false;
}
try {
this.dataChannel.send(JSON.stringify(message));
console.log(`[DataChannel] 发送消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
return true;
} catch (error) {
console.error('[DataChannel] 发送消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_SEND_ERROR', '发送消息失败', true, error as Error)
});
return false;
}
}
sendData(data: ArrayBuffer): boolean {
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
this.dataQueue.push(data);
return false;
}
try {
this.dataChannel.send(data);
console.log(`[DataChannel] 发送数据: ${data.byteLength} bytes`);
return true;
} catch (error) {
console.error('[DataChannel] 发送数据失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_SEND_DATA_ERROR', '发送数据失败', true, error as Error)
});
return false;
}
}
private flushQueues(): void {
// 发送排队的消息
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (message) {
this.sendMessage(message);
}
}
// 发送排队的数据
while (this.dataQueue.length > 0) {
const data = this.dataQueue.shift();
if (data) {
this.sendData(data);
}
}
}
getState(): RTCDataChannelState {
return this.dataChannel?.readyState || 'closed';
}
isChannelReady(): boolean {
return this.isReady && this.dataChannel?.readyState === 'open';
}
getBufferedAmount(): number {
return this.dataChannel?.bufferedAmount || 0;
}
getBufferedAmountLowThreshold(): number {
return this.dataChannel?.bufferedAmountLowThreshold || 0;
}
setBufferedAmountLowThreshold(threshold: number): void {
if (this.dataChannel) {
this.dataChannel.bufferedAmountLowThreshold = threshold;
}
}
onBufferedAmountLow(handler: () => void): void {
if (this.dataChannel) {
this.dataChannel.onbufferedamountlow = handler;
}
}
close(): void {
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
this.isReady = false;
this.messageQueue = [];
this.dataQueue = [];
this.emit('disconnected', { type: 'disconnected', reason: '数据通道已关闭' });
}
on(event: string, handler: EventHandler<ConnectionEvent>): this {
return super.on(event, handler);
}
emit(eventName: string, event?: ConnectionEvent): boolean {
return super.emit(eventName, event);
}
}

View File

@@ -0,0 +1,197 @@
import { WebRTCMessage, MessageHandler, DataHandler } from './types';
interface ChannelHandlers {
messageHandlers: Set<MessageHandler>;
dataHandlers: Set<DataHandler>;
}
export class MessageRouter {
private channels = new Map<string, ChannelHandlers>();
private defaultChannelHandlers: ChannelHandlers | null = null;
constructor() {
this.createDefaultChannel();
}
private createDefaultChannel(): void {
this.defaultChannelHandlers = {
messageHandlers: new Set(),
dataHandlers: new Set(),
};
}
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
let channelHandlers = this.channels.get(channel);
if (!channelHandlers) {
channelHandlers = {
messageHandlers: new Set(),
dataHandlers: new Set(),
};
this.channels.set(channel, channelHandlers);
}
channelHandlers.messageHandlers.add(handler);
// 返回取消注册函数
return () => {
channelHandlers!.messageHandlers.delete(handler);
// 如果通道没有处理器了,删除通道
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
this.channels.delete(channel);
}
};
}
registerDataHandler(channel: string, handler: DataHandler): () => void {
let channelHandlers = this.channels.get(channel);
if (!channelHandlers) {
channelHandlers = {
messageHandlers: new Set(),
dataHandlers: new Set(),
};
this.channels.set(channel, channelHandlers);
}
channelHandlers.dataHandlers.add(handler);
// 返回取消注册函数
return () => {
channelHandlers!.dataHandlers.delete(handler);
// 如果通道没有处理器了,删除通道
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
this.channels.delete(channel);
}
};
}
registerDefaultMessageHandler(handler: MessageHandler): () => void {
if (!this.defaultChannelHandlers) {
this.createDefaultChannel();
}
this.defaultChannelHandlers?.messageHandlers.add(handler);
return () => {
this.defaultChannelHandlers?.messageHandlers.delete(handler);
};
}
registerDefaultDataHandler(handler: DataHandler): () => void {
if (!this.defaultChannelHandlers) {
this.createDefaultChannel();
}
this.defaultChannelHandlers?.dataHandlers.add(handler);
return () => {
this.defaultChannelHandlers?.dataHandlers.delete(handler);
};
}
routeMessage(message: WebRTCMessage): void {
const channel = message.channel;
if (channel) {
// 路由到特定通道
const channelHandlers = this.channels.get(channel);
if (channelHandlers && channelHandlers.messageHandlers.size > 0) {
channelHandlers.messageHandlers.forEach(handler => {
try {
handler(message);
} catch (error) {
console.error(`消息处理器错误 (通道: ${channel}):`, error);
}
});
return;
}
}
// 回退到默认处理器
if (this.defaultChannelHandlers?.messageHandlers.size) {
this.defaultChannelHandlers.messageHandlers.forEach(handler => {
try {
handler(message);
} catch (error) {
console.error('默认消息处理器错误:', error);
}
});
} else {
console.warn('没有找到消息处理器:', message.type, channel || 'default');
}
}
routeData(data: ArrayBuffer, channel?: string): void {
if (channel) {
// 路由到特定通道
const channelHandlers = this.channels.get(channel);
if (channelHandlers && channelHandlers.dataHandlers.size > 0) {
channelHandlers.dataHandlers.forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(`数据处理器错误 (通道: ${channel}):`, error);
}
});
return;
}
}
// 回退到默认处理器
if (this.defaultChannelHandlers?.dataHandlers.size) {
this.defaultChannelHandlers.dataHandlers.forEach(handler => {
try {
handler(data);
} catch (error) {
console.error('默认数据处理器错误:', error);
}
});
} else {
console.warn('没有找到数据处理器,数据大小:', data.byteLength, 'bytes');
}
}
hasHandlers(channel?: string): boolean {
if (channel) {
const channelHandlers = this.channels.get(channel);
return channelHandlers ?
(channelHandlers.messageHandlers.size > 0 || channelHandlers.dataHandlers.size > 0) :
false;
}
return this.defaultChannelHandlers ?
(this.defaultChannelHandlers.messageHandlers.size > 0 || this.defaultChannelHandlers.dataHandlers.size > 0) :
false;
}
getChannelList(): string[] {
return Array.from(this.channels.keys());
}
getHandlerCount(channel?: string): { message: number; data: number } {
if (channel) {
const channelHandlers = this.channels.get(channel);
return channelHandlers ? {
message: channelHandlers.messageHandlers.size,
data: channelHandlers.dataHandlers.size,
} : { message: 0, data: 0 };
}
return this.defaultChannelHandlers ? {
message: this.defaultChannelHandlers.messageHandlers.size,
data: this.defaultChannelHandlers.dataHandlers.size,
} : { message: 0, data: 0 };
}
clear(): void {
this.channels.clear();
this.createDefaultChannel();
}
clearChannel(channel: string): void {
this.channels.delete(channel);
}
}

View File

@@ -0,0 +1,293 @@
import { EventEmitter } from 'events';
import { WebRTCError, WebRTCConfig, ConnectionEvent, EventHandler } from './types';
interface PeerConnectionManagerConfig extends WebRTCConfig {
onSignalingMessage: (message: any) => void;
onTrack?: (event: RTCTrackEvent) => void;
}
interface NegotiationOptions {
offerToReceiveAudio?: boolean;
offerToReceiveVideo?: boolean;
}
export class PeerConnectionManager extends EventEmitter {
private pc: RTCPeerConnection | null = null;
private config: PeerConnectionManagerConfig;
private isNegotiating = false;
private negotiationQueue: Array<() => Promise<void>> = [];
private localCandidates: RTCIceCandidate[] = [];
private remoteCandidates: RTCIceCandidate[] = [];
constructor(config: PeerConnectionManagerConfig) {
super();
this.config = config;
}
async createPeerConnection(): Promise<RTCPeerConnection> {
if (this.pc) {
this.destroyPeerConnection();
}
try {
this.pc = new RTCPeerConnection({
iceServers: this.config.iceServers,
iceCandidatePoolSize: this.config.iceCandidatePoolSize,
});
this.setupEventHandlers();
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
return this.pc;
} catch (error) {
throw new WebRTCError('PC_CREATE_FAILED', '创建PeerConnection失败', true, error as Error);
}
}
private setupEventHandlers(): void {
if (!this.pc) return;
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.localCandidates.push(event.candidate);
this.config.onSignalingMessage({
type: 'ice-candidate',
payload: event.candidate
});
} else {
console.log('[PeerConnection] ICE收集完成');
}
};
this.pc.oniceconnectionstatechange = () => {
console.log('[PeerConnection] ICE连接状态:', this.pc!.iceConnectionState);
switch (this.pc!.iceConnectionState) {
case 'connected':
case 'completed':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
break;
case 'failed':
this.emit('error', {
type: 'error',
error: new WebRTCError('ICE_FAILED', 'ICE连接失败', true)
});
break;
case 'disconnected':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
break;
}
};
this.pc.onconnectionstatechange = () => {
console.log('[PeerConnection] 连接状态:', this.pc!.connectionState);
switch (this.pc!.connectionState) {
case 'connected':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
break;
case 'failed':
this.emit('error', {
type: 'error',
error: new WebRTCError('CONNECTION_FAILED', 'WebRTC连接失败', true)
});
break;
case 'disconnected':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
break;
}
};
this.pc.ontrack = (event) => {
console.log('[PeerConnection] 收到轨道:', event.track.kind);
if (this.config.onTrack) {
this.config.onTrack(event);
}
};
this.pc.onsignalingstatechange = () => {
console.log('[PeerConnection] 信令状态:', this.pc!.signalingState);
if (this.pc!.signalingState === 'stable') {
this.isNegotiating = false;
this.processNegotiationQueue();
}
};
}
async createOffer(options: NegotiationOptions = {}): Promise<RTCSessionDescriptionInit> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
const offerOptions: RTCOfferOptions = {
offerToReceiveAudio: options.offerToReceiveAudio ?? true,
offerToReceiveVideo: options.offerToReceiveVideo ?? true,
};
const offer = await this.pc.createOffer(offerOptions);
await this.pc.setLocalDescription(offer);
return offer;
} catch (error) {
throw new WebRTCError('OFFER_FAILED', '创建Offer失败', true, error as Error);
}
}
async createAnswer(): Promise<RTCSessionDescriptionInit> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
return answer;
} catch (error) {
throw new WebRTCError('ANSWER_FAILED', '创建Answer失败', true, error as Error);
}
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
await this.pc.setRemoteDescription(description);
// 添加缓存的远程候选
for (const candidate of this.remoteCandidates) {
await this.pc.addIceCandidate(candidate);
}
this.remoteCandidates = [];
} catch (error) {
throw new WebRTCError('REMOTE_DESC_FAILED', '设置远程描述失败', false, error as Error);
}
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (!this.pc) {
this.remoteCandidates.push(new RTCIceCandidate(candidate));
return;
}
try {
await this.pc.addIceCandidate(candidate);
} catch (error) {
console.warn('添加ICE候选失败:', error);
}
}
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
return this.pc.addTrack(track, stream);
} catch (error) {
throw new WebRTCError('ADD_TRACK_FAILED', '添加轨道失败', false, error as Error);
}
}
removeTrack(sender: RTCRtpSender): void {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
this.pc.removeTrack(sender);
} catch (error) {
throw new WebRTCError('REMOVE_TRACK_FAILED', '移除轨道失败', false, error as Error);
}
}
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
return this.pc.createDataChannel(label, options);
}
async renegotiate(options: NegotiationOptions = {}): Promise<void> {
if (!this.pc || this.isNegotiating) {
this.negotiationQueue.push(() => this.doRenegotiate(options));
return;
}
await this.doRenegotiate(options);
}
private async doRenegotiate(options: NegotiationOptions): Promise<void> {
if (!this.pc || this.isNegotiating) return;
this.isNegotiating = true;
try {
const offer = await this.createOffer(options);
this.config.onSignalingMessage({
type: 'offer',
payload: offer
});
} catch (error) {
this.isNegotiating = false;
throw error;
}
}
private processNegotiationQueue(): void {
if (this.negotiationQueue.length === 0) return;
const nextNegotiation = this.negotiationQueue.shift();
if (nextNegotiation) {
nextNegotiation().catch(error => {
console.error('处理协商队列失败:', error);
});
}
}
getStats(): Promise<RTCStatsReport> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
return this.pc.getStats();
}
getConnectionState(): RTCPeerConnectionState {
return this.pc?.connectionState || 'closed';
}
getIceConnectionState(): RTCIceConnectionState {
return this.pc?.iceConnectionState || 'closed';
}
getSignalingState(): RTCSignalingState {
return this.pc?.signalingState || 'stable';
}
destroyPeerConnection(): void {
if (this.pc) {
this.pc.close();
this.pc = null;
}
this.isNegotiating = false;
this.negotiationQueue = [];
this.localCandidates = [];
this.remoteCandidates = [];
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
}
on(event: string, handler: EventHandler<ConnectionEvent>): this {
return super.on(event, handler);
}
emit(eventName: string, event?: ConnectionEvent): boolean {
return super.emit(eventName, event);
}
}

View File

@@ -0,0 +1,455 @@
import { EventEmitter } from 'events';
import { WebSocketManager } from './WebSocketManager';
import { PeerConnectionManager } from './PeerConnectionManager';
import { DataChannelManager } from './DataChannelManager';
import { MessageRouter } from './MessageRouter';
import {
WebRTCConnectionState,
WebRTCMessage,
WebRTCConfig,
WebRTCError,
MessageHandler,
DataHandler
} from './types';
import { getWsUrl } from '@/lib/config';
interface WebRTCManagerConfig extends Partial<WebRTCConfig> {
dataChannelName?: string;
enableLogging?: boolean;
}
interface SignalingMessage {
type: string;
payload: any;
}
export class WebRTCManager extends EventEmitter {
private wsManager: WebSocketManager;
private pcManager: PeerConnectionManager;
private dcManager: DataChannelManager;
private messageRouter: MessageRouter;
private config: WebRTCManagerConfig;
private state: WebRTCConnectionState;
private currentRoom: { code: string; role: 'sender' | 'receiver' } | null = null;
private isUserDisconnecting = false;
private abortController = new AbortController();
constructor(config: WebRTCManagerConfig = {}) {
super();
this.config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:global.stun.twilio.com:3478' },
],
iceCandidatePoolSize: 10,
chunkSize: 256 * 1024,
maxRetries: 5,
retryDelay: 1000,
ackTimeout: 5000,
dataChannelName: 'shared-channel',
enableLogging: true,
...config
};
this.state = {
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false,
currentRoom: null,
};
// 初始化各个管理器
this.wsManager = new WebSocketManager({
url: '',
reconnectAttempts: 5,
reconnectDelay: 1000,
timeout: 10000,
});
this.pcManager = new PeerConnectionManager({
iceServers: this.config.iceServers!,
iceCandidatePoolSize: this.config.iceCandidatePoolSize!,
chunkSize: this.config.chunkSize!,
maxRetries: this.config.maxRetries!,
retryDelay: this.config.retryDelay!,
ackTimeout: this.config.ackTimeout!,
onSignalingMessage: this.handleSignalingMessage.bind(this),
onTrack: this.handleTrack.bind(this),
});
this.dcManager = new DataChannelManager({
channelName: this.config.dataChannelName!,
onMessage: this.handleDataChannelMessage.bind(this),
onData: this.handleDataChannelData.bind(this),
});
this.messageRouter = new MessageRouter();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
// WebSocket 事件处理
this.wsManager.on('connecting', (event) => {
this.updateState({ isConnecting: true, error: null });
});
this.wsManager.on('connected', (event) => {
this.updateState({
isWebSocketConnected: true,
isConnecting: false,
isConnected: true
});
});
this.wsManager.on('disconnected', (event: any) => {
this.updateState({ isWebSocketConnected: false });
if (!this.isUserDisconnecting) {
this.updateState({
error: event.reason,
canRetry: true
});
}
});
this.wsManager.on('error', (event: any) => {
this.updateState({
error: event.error.message,
canRetry: event.error.retryable
});
});
this.wsManager.on('message', (message: any) => {
this.handleWebSocketMessage(message);
});
// PeerConnection 事件处理
this.pcManager.on('state-change', (event: any) => {
this.updateState(event.state);
});
this.pcManager.on('error', (event: any) => {
this.updateState({
error: event.error.message,
canRetry: event.error.retryable
});
});
// DataChannel 事件处理
this.dcManager.on('state-change', (event: any) => {
this.updateState(event.state);
});
this.dcManager.on('error', (event: any) => {
this.updateState({
error: event.error.message,
canRetry: event.error.retryable
});
});
}
private updateState(updates: Partial<WebRTCConnectionState>): void {
this.state = { ...this.state, ...updates };
this.emit('state-change', { type: 'state-change', state: updates });
}
private handleWebSocketMessage(message: SignalingMessage): void {
if (this.config.enableLogging) {
console.log('[WebRTCManager] 收到信令消息:', message.type);
}
switch (message.type) {
case 'peer-joined':
this.handlePeerJoined(message.payload);
break;
case 'offer':
this.handleOffer(message.payload);
break;
case 'answer':
this.handleAnswer(message.payload);
break;
case 'ice-candidate':
this.handleIceCandidate(message.payload);
break;
case 'error':
this.handleError(message);
break;
case 'disconnection':
this.handleDisconnection(message);
break;
default:
if (this.config.enableLogging) {
console.warn('[WebRTCManager] 未知消息类型:', message.type);
}
}
}
private handleSignalingMessage(message: SignalingMessage): void {
this.wsManager.send(message);
}
private handleTrack(event: RTCTrackEvent): void {
if (this.config.enableLogging) {
console.log('[WebRTCManager] 收到媒体轨道:', event.track.kind);
}
// 这里可以添加轨道处理逻辑,或者通过事件传递给业务层
}
private handleDataChannelMessage(message: WebRTCMessage): void {
this.messageRouter.routeMessage(message);
}
private handleDataChannelData(data: ArrayBuffer): void {
// 默认路由到文件传输通道
this.messageRouter.routeData(data, 'file-transfer');
}
private async handlePeerJoined(payload: any): Promise<void> {
if (!this.currentRoom) return;
const { role } = payload;
const { role: currentRole } = this.currentRoom;
if (this.config.enableLogging) {
console.log('[WebRTCManager] 对方加入房间:', role);
}
if (currentRole === 'sender' && role === 'receiver') {
this.updateState({ isPeerConnected: true });
try {
await this.pcManager.createOffer();
} catch (error) {
console.error('[WebRTCManager] 创建Offer失败:', error);
}
} else if (currentRole === 'receiver' && role === 'sender') {
this.updateState({ isPeerConnected: true });
}
}
private async handleOffer(payload: RTCSessionDescriptionInit): Promise<void> {
try {
await this.pcManager.setRemoteDescription(payload);
const answer = await this.pcManager.createAnswer();
this.handleSignalingMessage({ type: 'answer', payload: answer });
} catch (error) {
console.error('[WebRTCManager] 处理Offer失败:', error);
this.updateState({
error: '处理连接请求失败',
canRetry: true
});
}
}
private async handleAnswer(payload: RTCSessionDescriptionInit): Promise<void> {
try {
await this.pcManager.setRemoteDescription(payload);
} catch (error) {
console.error('[WebRTCManager] 处理Answer失败:', error);
this.updateState({
error: '处理连接响应失败',
canRetry: true
});
}
}
private async handleIceCandidate(payload: RTCIceCandidateInit): Promise<void> {
try {
await this.pcManager.addIceCandidate(payload);
} catch (error) {
console.warn('[WebRTCManager] 添加ICE候选失败:', error);
}
}
private handleError(message: any): void {
this.updateState({
error: message.error || '信令服务器错误',
canRetry: true
});
}
private handleDisconnection(message: any): void {
this.updateState({
isPeerConnected: false,
error: '对方已离开房间',
canRetry: true
});
// 清理P2P连接但保持WebSocket连接
this.pcManager.destroyPeerConnection();
this.dcManager.close();
}
async connect(roomCode: string, role: 'sender' | 'receiver'): Promise<void> {
if (this.state.isConnecting) {
console.warn('[WebRTCManager] 正在连接中,跳过重复连接请求');
return;
}
this.isUserDisconnecting = false;
this.abortController = new AbortController();
try {
this.currentRoom = { code: roomCode, role };
this.updateState({
isConnecting: true,
error: null,
currentRoom: { code: roomCode, role }
});
// 创建PeerConnection
const pc = await this.pcManager.createPeerConnection();
// 如果是发送方,创建数据通道
if (role === 'sender') {
this.dcManager.createDataChannel(pc);
}
// 连接WebSocket
const baseWsUrl = getWsUrl();
if (!baseWsUrl) {
throw new WebRTCError('WS_URL_NOT_CONFIGURED', 'WebSocket URL未配置', false);
}
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
this.wsManager = new WebSocketManager({
url: wsUrl,
reconnectAttempts: 5,
reconnectDelay: 1000,
timeout: 10000,
});
this.setupEventHandlers();
await this.wsManager.connect();
} catch (error) {
console.error('[WebRTCManager] 连接失败:', error);
this.updateState({
error: error instanceof WebRTCError ? error.message : '连接失败',
isConnecting: false,
canRetry: true
});
throw error;
}
}
disconnect(): void {
if (this.config.enableLogging) {
console.log('[WebRTCManager] 主动断开连接');
}
this.isUserDisconnecting = true;
this.abortController.abort();
// 通知对方断开连接
this.wsManager.send({
type: 'disconnection',
payload: { reason: '用户主动断开' }
});
// 清理所有连接
this.dcManager.close();
this.pcManager.destroyPeerConnection();
this.wsManager.disconnect();
this.messageRouter.clear();
// 重置状态
this.currentRoom = null;
this.updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false,
currentRoom: null,
});
}
async retry(): Promise<void> {
if (!this.currentRoom) {
throw new WebRTCError('NO_ROOM_INFO', '没有房间信息,无法重试', false);
}
if (this.config.enableLogging) {
console.log('[WebRTCManager] 重试连接:', this.currentRoom);
}
this.disconnect();
await this.connect(this.currentRoom.code, this.currentRoom.role);
}
sendMessage(message: WebRTCMessage, channel?: string): boolean {
const messageWithChannel = channel ? { ...message, channel } : message;
return this.dcManager.sendMessage(messageWithChannel);
}
sendData(data: ArrayBuffer): boolean {
return this.dcManager.sendData(data);
}
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
return this.messageRouter.registerMessageHandler(channel, handler);
}
registerDataHandler(channel: string, handler: DataHandler): () => void {
return this.messageRouter.registerDataHandler(channel, handler);
}
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
return this.pcManager.addTrack(track, stream);
}
removeTrack(sender: RTCRtpSender): void {
this.pcManager.removeTrack(sender);
}
onTrack(handler: (event: RTCTrackEvent) => void): void {
// 简化实现,直接设置处理器
this.pcManager.on('track', (event) => {
if (event.type === 'state-change' && 'onTrack' in this.config) {
// 这里需要适配事件类型
}
});
}
getPeerConnection(): RTCPeerConnection | null {
// 返回内部 PeerConnection 的引用
return (this.pcManager as any).pc;
}
async createOfferNow(): Promise<boolean> {
try {
await this.pcManager.createOffer();
return true;
} catch (error) {
console.error('[WebRTCManager] 创建Offer失败:', error);
return false;
}
}
getChannelState(): RTCDataChannelState {
return this.dcManager.getState();
}
isConnectedToRoom(roomCode: string, role: 'sender' | 'receiver'): boolean {
return this.currentRoom?.code === roomCode &&
this.currentRoom?.role === role &&
this.state.isConnected;
}
getState(): WebRTCConnectionState {
return { ...this.state };
}
getConfig(): WebRTCManagerConfig {
return { ...this.config };
}
}

View File

@@ -0,0 +1,218 @@
import { EventEmitter } from 'events';
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler } from './types';
interface WebSocketManagerConfig {
url: string;
reconnectAttempts?: number;
reconnectDelay?: number;
timeout?: number;
}
interface WebSocketMessage {
type: string;
payload: any;
}
export class WebSocketManager extends EventEmitter {
private ws: WebSocket | null = null;
private config: WebSocketManagerConfig;
private reconnectCount = 0;
private isConnecting = false;
private isUserDisconnecting = false;
private messageQueue: WebSocketMessage[] = [];
private reconnectTimer: NodeJS.Timeout | null = null;
constructor(config: WebSocketManagerConfig) {
super();
this.config = {
reconnectAttempts: 5,
reconnectDelay: 1000,
timeout: 10000,
...config
};
}
async connect(): Promise<void> {
if (this.isConnecting || this.isConnected()) {
return;
}
this.isConnecting = true;
this.isUserDisconnecting = false;
this.emit('connecting', { type: 'connecting' });
try {
const ws = new WebSocket(this.config.url);
// 设置超时
const timeout = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
ws.close();
throw new WebRTCError('WS_TIMEOUT', 'WebSocket连接超时', true);
}
}, this.config.timeout);
await new Promise<void>((resolve, reject) => {
ws.onopen = () => {
clearTimeout(timeout);
this.isConnecting = false;
this.reconnectCount = 0;
this.ws = ws;
this.setupEventHandlers();
this.flushMessageQueue();
this.emit('connected', { type: 'connected' });
resolve();
};
ws.onerror = (errorEvent) => {
clearTimeout(timeout);
this.isConnecting = false;
const wsError = new WebRTCError('WS_ERROR', 'WebSocket连接错误', true, new Error('WebSocket连接错误'));
this.emit('error', { type: 'error', error: wsError });
reject(wsError);
};
ws.onclose = (closeEvent) => {
clearTimeout(timeout);
this.isConnecting = false;
this.ws = null;
if (!this.isUserDisconnecting) {
this.handleReconnect();
}
this.emit('disconnected', {
type: 'disconnected',
reason: `WebSocket关闭: ${closeEvent.code} - ${closeEvent.reason}`
});
};
});
} catch (error) {
this.isConnecting = false;
if (error instanceof WebRTCError) {
this.emit('error', { type: 'error', error });
throw error;
}
throw new WebRTCError('WS_CONNECTION_FAILED', 'WebSocket连接失败', true, error as Error);
}
}
private setupEventHandlers(): void {
if (!this.ws) return;
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.emit('message', message);
} catch (error) {
console.error('解析WebSocket消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
});
}
};
this.ws.onerror = (errorEvent) => {
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_ERROR', 'WebSocket错误', true, new Error('WebSocket错误'))
});
};
this.ws.onclose = (event) => {
this.ws = null;
if (!this.isUserDisconnecting) {
this.handleReconnect();
}
this.emit('disconnected', {
type: 'disconnected',
reason: `WebSocket关闭: ${event.code} - ${event.reason}`
});
};
}
private handleReconnect(): void {
if (this.reconnectCount >= this.config.reconnectAttempts!) {
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_RECONNECT_FAILED', '重连失败,已达最大重试次数', false)
});
return;
}
const delay = this.config.reconnectDelay! * Math.pow(2, this.reconnectCount);
this.reconnectCount++;
console.log(`WebSocket重连中... (${this.reconnectCount}/${this.config.reconnectAttempts}),延迟: ${delay}ms`);
this.reconnectTimer = setTimeout(() => {
this.emit('retry', { type: 'retry' });
this.connect().catch(error => {
console.error('WebSocket重连失败:', error);
});
}, delay);
}
send(message: WebSocketMessage): boolean {
if (!this.isConnected()) {
this.messageQueue.push(message);
return false;
}
try {
this.ws!.send(JSON.stringify(message));
return true;
} catch (error) {
console.error('发送WebSocket消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_SEND_ERROR', '发送消息失败', true, error as Error)
});
return false;
}
}
private flushMessageQueue(): void {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (message) {
this.send(message);
}
}
}
disconnect(): void {
this.isUserDisconnecting = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close(1000, '用户主动断开');
this.ws = null;
}
this.messageQueue = [];
this.isConnecting = false;
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
isConnectingState(): boolean {
return this.isConnecting;
}
on(event: string, handler: EventHandler<ConnectionEvent>): this {
return super.on(event, handler);
}
emit(eventName: string, event?: ConnectionEvent): boolean {
return super.emit(eventName, event);
}
}

View File

@@ -0,0 +1,7 @@
// WebRTC核心功能
export * from './DataChannelManager';
export * from './MessageRouter';
export * from './PeerConnectionManager';
export * from './WebRTCManager';
export * from './WebSocketManager';
export * from './types';

View File

@@ -0,0 +1,58 @@
// WebRTC 核心类型定义
// 基础连接状态
export interface WebRTCConnectionState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
canRetry: boolean;
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
}
// 消息类型
export interface WebRTCMessage<T = any> {
type: string;
payload: T;
channel?: string;
}
// 消息处理器类型
export type MessageHandler = (message: WebRTCMessage) => void;
export type DataHandler = (data: ArrayBuffer) => void;
// WebRTC 配置
export interface WebRTCConfig {
iceServers: RTCIceServer[];
iceCandidatePoolSize: number;
chunkSize: number;
maxRetries: number;
retryDelay: number;
ackTimeout: number;
}
// 错误类型
export class WebRTCError extends Error {
constructor(
public code: string,
message: string,
public retryable: boolean = false,
public cause?: Error
) {
super(message);
this.name = 'WebRTCError';
}
}
// 连接事件
export type ConnectionEvent =
| { type: 'connecting' }
| { type: 'connected' }
| { type: 'disconnected'; reason?: string }
| { type: 'error'; error: WebRTCError }
| { type: 'retry' }
| { type: 'state-change'; state: Partial<WebRTCConnectionState> };
// 事件处理器
export type EventHandler<T extends ConnectionEvent> = (event: T) => void;

View File

@@ -11,23 +11,6 @@ interface ApiResponse {
data?: unknown;
}
interface CreateRoomData {
type?: string;
content?: string;
password?: string;
}
interface CreateTextRoomData {
type: string;
content: string;
password?: string;
}
interface UpdateFilesData {
roomId: string;
files: File[];
}
export class ClientAPI {
private baseUrl: string;
@@ -94,20 +77,10 @@ export class ClientAPI {
}
/**
* 创建房间(统一接口)
* 创建房间(简化版本)- 后端会忽略传入的参数
*/
async createRoom(data: CreateRoomData): Promise<ApiResponse> {
return this.post('/api/create-room', data);
}
/**
* 创建文本房间
*/
async createTextRoom(content: string): Promise<ApiResponse> {
return this.post('/api/create-room', {
type: 'text',
content: content
});
async createRoom(): Promise<ApiResponse> {
return this.post('/api/create-room', {});
}
/**
@@ -140,13 +113,6 @@ export class ClientAPI {
async getWebRTCRoomStatus(code: string): Promise<ApiResponse> {
return this.get(`/api/webrtc-room-status?code=${code}`);
}
/**
* 更新文件
*/
async updateFiles(data: UpdateFilesData): Promise<ApiResponse> {
return this.post('/api/update-files', data);
}
}
// 导出单例实例

View File

@@ -0,0 +1,161 @@
/**
* WebRTC 浏览器支持检测工具
*/
export interface WebRTCSupport {
isSupported: boolean;
missing: string[];
details: {
rtcPeerConnection: boolean;
dataChannel: boolean;
};
}
/**
* 检测浏览器是否支持WebRTC及其相关功能
*/
export function detectWebRTCSupport(): WebRTCSupport {
const missing: string[] = [];
const details = {
rtcPeerConnection: false,
getUserMedia: false,
getDisplayMedia: false,
dataChannel: false,
};
// 检测 RTCPeerConnection
if (typeof RTCPeerConnection !== 'undefined') {
details.rtcPeerConnection = true;
} else {
missing.push('RTCPeerConnection');
}
// 检测 DataChannel 支持
try {
if (typeof RTCPeerConnection !== 'undefined') {
const pc = new RTCPeerConnection();
const dc = pc.createDataChannel('test');
if (dc) {
details.dataChannel = true;
}
pc.close();
}
} catch (error) {
missing.push('DataChannel');
}
const isSupported = missing.length === 0;
return {
isSupported,
missing,
details,
};
}
/**
* 获取浏览器信息
*/
export function getBrowserInfo(): {
name: string;
version: string;
isSupported: boolean;
recommendations?: string[];
} {
const userAgent = navigator.userAgent;
let browserName = 'Unknown';
let version = 'Unknown';
let isSupported = true;
const recommendations: string[] = [];
// Chrome
if (/Chrome/.test(userAgent) && !/Edg/.test(userAgent)) {
browserName = 'Chrome';
const match = userAgent.match(/Chrome\/(\d+)/);
version = match ? match[1] : 'Unknown';
isSupported = parseInt(version) >= 23;
if (!isSupported) {
recommendations.push('请升级到 Chrome 23 或更新版本');
}
}
// Firefox
else if (/Firefox/.test(userAgent)) {
browserName = 'Firefox';
const match = userAgent.match(/Firefox\/(\d+)/);
version = match ? match[1] : 'Unknown';
isSupported = parseInt(version) >= 22;
if (!isSupported) {
recommendations.push('请升级到 Firefox 22 或更新版本');
}
}
// Safari
else if (/Safari/.test(userAgent) && !/Chrome/.test(userAgent)) {
browserName = 'Safari';
const match = userAgent.match(/Version\/(\d+)/);
version = match ? match[1] : 'Unknown';
isSupported = parseInt(version) >= 11;
if (!isSupported) {
recommendations.push('请升级到 Safari 11 或更新版本');
}
}
// Edge
else if (/Edg/.test(userAgent)) {
browserName = 'Edge';
const match = userAgent.match(/Edg\/(\d+)/);
version = match ? match[1] : 'Unknown';
isSupported = parseInt(version) >= 12;
if (!isSupported) {
recommendations.push('请升级到 Edge 12 或更新版本');
}
}
// Internet Explorer
else if (/MSIE|Trident/.test(userAgent)) {
browserName = 'Internet Explorer';
isSupported = false;
recommendations.push(
'请使用现代浏览器,如 Chrome、Firefox、Safari 或 Edge',
'Internet Explorer 不支持 WebRTC'
);
}
return {
name: browserName,
version,
isSupported,
recommendations: recommendations.length > 0 ? recommendations : undefined,
};
}
/**
* 获取推荐的浏览器列表
*/
export function getRecommendedBrowsers(): Array<{
name: string;
minVersion: string;
downloadUrl: string;
}> {
return [
{
name: 'Google Chrome',
minVersion: '23+',
downloadUrl: 'https://www.google.com/chrome/',
},
{
name: 'Mozilla Firefox',
minVersion: '22+',
downloadUrl: 'https://www.mozilla.org/firefox/',
},
{
name: 'Safari',
minVersion: '11+',
downloadUrl: 'https://www.apple.com/safari/',
},
{
name: 'Microsoft Edge',
minVersion: '12+',
downloadUrl: 'https://www.microsoft.com/edge',
},
];
}

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: '3.8'
services:
file-transfer-go:
build: .
ports:
- "8080:8080"
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# 可选:使用已发布的镜像
# file-transfer-go:
# image: matrixseven/file-transfer:latest
# ports:
# - "8080:8080"
# restart: unless-stopped

163
docker-release.sh Executable file
View File

@@ -0,0 +1,163 @@
#!/bin/bash
# ==============================================
# Docker 发布脚本
# 支持单架构和多架构构建
# ==============================================
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
NC='\033[0m'
# 配置
DOCKER_HUB_USER=${DOCKER_HUB_USER:-"matrixseven"} # 替换为你的 Docker Hub 用户名
REPO_NAME="file-transfer-go"
IMAGE_NAME="${DOCKER_HUB_USER}/${REPO_NAME}"
VERSION="v1.0.5"
print_header() {
echo -e "${PURPLE}========================================${NC}"
echo -e "${PURPLE}🐳 $1${NC}"
echo -e "${PURPLE}========================================${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
# 检查 Docker 是否支持多架构构建
check_multiarch_support() {
if command -v docker buildx >/dev/null 2>&1; then
echo "true"
else
echo "false"
fi
}
# 登录 Docker Hub
docker_login() {
print_info "登录 Docker Hub..."
if ! docker info | grep -q "Username: ${DOCKER_HUB_USER}"; then
echo -e "${YELLOW}请输入 Docker Hub 登录信息:${NC}"
docker login
else
print_success "已登录 Docker Hub"
fi
}
# 推送镜像到 Docker Hub
push_to_dockerhub() {
print_info "推送镜像到 Docker Hub..."
docker push "${IMAGE_NAME}:${VERSION}"
docker push "${IMAGE_NAME}:latest"
print_success "镜像推送完成"
}
# 单架构构建(当前方法)
build_single_arch() {
print_header "单架构 Docker 镜像构建"
print_info "构建镜像: ${IMAGE_NAME}:${VERSION}"
docker build -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" .
print_success "单架构镜像构建完成"
docker images "${IMAGE_NAME}"
}
# 多架构构建(需要 buildx
build_multiarch() {
print_header "多架构 Docker 镜像构建"
print_info "创建 buildx builder"
docker buildx create --name multiarch --use 2>/dev/null || true
docker buildx inspect --bootstrap
print_info "构建多架构镜像: linux/amd64,linux/arm64"
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t "${IMAGE_NAME}:${VERSION}" \
-t "${IMAGE_NAME}:latest" \
--push \
.
print_success "多架构镜像构建并推送完成"
}
# 显示使用说明
show_usage() {
print_header "Docker 镜像使用说明"
echo -e "${GREEN}🚀 运行镜像:${NC}"
echo " docker run -d -p 8080:8080 ${IMAGE_NAME}:${VERSION}"
echo ""
echo -e "${GREEN}📦 镜像信息:${NC}"
echo " - Docker Hub: https://hub.docker.com/r/${DOCKER_HUB_USER}/${REPO_NAME}"
echo " - 版本: ${VERSION}"
echo " - 大小: ~16MB"
echo " - 架构: $(check_multiarch_support && echo "amd64, arm64" || echo "amd64")"
echo " - 基础镜像: alpine:3.18"
echo ""
echo -e "${GREEN}🌟 特性:${NC}"
echo " ✅ 静态编译,无外部依赖"
echo " ✅ 前端文件完全嵌入"
echo " ✅ 多平台文件传输支持"
echo " ✅ WebRTC P2P 连接"
echo " ✅ 桌面共享功能"
echo ""
}
# 主函数
main() {
# 登录 Docker Hub
docker_login
case "${1:-single}" in
"multi")
if [ "$(check_multiarch_support)" = "true" ]; then
build_multiarch # 多架构构建会自动推送
else
echo -e "${RED}❌ Docker buildx 不可用,回退到单架构构建${NC}"
build_single_arch
push_to_dockerhub
fi
;;
"single"|*)
build_single_arch
push_to_dockerhub
;;
esac
show_usage
}
# 检查参数
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "用法: $0 [single|multi]"
echo ""
echo " single 构建单架构镜像并推送到 Docker Hub (默认amd64)"
echo " multi 构建多架构镜像并推送到 Docker Hub (amd64, arm64)"
echo ""
echo "环境变量:"
echo " DOCKER_HUB_USER Docker Hub 用户名 (默认: matrixseven)"
echo ""
echo "示例:"
echo " $0 single # 单架构构建"
echo " $0 multi # 多架构构建"
echo " DOCKER_HUB_USER=yourname $0 single # 指定用户名"
echo ""
exit 0
fi
main "$@"

View File

@@ -23,7 +23,7 @@ func (h *Handler) HandleWebRTCWebSocket(w http.ResponseWriter, r *http.Request)
h.webrtcService.HandleWebSocket(w, r)
}
// CreateRoomHandler 创建房间API
// CreateRoomHandler 创建房间API - 简化版本,不处理无用参数
func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
// 设置响应为JSON格式
w.Header().Set("Content-Type", "application/json")
@@ -36,7 +36,7 @@ func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
return
}
// 创建新房间
// 创建新房间(忽略请求体中的无用参数)
code := h.webrtcService.CreateNewRoom()
log.Printf("创建房间成功: %s", code)

View File

@@ -76,6 +76,34 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if code == "" || (role != "sender" && role != "receiver") {
log.Printf("WebRTC连接参数无效: code=%s, role=%s", code, role)
conn.WriteJSON(map[string]interface{}{
"type": "error",
"message": "连接参数无效",
})
return
}
// 验证房间是否存在
ws.roomsMux.RLock()
room := ws.rooms[code]
ws.roomsMux.RUnlock()
if room == nil {
log.Printf("房间不存在: %s", code)
conn.WriteJSON(map[string]interface{}{
"type": "error",
"message": "房间不存在或已过期",
})
return
}
// 检查房间是否已过期
if time.Now().After(room.ExpiresAt) {
log.Printf("房间已过期: %s", code)
conn.WriteJSON(map[string]interface{}{
"type": "error",
"message": "房间已过期",
})
return
}
@@ -127,13 +155,8 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
room := ws.rooms[code]
if room == nil {
room = &WebRTCRoom{
Code: code,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour), // 1小时后过期
}
ws.rooms[code] = room
log.Printf("自动创建WebRTC房间: %s", code)
log.Printf("尝试加入不存在的WebRTC房间: %s", code)
return
}
if client.Role == "sender" {
@@ -253,19 +276,39 @@ func (ws *WebRTCService) CreateRoom(code string) {
}
}
// CreateNewRoom 创建新房间并返回房间码
// CreateNewRoom 创建新房间并返回房间码 - 确保不重复
func (ws *WebRTCService) CreateNewRoom() string {
code := ws.generatePickupCode()
var code string
// 生成唯一房间码,确保不重复
for {
code = ws.generatePickupCode()
ws.roomsMux.RLock()
_, exists := ws.rooms[code]
ws.roomsMux.RUnlock()
if !exists {
break // 找到了不重复的代码
}
// 如果重复了,继续生成新的
}
ws.CreateRoom(code)
return code
}
// generatePickupCode 生成6位取件码
// generatePickupCode 生成6位取件码 - 统一规则只使用大写字母和数字排除0和O避免混淆
func (ws *WebRTCService) generatePickupCode() string {
// 只使用大写字母和数字排除容易混淆的字符数字0和字母O
chars := "123456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
source := rand.NewSource(time.Now().UnixNano())
rng := rand.New(source)
code := rng.Intn(900000) + 100000
return fmt.Sprintf("%d", code)
result := make([]byte, 6)
for i := 0; i < 6; i++ {
result[i] = chars[rng.Intn(len(chars))]
}
return string(result)
}
// cleanupExpiredRooms 定期清理过期房间