34 Commits
v1.0.3 ... main

Author SHA1 Message Date
MatrixSeven
b7485059cf feat: 更新README文档 2025-11-24 18:06:43 +08:00
MatrixSeven
04d4af5ef1 feat: 共享桌面支持开启语音会话,增加实用性 2025-11-24 18:05:15 +08:00
MatrixSeven
08f9d50e66 feat:桌面共享UI/逻辑优化 2025-09-18 18:43:54 +08:00
MatrixSeven
2fc478e889 feat: ws实现 2025-09-16 16:41:38 +08:00
MatrixSeven
15d23de5a7 feat: 优化文件传输逻辑,添加断开连接回调和清除发送方数据功能
- 使用更宽松的条件检查连接状态,确保文件列表和文件请求的发送时机
- 添加清除发送方数据的逻辑,当接收方离开房间时重置文件传输状态
- 在文件传输业务中设置断开连接回调,确保在连接断开时清理相关数据
- 更新数据通道和P2P连接状态的处理,增强连接状态的监控和反馈
2025-09-15 19:39:57 +08:00
MatrixSeven
550be8bcc6 feat:降级 2025-09-15 18:28:16 +08:00
MatrixSeven
50d30f23bf feat: 移除WebRTC房间状态API,优化路由设置 2025-09-11 15:55:10 +08:00
MatrixSeven
4b31e76488 feat: 添加文件传输速度和剩余时间计算,优化文件传输进度显示 2025-09-10 16:49:18 +08:00
MatrixSeven
84d7caea8c feat: 添加文件传输服务器配置和路由设置,支持外部前端目录|添加文件传输速度计算 2025-09-10 16:48:21 +08:00
MatrixSeven
343e7f1192 feat: 更新ICE服务器配置,添加默认服务器标记,优化删除提示信息 2025-09-10 15:05:28 +08:00
MatrixSeven
07409abb3b feat:UI更新 2025-09-08 10:45:35 +08:00
MatrixSeven
1e5d74433b feat:自定义turn更新|链接恢复机制|帮助页面添加 2025-09-05 17:12:22 +08:00
MatrixSeven
8e4c42bbbe feat:issue模板添加 2025-09-03 10:46:24 +08:00
MatrixSeven
6d5b4329db feat:issue模板添加 2025-09-03 10:45:04 +08:00
MatrixSeven
dfa225e68e feat: 删除不再使用的构建脚本和模型文件 2025-09-02 16:30:33 +08:00
MatrixSeven
4faf1c3141 feat:共享桌面移动端全屏支持 2025-09-01 15:53:03 +08:00
MatrixSeven
86fd9ec08c feat: 合并effect,优化逻辑 2025-08-29 18:19:59 +08:00
Accelerator
0c33a72c0a feat:docker镜像发布 2025-08-28 12:55:52 +00:00
Accelerator
bbf303711d feat:Update Dockerfile 2025-08-28 20:27:18 +08:00
MatrixSeven
17a44c866d feat:修复github action 2025-08-28 19:49:56 +08:00
MatrixSeven
57c5fa7687 feat:修复github action 2025-08-28 19:44:47 +08:00
MatrixSeven
109b918953 feat:修复github action 2025-08-28 19:40:24 +08:00
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
MatrixSeven
0caeaf62c4 feat: 更新README.md,优化功能描述和最近更新日志 2025-08-24 16:07:06 +08:00
MatrixSeven
6b69d35a20 feat:处理组件渲染导致重复注册事件文件 2025-08-24 15:41:58 +08:00
MatrixSeven
75825e1104 feat: 文件传输ACK确认机制.保证数据完整. 2025-08-24 15:24:12 +08:00
MatrixSeven
720f808ed6 feat:状态组件同步,UI细节处理 2025-08-15 19:24:55 +08:00
MatrixSeven
2abf7bdf42 feat: UI分离 2025-08-15 14:15:51 +08:00
132 changed files with 13019 additions and 4134 deletions

12
.chuan.env Normal file
View File

@@ -0,0 +1,12 @@
# 文件传输服务器配置
# 主服务器配置
PORT=8080
# FRONTEND_DIR=./dist
# TURN服务器配置
TURN_ENABLED=false
TURN_PORT=3478
TURN_USERNAME=chuan
TURN_PASSWORD=chuan123
TURN_REALM=localhost

23
.chuan.env.example Normal file
View File

@@ -0,0 +1,23 @@
# 文件传输服务器配置文件
# 这个文件会被自动加载,支持 KEY=VALUE 格式
# 服务器端口
PORT=8080
# 外部前端文件目录 (可选)
# 如果设置了这个路径,服务器会使用指定目录的前端文件
# 而不是内嵌在二进制文件中的前端文件
# FRONTEND_DIR=./chuan-next/out
# FRONTEND_DIR=/var/www/chuan-frontend
# 示例: Docker 容器内的路径
# FRONTEND_DIR=/app/frontend
# 示例: 开发环境
# FRONTEND_DIR=./chuan-next/dist
# 注意:
# 1. 环境变量的优先级高于配置文件
# 2. 命令行参数的优先级最高
# 3. 空行和以 # 开头的行会被忽略
# 4. 值可以用单引号或双引号包围

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/

95
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: 🐛 Bug 报告
description: 报告一个错误或问题
title: "[Bug] "
labels: ["bug", "需要调查"]
body:
- type: markdown
attributes:
value: |
感谢您花时间填写错误报告!请详细描述遇到的问题,这将帮助我们更快地定位和解决问题。
- type: textarea
id: problem-description
attributes:
label: 问题描述
description: 请详细描述您遇到的问题
placeholder: |
例如:在文件传输过程中,当传输大文件时连接会意外断开...
validations:
required: true
- type: dropdown
id: deployment-environment
attributes:
label: 部署环境
description: 您使用的是什么部署方式?
options:
- 二进制部署(下载发布的可执行文件)
- 自行构建(从源码编译)
- Docker 部署
- 官方演示站
- 其他(请在详细信息中说明)
validations:
required: true
- type: textarea
id: environment-details
attributes:
label: 环境详细信息
description: 请提供环境相关信息
placeholder: |
- 操作系统Linux Ubuntu 20.04 / Windows 10 / macOS 12.x
- 浏览器Chrome 120.x / Firefox 121.x / Safari 17.x
- 网络环境:局域网 / 公网 / NAT环境
- 设备类型PC / 移动设备
- 其他相关信息...
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: 复现步骤
description: 请描述如何复现这个问题
placeholder: |
1. 打开应用
2. 点击 '文件传输'
3. 选择一个大文件 (>100MB)
4. 点击发送
5. 观察到连接断开...
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: |
请提供相关的错误日志、控制台输出或服务器日志
提示:您可以在浏览器开发者工具的控制台中查看客户端日志
render: text
placeholder: |
[2024-01-15 10:30:45] [ERROR] WebRTC连接失败: ICE连接超时
[2024-01-15 10:30:45] [INFO] 尝试重新连接...
或者粘贴浏览器控制台的错误信息...
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: |
如果适用,请添加截图或录屏来帮助解释您的问题
您可以直接拖拽图片到这个文本框中
placeholder: 拖拽图片文件到这里,或者粘贴图片链接
- type: textarea
id: additional-context
attributes:
label: 其他信息
description: 任何其他可能有助于解决问题的信息
placeholder: |
- 问题发生的频率:每次 / 偶尔 / 特定条件下
- 是否在多个设备上都出现
- 最近是否有环境变化
- 其他可能相关的信息...

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 📚 项目文档
url: https://github.com/MatrixSeven/file-transfer-go/blob/main/README.md
about: 查看项目使用文档和部署指南
- name: 💬 讨论区
url: https://github.com/MatrixSeven/file-transfer-go/discussions
about: 参与社区讨论,分享使用经验和想法
- name: 🌐 官方演示
url: https://transfer.52python.cn/
about: 访问官方演示站点体验功能(如果可用)

View File

@@ -0,0 +1,72 @@
name: ✨ 功能请求
description: 建议一个新功能或改进
title: "[Feature] "
labels: ["enhancement", "功能请求"]
body:
- type: markdown
attributes:
value: |
感谢您提出功能建议!您的想法对改进项目非常重要。请详细描述您的建议。
- type: textarea
id: feature-description
attributes:
label: 建议功能
description: 请清楚简洁地描述您希望实现的功能
placeholder: |
例如:希望添加文件加密传输功能,在传输过程中对文件进行端到端加密...
validations:
required: true
- type: textarea
id: motivation
attributes:
label: 需求原因
description: 请解释为什么需要这个功能,它解决了什么问题?
placeholder: |
例如:
- 当前在传输敏感文件时缺乏安全保障
- 在公网环境下传输文件可能被第三方截获
- 企业用户需要确保数据传输的安全性
validations:
required: true
- type: textarea
id: use-cases
attributes:
label: 使用场景
description: 请描述这个功能的具体使用场景和出发点
placeholder: |
例如:
- 医疗机构传输患者档案时需要加密保护
- 企业内部传输财务报表等敏感文档
- 个人用户传输私人照片和视频时希望保护隐私
- 在不受信任的网络环境下进行文件传输
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 优先级
description: 您认为这个功能的优先级如何?
options:
- Nice to have
- 中(重要但不紧急)
- 高(对用户体验很重要)
- 关键(阻碍正常使用)
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: 其他信息
description: 任何其他可能有助于理解和实现这个功能的信息
placeholder: |
- 类似功能的参考应用或网站
- 相关技术文档或标准
- 社区讨论链接
- 其他补充说明

54
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: 💬 问题咨询
description: 使用问题、配置疑问或一般性讨论
title: "[Question] "
labels: ["question", "需要回复"]
body:
- type: markdown
attributes:
value: |
如果您有使用问题、配置疑问或想要讨论项目相关话题,请使用这个模板。
- type: dropdown
id: question-type
attributes:
label: 问题类型
description: 请选择您的问题类型
options:
- 使用问题(如何使用某个功能)
- 配置问题(部署和设置相关)
- 技术咨询(技术实现相关)
- 功能理解(不确定某个功能如何工作)
- 其他
validations:
required: true
- type: textarea
id: question
attributes:
label: 具体问题
description: 请详细描述您的问题
placeholder: |
例如我想在内网环境下部署这个应用但是不知道如何配置STUN服务器...
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 如果相关,请提供环境信息
placeholder: |
- 操作系统:
- 部署方式:
- 网络环境:
- 浏览器版本:
- type: checkboxes
id: checklist
attributes:
label: 确认事项
options:
- label: 我已经查看了项目文档和README
required: true
- label: 我已经搜索了现有的Issues
required: true

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

@@ -0,0 +1,139 @@
name: 🐳 Build and Push Docker Image (AMD64)
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
# 推送标签时触发
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:
driver-opts: |
network=host
buildkitd-flags: |
--allow-insecure-entitlement=network.host
- 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: 🏗️ Set build platform
id: platforms
run: |
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
- name: 🐳 Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: ${{ steps.platforms.outputs.platforms }}
push: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.push_to_hub == true) }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
provenance: false
sbom: false
- 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**: ${{ steps.platforms.outputs.platforms }}" >> $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'

5
.gitignore vendored
View File

@@ -109,4 +109,7 @@ backup/
./chuan/.next
./internal/web/frontend/*
./file-transfer-server
file-transfer-server
file-transfer-server
./chuan-vue
./chuan-vue/*
chuan-vue

69
Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
# ==============================================
# AMD64 Dockerfile - 基于 build-fullstack.sh 流程
# ==============================================
# 前端构建阶段
FROM node:20-alpine AS frontend-builder
# 安装 yarn
RUN apk add --no-cache yarn
WORKDIR /app/chuan-next
# 复制所有源代码(确保获取最新代码)
COPY chuan-next/ ./
# 清理构建文件(模拟 build-fullstack.sh 的 clean_all 函数)
RUN rm -rf .next out
# 前端依赖和构建
COPY chuan-next/package.json chuan-next/yarn.lock ./
RUN yarn install --frozen-lockfile --network-timeout 300000
# 临时移除 API 目录进行 SSG 构建(模拟 build-fullstack.sh 的 build_frontend 函数)
RUN if [ -d "src/app/api" ]; then mv src/app/api /tmp/api-backup; fi && \
NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL= NEXT_PUBLIC_WS_URL= NEXT_PUBLIC_API_BASE_URL= yarn build && \
if [ -d "/tmp/api-backup" ]; then mv /tmp/api-backup src/app/api; fi
# ==============================================
# Go 构建阶段
FROM golang:1.21-alpine AS go-builder
# 安装构建依赖
RUN apk add --no-cache git ca-certificates tzdata
ENV GOPROXY=https://proxy.golang.org,direct
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
WORKDIR /app
# 先复制所有源代码(确保获取最新代码)
COPY . .
# Go 依赖
COPY go.mod go.sum ./
RUN go mod download
# 拷贝前端构建结果
COPY --from=frontend-builder /app/chuan-next/out ./internal/web/frontend/
# 构建 Go 应用 - AMD64 架构(模拟 build-fullstack.sh 的 build_backend 函数)
RUN go build -ldflags='-s -w -extldflags '-static'' -o server ./cmd
# ==============================================
# 最终镜像
FROM alpine:3.18
RUN apk add --no-cache ca-certificates tzdata && \
adduser -D -s /bin/sh appuser
WORKDIR /app
COPY --from=go-builder --chown=appuser:appuser /app/server ./
USER appuser
EXPOSE 8080
CMD ["./server"]

155
README.md
View File

@@ -1,29 +1,110 @@
# 文件快传 - P2P文件传输工具
### 在线体验 https://transfer.52python.cn
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
## [在线体验](https://transfer.52python.cn) • [关注我](https://x.com/_MatrixSeven) • [帮助文档](https://transfer.52python.cn/help)
![项目演示](img.png)
> 安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用
## ✨ 核心功能
## ✨ 核心功能[端到端数据传输完全基于WebRTC的P2P直连]
<div align="center">
- 📁 **文件传输** - 支持多文件同时传输基于WebRTC的P2P直连
![React](https://img.shields.io/badge/React-18-blue.svg)
![Next.js](https://img.shields.io/badge/Next.js-15-black.svg)
![TypeScript](https://img.shields.io/badge/TypeScript-5-blue.svg)
![Go](https://img.shields.io/badge/Go-1.22-blue.svg)
![WebRTC](https://img.shields.io/badge/WebRTC-green.svg)
![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-3.4-blue.svg)
</div>
- 📁 **文件传输** - 支持多文件同时传输
- 📝 **文字传输** - 快速分享文本内容
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
- 🖥️ **桌面共享** - 实时屏幕共享
- 🔗 **连接状态同步** - 实时连接状态UI同步
- 🔒 **端到端加密** - 数据传输安全,服务器不存储文件
- 📱 **响应式设计** - 完美适配手机、平板、电脑
- 🖥️ **多平台支持** - 支持linux/macos/win 单文件部署
## 🔄 最近更新日志
### 2025-11-24
-**共享桌面** - 共享桌面支持开启语音,提升实用性
### 2025-09-5
-**WEBRTC链接恢复** - 关闭页面后在打开,进行数据链接恢复
-**定义TURN配置** - 支持自定义中继TURN配置
-**优化移动端提示** - 优化各种场景的错误提示
-**帮助文档** - 常见问题说明文档更新
### 2025-09-1
-**移动端桌面全屏** - 优化移动端下UI并解决全屏问题
### 2025-08-28
-**完善Docker部署支持** - 优化Docker配置支持一键部署和多环境配置
-**优化README文档** - 更新项目说明,完善部署指南和技术栈信息
-**改进UI用户体验** - 优化界面细节,完善错误提示和加载状态
-**重构Hooks架构** - 拆分复杂hooks提高代码复用性和可维护性
### 2025-08-24
-**文件传输 ACK 确认支持** - 实现了可靠的数据传输机制,每个数据块都需要接收方确认
-**修复组件渲染后重复注册/解绑 bug** - 解决了 React 组件重复渲染导致的处理器反复注册问题
-**修复进度显示 Infinity% 问题** - 解决了除零错误和进度闪烁问题
### 2025-08-14
-**分离UI组件统一UI状态** - 重构UI架构提高代码复用性和可维护性
-**共享底层链接** - 优化WebRTC连接管理支持多个业务模块共享连接
-**远程桌面支持** - 新增实时屏幕共享功能
-**修复 WebRTC 连接状态异常** - 增强了连接状态错误处理和恢复能力
## 🚀 技术栈
**前端** - Next.js 15 + React 18 + TypeScript + Tailwind CSS
**后端** - Go + WebSocket + 内存存储
**传输** - WebRTC DataChannel + P2P直连
### 前端技术栈
- **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穿透支持
### 架构特点
- **微服务架构** - 前后端分离
- **实时通信** - 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-go:latest
```
### 方式二:本地构建部署
```bash
git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
@@ -33,13 +114,54 @@ 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` - 开发版本
## 🎯 使用方法
**发送文件**
### 发送文件
1. 选择文件 → 生成取件码 → 分享6位码
**接收文件**
1. 输入取件码 → 自动连接 → 下载文件
### 文字传输
1. 输入文字内容 → 生成取件码 → 分享给对方
### 桌面共享
1. 点击共享桌面 → 生成取件码 → 对方输入码观看
## 📊 项目架构
@@ -59,10 +181,19 @@ make dev
cd chuan-next && yarn && yarn dev
```
## 📄 许可证
MIT License
---
⭐ 觉得有用请给个星标!
<div align="center">
⭐ 如果觉得这个项目对你有帮助,请给个星标!
[![Star History Chart](https://api.star-history.com/svg?repos=MatrixSeven/file-transfer-go&type=timeline)]
</div>
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")

37
_deploy.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
set -e
echo "🚀 构建并部署前端..."
# 构建前端
cd chuan-next
npm run build:ssg
cd ..
# 压缩
tar -czf /tmp/frontend.tar.gz -C chuan-next/out .
# 创建服务器目录并上传
ssh root@101.33.214.22 "mkdir -p /root/file-transfer/chuan-next"
scp /tmp/frontend.tar.gz root@101.33.214.22:/root/file-transfer/chuan-next/
ssh root@101.33.214.22 << 'EOF'
cd /root/file-transfer/chuan-next
# 备份 api 目录
[ -d current/api ] && cp -r current/api /tmp/api-backup
# 解压新版本
rm -rf current
mkdir current
cd current
tar -xzf ../frontend.tar.gz
# 还原 api 目录
[ -d /tmp/api-backup ] && cp -r /tmp/api-backup ./api && rm -rf /tmp/api-backup
# 清理压缩包
rm -f ../frontend.tar.gz
EOF
# 清理本地文件
rm -f /tmp/frontend.tar.gz
rm -rf chuan-next/out
echo "✅ 部署完成"

View File

@@ -1,87 +0,0 @@
#!/bin/bash
# =============================================================================
# 简化版 SSG 构建脚本
# 专注于 API 路由的处理和静态导出
# =============================================================================
set -e
# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
# 配置
PROJECT_ROOT="$(pwd)"
API_DIR="$PROJECT_ROOT/src/app/api"
TEMP_API_DIR="/tmp/nextjs-api-$(date +%s)"
echo -e "${GREEN}🚀 开始 SSG 静态导出构建...${NC}"
# 错误处理函数
cleanup_on_error() {
echo -e "${RED}❌ 构建失败,正在恢复文件...${NC}"
if [ -d "$TEMP_API_DIR" ]; then
if [ -d "$TEMP_API_DIR/api" ]; then
mv "$TEMP_API_DIR/api" "$API_DIR" 2>/dev/null || true
echo -e "${YELLOW}📂 已恢复 API 目录${NC}"
fi
rm -rf "$TEMP_API_DIR"
fi
exit 1
}
# 设置错误处理
trap cleanup_on_error ERR INT TERM
# 步骤 1: 备份 API 路由
if [ -d "$API_DIR" ]; then
echo -e "${YELLOW}📦 备份 API 路由...${NC}"
mkdir -p "$TEMP_API_DIR"
mv "$API_DIR" "$TEMP_API_DIR/"
echo "✅ API 路由已备份到临时目录"
else
echo -e "${YELLOW}⚠️ API 目录不存在,跳过备份${NC}"
fi
# 步骤 2: 清理构建文件
echo -e "${YELLOW}🧹 清理之前的构建...${NC}"
rm -rf .next out
# 步骤 3: 执行静态构建
echo -e "${YELLOW}🔨 执行静态导出构建...${NC}"
NEXT_EXPORT=true yarn build
# 步骤 4: 验证构建结果
if [ -d "out" ] && [ -f "out/index.html" ]; then
echo -e "${GREEN}✅ 静态导出构建成功!${NC}"
# 显示构建统计
file_count=$(find out -type f | wc -l)
dir_size=$(du -sh out | cut -f1)
echo "📊 构建统计:"
echo " - 文件数量: $file_count"
echo " - 总大小: $dir_size"
else
echo -e "${RED}❌ 构建验证失败${NC}"
cleanup_on_error
fi
# 步骤 5: 恢复 API 路由
if [ -d "$TEMP_API_DIR/api" ]; then
echo -e "${YELLOW}🔄 恢复 API 路由...${NC}"
mv "$TEMP_API_DIR/api" "$API_DIR"
echo "✅ API 路由已恢复"
fi
# 步骤 6: 清理临时文件
rm -rf "$TEMP_API_DIR"
echo ""
echo -e "${GREEN}🎉 SSG 构建完成!${NC}"
echo -e "${GREEN}📁 静态文件位于: ./out/${NC}"
echo -e "${GREEN}🚀 部署命令: npx serve out${NC}"
echo ""
echo -e "${YELLOW}💡 提示: 静态版本会直接连接到 Go 后端 (localhost:8080)${NC}"

0
chuan-next/build-static.sh Normal file → Executable file
View File

View File

@@ -35,7 +35,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@@ -1,105 +1,213 @@
"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 Hero from '@/components/Hero';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
import DesktopShare from '@/components/DesktopShare';
import Footer from '@/components/Footer';
import Hero from '@/components/Hero';
import WeChatGroup from '@/components/WeChatGroup';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import WebRTCSettings from '@/components/WebRTCSettings';
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
import { WebRTCUnsupportedModal } from '@/components/WebRTCUnsupportedModal';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useWebRTCSupport } from '@/hooks/connection';
import { useWebRTCConfigSync } from '@/hooks/settings';
import { TabType, useTabNavigation } from '@/hooks/ui';
import { MessageSquare, Monitor, Settings, Upload, Users } from 'lucide-react';
export default function HomePage() {
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('webrtc');
const [hasInitialized, setHasInitialized] = useState(false);
// 根据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配置同步
useWebRTCConfigSync();
// 使用tab导航hook
const {
activeTab,
handleTabChange,
confirmDialogState,
closeConfirmDialog
} = useTabNavigation();
// WebRTC 支持检测
const {
webrtcSupport,
isSupported,
isChecked,
showUnsupportedModal,
closeUnsupportedModal,
showUnsupportedModalManually,
} = useWebRTCSupport();
// 处理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">
<div className="container mx-auto px-4 py-4 sm:py-6 md:py-8">
{/* Hero Section */}
<div className="text-center mb-6 sm:mb-8">
<Hero />
</div>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex flex-col">
<div className="flex-1">
<div className="container mx-auto px-4 py-2 sm:py-4 md:py-6">
{/* Hero Section */}
<div className="text-center mb-4 sm:mb-6">
<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-6 bg-gradient-to-r from-rose-50 via-orange-50 to-amber-50 border border-orange-200 rounded-xl shadow-sm backdrop-blur-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-3 h-3 bg-gradient-to-r from-orange-400 to-red-500 rounded-full animate-pulse shadow-lg"></div>
<div className="absolute inset-0 w-3 h-3 bg-gradient-to-r from-orange-400 to-red-500 rounded-full animate-ping opacity-30"></div>
</div>
<div className="flex flex-col">
<span className="text-orange-800 font-semibold text-sm">
</span>
<span className="text-orange-700 text-sm">
WebRTC使
</span>
</div>
</div>
<button
onClick={showUnsupportedModalManually}
className="px-4 py-2 bg-gradient-to-r from-orange-500 to-red-500 text-white text-sm font-medium rounded-lg hover:from-orange-600 hover:to-red-600 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
</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-5 max-w-3xl 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>
<TabsTrigger
value="settings"
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-orange-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-orange-600"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
<DesktopShare />
</TabsContent>
{/* WebRTC 不支持时的提示 */}
{!isSupported && (
<p className="text-center text-xs text-gray-500 mt-2">
* WebRTC 使
</p>
)}
</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>
<TabsContent value="settings" className="mt-0 animate-fade-in-up">
<WebRTCSettings />
</TabsContent>
</div>
</Tabs>
</div>
</Tabs>
)}
</div>
</div>
{/* 页脚 */}
<Footer />
{/* 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

@@ -86,7 +86,6 @@ body {
color: hsl(var(--foreground));
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow-x: hidden;
overflow-y: hidden;
min-height: 100vh;
margin: 0;
padding: 0;

View File

@@ -0,0 +1,761 @@
"use client";
import React, { useState, useEffect } from 'react';
import {
Book,
Server,
Download,
Code,
Container,
Globe,
ChevronRight,
ExternalLink,
Copy,
Check,
AlertTriangle,
Info,
Lightbulb,
HelpCircle,
Upload,
MessageSquare,
Monitor,
Settings,
Shield,
Smartphone,
Wifi,
Users,
Home,
ArrowLeft
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
interface SectionProps {
id: string;
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}
function Section({ id, title, icon, children }: SectionProps) {
return (
<section id={id} className="mb-6 scroll-mt-16 lg:scroll-mt-20">
<div className="flex items-center gap-3 mb-4 lg:mb-6">
<div className="p-2 lg:p-3 bg-blue-100 rounded-lg">
{icon}
</div>
<h2 className="text-xl lg:text-2xl font-bold text-gray-900">{title}</h2>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 lg:p-6">
{children}
</div>
</section>
);
}
interface CodeBlockProps {
code: string;
language?: string;
}
function CodeBlock({ code, language = "bash" }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('复制失败:', error);
}
};
return (
<div className="relative">
<div className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 text-sm font-mono">
<code>{code}</code>
</pre>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="absolute top-2 right-2 text-gray-400 hover:text-white"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
);
}
interface InfoBoxProps {
type: 'info' | 'warning' | 'tip' | 'error';
title: string;
children: React.ReactNode;
}
function InfoBox({ type, title, children }: InfoBoxProps) {
const styles = {
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: <Info className="w-5 h-5 text-blue-600" />,
titleColor: 'text-blue-900'
},
warning: {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
icon: <AlertTriangle className="w-5 h-5 text-yellow-600" />,
titleColor: 'text-yellow-900'
},
tip: {
bg: 'bg-green-50',
border: 'border-green-200',
icon: <Lightbulb className="w-5 h-5 text-green-600" />,
titleColor: 'text-green-900'
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
icon: <AlertTriangle className="w-5 h-5 text-red-600" />,
titleColor: 'text-red-900'
}
};
const style = styles[type];
return (
<div className={`${style.bg} ${style.border} border rounded-lg p-4 my-4`}>
<div className="flex items-start gap-3">
{style.icon}
<div className="flex-1">
<h4 className={`font-semibold ${style.titleColor} mb-2`}>{title}</h4>
<div className="text-sm text-gray-700">{children}</div>
</div>
</div>
</div>
);
}
export default function HelpPage() {
const [activeSection, setActiveSection] = useState('deployment');
const [sidebarLeft, setSidebarLeft] = useState(0);
const sections = [
{
id: 'deployment',
title: '部署指南',
icon: <Server className="w-5 h-5 text-blue-600" />,
children: [
{ id: 'docker-deployment', title: 'Docker 部署', icon: <Container className="w-4 h-4 text-blue-500" /> },
{ id: 'binary-deployment', title: '二进制部署', icon: <Download className="w-4 h-4 text-green-500" /> },
{ id: 'build-deployment', title: '自行构建', icon: <Code className="w-4 h-4 text-purple-500" /> },
]
},
{ id: 'desktop-share', title: '桌面共享权限问题', icon: <Monitor className="w-5 h-5 text-blue-600" /> },
{ id: 'port-config', title: '自定义端口配置', icon: <Settings className="w-5 h-5 text-blue-600" /> },
{ id: 'security', title: '全局域网部署', icon: <Shield className="w-5 h-5 text-blue-600" /> },
{ id: 'data-transfer', title: '数据传输机制', icon: <Wifi className="w-5 h-5 text-blue-600" /> },
{ id: 'contact', title: '交流反馈群', icon: <Users className="w-5 h-5 text-blue-600" /> },
];
const scrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
setActiveSection(sectionId);
// 更新 URL hash
window.history.pushState(null, '', `#${sectionId}`);
}
};
// 初始化时检查 URL hash 并滚动到对应位置
useEffect(() => {
const hash = window.location.hash.replace('#', '');
if (hash) {
// 延迟一下确保 DOM 已经渲染完成
setTimeout(() => {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
setActiveSection(hash);
}
}, 100);
}
}, []);
// 监听滚动事件来更新活跃的章节和 URL hash
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY + 100;
// 检查所有可能的section ID包括子目录
const allSectionIds = sections.reduce<string[]>((acc, section) => {
acc.push(section.id);
if (section.children) {
acc.push(...section.children.map(child => child.id));
}
return acc;
}, []);
for (const sectionId of allSectionIds) {
const element = document.getElementById(sectionId);
if (element) {
const { offsetTop, offsetHeight } = element;
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
setActiveSection(sectionId);
// 更新 URL hash但不触发页面滚动
if (window.location.hash !== `#${sectionId}`) {
window.history.replaceState(null, '', `#${sectionId}`);
}
break;
}
}
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [sections]);
// 计算侧边栏的位置
useEffect(() => {
const updateSidebarPosition = () => {
const container = document.querySelector('.w-\\[95\\%\\]');
if (container) {
const containerRect = container.getBoundingClientRect();
const containerLeft = containerRect.left;
// 计算第一列的位置24px padding + grid gap
setSidebarLeft(containerLeft + 24);
}
};
updateSidebarPosition();
window.addEventListener('resize', updateSidebarPosition);
return () => window.removeEventListener('resize', updateSidebarPosition);
}, []);
return (
<div className="w-[95%] lg:w-[70%] max-w-none mx-auto p-4 lg:p-6">
{/* Header */}
<div className="text-center mb-8 lg:mb-12">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="p-3 bg-blue-100 rounded-xl">
<Book className="w-8 h-8 text-blue-600" />
</div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">使</h1>
</div>
<p className="text-base lg:text-lg text-gray-600 max-w-2xl mx-auto">
使
</p>
</div>
<div className="relative">
{/* 返回首页按钮 - 桌面端固定定位 */}
<div className="hidden lg:block">
<div
className="fixed bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-20"
style={{ left: `${sidebarLeft}px`, top: '2rem', width: '256px' }}
>
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors hover:bg-blue-50 text-blue-600 hover:text-blue-700"
>
<ArrowLeft className="w-5 h-5" />
<span className="text-sm font-medium"></span>
</Link>
</div>
</div>
{/* 返回首页按钮 - 移动端固定定位 */}
<div className="lg:hidden">
<div className="fixed left-4 top-4 z-20">
<Link
href="/"
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg shadow-lg border border-gray-200 text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-xs font-medium"></span>
</Link>
</div>
</div>
{/* 侧边栏目录 - 桌面端固定定位 */}
<div className="hidden lg:block">
<div
className="fixed w-64 bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-h-[calc(100vh-10rem)] overflow-y-auto z-10"
style={{ left: `${sidebarLeft}px`, top: '7rem' }}
>
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<nav className="space-y-2">
{sections.map((section) => (
<div key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
activeSection === section.id
? 'bg-blue-50 text-blue-700 border border-blue-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
{section.icon}
<span className="text-sm font-medium">{section.title}</span>
<ChevronRight className="w-4 h-4 ml-auto" />
</button>
{/* 子目录 */}
{section.children && (
<div className="ml-8 mt-1 space-y-1">
{section.children.map((child) => (
<button
key={child.id}
onClick={() => scrollToSection(child.id)}
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
activeSection === child.id
? 'bg-blue-100 text-blue-600 border border-blue-200'
: 'hover:bg-gray-50 text-gray-600'
}`}
>
{child.icon}
<span className="text-xs text-gray-700">{child.title}</span>
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
{/* 移动端目录 - 粘性定位 */}
<div className="lg:hidden mb-6 mt-16">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h3 className="text-base font-semibold text-gray-900 mb-3"></h3>
<nav className="space-y-1">
{sections.map((section) => (
<div key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
activeSection === section.id
? 'bg-blue-50 text-blue-700 border border-blue-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
{section.icon}
<span className="text-xs font-medium">{section.title}</span>
<ChevronRight className="w-3 h-3 ml-auto" />
</button>
{/* 子目录 */}
{section.children && (
<div className="ml-6 mt-1 space-y-1">
{section.children.map((child) => (
<button
key={child.id}
onClick={() => scrollToSection(child.id)}
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
activeSection === child.id
? 'bg-blue-100 text-blue-600 border border-blue-200'
: 'hover:bg-gray-50 text-gray-600'
}`}
>
{child.icon}
<span className="text-xs text-gray-700">{child.title}</span>
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
{/* 主要内容 */}
<div className="lg:ml-72 lg:mr-4">
{/* 部署指南 */}
<Section id="deployment" title="部署指南" icon={<Server className="w-6 h-6 text-blue-600" />}>
<div className="space-y-8">
<div>
<p className="text-gray-700 mb-6">
</p>
</div>
{/* Docker 部署 */}
<div className="scroll-mt-20" id="docker-deployment">
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Container className="w-6 h-6 text-blue-600" />
Docker
</h3>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold mb-3">使 Docker Compose</h4>
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
docker-compose up -d`} />
</div>
<div>
<h4 className="text-lg font-semibold mb-3">使 Docker </h4>
<CodeBlock code={`docker run -d -p 8080:8080 --name file-transfer-go matrixseven/file-transfer-go:latest`} />
</div>
<InfoBox type="tip" title="部署提示">
<ul className="list-disc list-inside space-y-1">
<li>Docker Compose </li>
<li>访 <code className="bg-gray-100 px-2 py-1 rounded">http://localhost:8080</code></li>
<li> <code className="bg-gray-100 px-2 py-1 rounded">docker-compose.yml</code> </li>
</ul>
</InfoBox>
</div>
</div>
{/* 二进制部署 */}
<div className="scroll-mt-20" id="binary-deployment">
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Download className="w-6 h-6 text-green-600" />
</h3>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<p className="text-gray-700 mb-3">
<a
href="https://github.com/MatrixSeven/file-transfer-go/releases/"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
target="_blank"
rel="noopener noreferrer"
>
GitHub Releases <ExternalLink className="w-4 h-4" />
</a>
</p>
<div className="bg-gray-50 rounded-lg p-4">
<h5 className="font-semibold mb-2"></h5>
<ul className="list-disc list-inside space-y-1 text-gray-700">
<li>Linux (AMD64/ARM64)</li>
<li>Windows (AMD64)</li>
<li>macOS (AMD64/ARM64)</li>
</ul>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<p className="text-gray-700 mb-3"></p>
<CodeBlock code={`# Linux/macOS
chmod +x file-transfer-server-linux-amd64
./file-transfer-server-linux-amd64
# Windows
file-transfer-server-windows-amd64.exe`} />
</div>
<InfoBox type="info" title="注意事项">
<ul className="list-disc list-inside space-y-1">
<li></li>
<li> 8080</li>
<li>使 systemd </li>
</ul>
</InfoBox>
</div>
</div>
{/* 自行构建 */}
<div className="scroll-mt-20" id="build-deployment">
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Code className="w-6 h-6 text-purple-600" />
</h3>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<div className="bg-gray-50 rounded-lg p-4">
<ul className="list-disc list-inside space-y-1 text-gray-700">
<li>Go 1.21 </li>
<li>Node.js 18 </li>
<li>Git</li>
</ul>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
./build-fullstack.sh
./dist/file-transfer-go`} />
</div>
<InfoBox type="warning" title="构建注意事项">
<ul className="list-disc list-inside space-y-1">
<li> Go npm </li>
<li></li>
<li></li>
</ul>
</InfoBox>
</div>
</div>
</div>
</Section>
{/* 桌面共享权限 */}
<Section id="desktop-share" title="桌面共享权限问题" icon={<Monitor className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Smartphone className="w-5 h-5" />
</h3>
<InfoBox type="error" title="移动端限制">
<p>WebRTC </p>
<p className="mt-2 font-semibold">使</p>
</InfoBox>
</div>
<div>
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Monitor className="w-5 h-5" />
PC
</h3>
<InfoBox type="warning" title="HTTPS 要求">
<p>/访 TLS <code className="bg-gray-100 px-2 py-1 rounded">https</code> 访</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li><code className="bg-gray-100 px-2 py-1 rounded">localhost</code> </li>
<li> nginx HTTPS</li>
<li> IP </li>
</ul>
</InfoBox>
<InfoBox type="tip" title="临时解决方案">
<p> IP+ </p>
<ol className="list-decimal list-inside space-y-1 mt-2">
<li></li>
<li> WebRTC </li>
<li> <code className="bg-gray-100 px-2 py-1 rounded">Anonymize local IPs exposed by WebRTC</code></li>
<li> <code className="bg-gray-100 px-2 py-1 rounded">Enabled</code> </li>
</ol>
</InfoBox>
</div>
</div>
</Section>
{/* 端口配置 */}
<Section id="port-config" title="端口配置" icon={<Settings className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-gray-700 mb-3"> Linux 18080 </p>
<CodeBlock code="./file-transfer-server-linux-amd64 -port 18080" />
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Docker </h3>
<p className="text-gray-700 mb-3">使 Docker </p>
<CodeBlock code="docker run -d -p 18080:8080 matrixseven/file-transfer-go:latest" />
</div>
<InfoBox type="info" title="端口选择建议">
<ul className="list-disc list-inside space-y-1">
<li>使1-1024</li>
<li></li>
<li></li>
<li>使 8080, 3000, 8000 </li>
</ul>
</InfoBox>
</div>
</Section>
{/* 安全内网部署 */}
<Section id="security" title="安全内网部署" icon={<Shield className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<InfoBox type="warning" title="实验性功能">
<p></p>
</InfoBox>
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-4">
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold mb-2">1. DNS </h4>
<p className="text-gray-700">使 IP 访</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold mb-2">2. STUN/TURN </h4>
<p className="text-gray-700"> STUN/TURN NAT 穿</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold mb-2">3. </h4>
<p className="text-gray-700"> STUN/TURN </p>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">STUN/TURN </h3>
<div className="bg-gray-50 rounded-lg p-4">
<ul className="list-disc list-inside space-y-1 text-gray-700">
<li><strong>Coturn</strong> TURN/STUN </li>
<li><strong>Janus</strong>WebRTC STUN/TURN </li>
<li><strong></strong> Docker </li>
</ul>
</div>
</div>
<InfoBox type="tip" title="配置提示">
<p> ICE </p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>STUN <code className="bg-gray-100 px-2 py-1 rounded">stun:your-server.local:3478</code></li>
<li>TURN <code className="bg-gray-100 px-2 py-1 rounded">turn:your-server.local:3478</code></li>
<li>TURN </li>
</ul>
</InfoBox>
</div>
</Section>
{/* 数据传输说明 */}
<Section id="data-transfer" title="数据传输机制" icon={<Wifi className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 text-green-600"> </h4>
<p className="text-sm text-gray-600"> WebRTC </p>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 text-blue-600"> </h4>
<p className="text-sm text-gray-600"> TURN </p>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">1</div>
<div>
<h4 className="font-semibold"></h4>
<p className="text-gray-600"> WebSocket </p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">2</div>
<div>
<h4 className="font-semibold">NAT 穿</h4>
<p className="text-gray-600">使 STUN </p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">3</div>
<div>
<h4 className="font-semibold"></h4>
<p className="text-gray-600"> P2P TURN </p>
</div>
</div>
</div>
</div>
<InfoBox type="info" title="隐私保护">
<ul className="list-disc list-inside space-y-1">
<li></li>
<li></li>
<li></li>
<li>使使 TURN </li>
</ul>
</InfoBox>
</div>
</Section>
{/* 交流反馈 */}
<Section id="contact" title="交流反馈" icon={<Users className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="flex-1">
<p className="text-gray-700 mb-4">
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<li></li>
<li>使</li>
<li></li>
<li></li>
</ul>
</div>
<div className="flex-shrink-0">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<img
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
alt="交流反馈群二维码"
className="w-32 h-32 mx-auto rounded-lg"
/>
<p className="text-sm text-gray-600 mt-2"></p>
</div>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<ExternalLink className="w-5 h-5 text-blue-600" />
GitHub Issues
</h4>
<p className="text-sm text-gray-600 mb-2"> Bug </p>
<a
href="https://github.com/MatrixSeven/file-transfer-go/issues"
className="text-blue-600 hover:underline text-sm"
target="_blank"
rel="noopener noreferrer"
>
Issues
</a>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Book className="w-5 h-5 text-green-600" />
</h4>
<p className="text-sm text-gray-600 mb-2"></p>
<a
href="https://github.com/MatrixSeven/file-transfer-go"
className="text-blue-600 hover:underline text-sm"
target="_blank"
rel="noopener noreferrer"
>
</a>
</div>
</div>
</div>
<InfoBox type="tip" title="反馈建议">
<p></p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li></li>
<li>Docker//</li>
<li></li>
<li>//NAT类型</li>
<li></li>
</ul>
</InfoBox>
</div>
</Section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '使用帮助 - 文件传输工具',
description: '详细的部署指南和使用说明,帮助您快速上手文件传输工具',
keywords: ['文件传输', '帮助文档', '部署指南', 'WebRTC', '使用说明'],
}
export default function HelpLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -0,0 +1,5 @@
import HelpPage from '@/app/help/HelpPage'
export default function Help() {
return <HelpPage />
}

View File

@@ -25,6 +25,9 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-CN">
<head>
<script defer src="https://track.biu.52python.cn/script.js" data-website-id="28f20618-8d31-421d-8ee2-16fcde0e299a"></script>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -0,0 +1,294 @@
import { useReadConnectState } from '@/hooks/connection/state/useWebConnectStateManager';
import { Role } from '@/hooks/connection/types';
import { useWebRTCStore } from '@/hooks/index';
import { cn } from '@/lib/utils';
interface ConnectionStatusProps {
// 房间信息 - 只需要这个基本信息
currentRoom?: { code: string; role: 'sender' | 'receiver' } | null;
// 样式类名
className?: string;
// 紧凑模式
compact?: boolean;
// 内联模式 - 只返回状态文本不包含UI结构
inline?: boolean;
}
// 连接状态枚举
const getConnectionStatus = (
currentRoom: { code: string; role: Role } | null,
connection: {
isWebSocketConnected: boolean;
isPeerConnected: boolean;
isConnecting: boolean;
error: string | null;
currentConnectType: string;
isJoinedRoom: boolean;
}
) => {
const { isWebSocketConnected, isPeerConnected, isConnecting, error, currentConnectType, isJoinedRoom } = connection;
if (!currentRoom) {
return {
type: 'disconnected' as const,
message: '未连接',
detail: '尚未创建房间',
};
}
if (error) {
return {
type: 'error' as const,
message: '连接失败',
detail: error,
};
}
if (currentConnectType === 'websocket') {
if (isWebSocketConnected && isJoinedRoom) {
return {
type: 'connected' as const,
message: 'P2P链接失败,WS降级中',
detail: 'WebSocket传输模式已就绪',
};
}
return {
type: 'room-ready' as const,
message: '房间已创建',
detail: '等待对方加入并建立WS连接...',
};
}
if (isConnecting) {
return {
type: 'connecting' as const,
message: '正在连接',
detail: '建立房间连接中...',
};
}
// 如果有房间信息但WebSocket未连接且不是正在连接状态
// 可能是状态更新的时序问题,显示连接中状态
if (isPeerConnected) {
return {
type: 'connected' as const,
message: 'P2P连接成功',
detail: '可以开始传输',
};
}
if (!isWebSocketConnected) {
return {
type: 'connecting' as const,
message: '连接中',
detail: '正在建立WebSocket连接...',
};
}
if (!isJoinedRoom) {
return {
type: 'room-ready' as const,
message: '房间已创建',
detail: '等待对方加入并建立P2P连接...',
};
}
if (isJoinedRoom) {
return {
type: 'room-ready' as const,
message: '对方已加入房间',
detail: '正在建立P2P连接...',
};
}
if (isJoinedRoom && !isPeerConnected) {
return {
type: 'room-ready' as const,
message: '房间已创建',
detail: '等待对方加入并建立P2P连接...',
};
}
console.log('Unknown connection state:', connection);
return {
type: 'unknown' as const,
message: '状态未知',
detail: '',
};
};
// 状态颜色映射
const getStatusColor = (type: string) => {
switch (type) {
case 'connected':
return 'text-green-600';
case 'connecting':
case 'room-ready':
return 'text-yellow-600';
case 'websocket-ready':
return 'text-orange-600';
case 'error':
return 'text-red-600';
case 'disconnected':
case 'unknown':
default:
return 'text-gray-500';
}
};
// 状态图标
const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?: string }) => {
const iconClass = cn('inline-block', className);
switch (type) {
case 'connected':
return <div className={cn(iconClass, 'bg-green-500 rounded-full')} />;
case 'connecting':
case 'room-ready':
return (
<div className={cn(iconClass, 'bg-yellow-500 rounded-full animate-pulse')} />
);
case 'websocket-ready':
return <div className={cn(iconClass, 'bg-orange-500 rounded-full')} />;
case 'error':
return <div className={cn(iconClass, 'bg-red-500 rounded-full')} />;
case 'disconnected':
case 'unknown':
default:
return <div className={cn(iconClass, 'bg-gray-400 rounded-full')} />;
}
};
// 获取连接状态文字描述
const getConnectionStatusText = (connection: { isWebSocketConnected?: boolean; isPeerConnected?: boolean; isConnecting?: boolean; error?: string | null; currentConnectType?: 'webrtc' | 'websocket' }) => {
const isWebSocketConnected = connection?.isWebSocketConnected || false;
const isPeerConnected = connection?.isPeerConnected || false;
const isConnecting = connection?.isConnecting || false;
const error = connection?.error || null;
const currentConnectType = connection?.currentConnectType || 'webrtc';
const wsStatus = isWebSocketConnected ? 'WS已连接' : 'WS未连接';
const rtcStatus = isPeerConnected ? 'RTC已连接' :
isWebSocketConnected ? 'RTC等待连接' : 'RTC未连接';
if (error) {
return `${wsStatus} ${rtcStatus} - 连接失败`;
}
if (isConnecting) {
return `${wsStatus} ${rtcStatus} - 连接中`;
}
if (isPeerConnected) {
return `${wsStatus} ${rtcStatus} - P2P连接成功`;
}
// 如果WebSocket已连接但P2P未连接且当前连接类型是websocket
if (isWebSocketConnected && !isPeerConnected && currentConnectType === 'websocket') {
return `${wsStatus} ${rtcStatus} - P2P链接失败,将使用WS进行传输`;
}
return `${wsStatus} ${rtcStatus}`;
};
export function ConnectionStatus(props: ConnectionStatusProps) {
const { currentRoom, className, compact = false, inline = false } = props;
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 创建connection对象以兼容现有代码
const connection = {
isWebSocketConnected: webrtcState.isWebSocketConnected,
isPeerConnected: webrtcState.isPeerConnected,
isConnecting: webrtcState.isConnecting,
error: webrtcState.error,
currentConnectType: webrtcState.currentConnectType,
isJoinedRoom: webrtcState.isJoinedRoom,
};
// 如果是内联模式,只返回状态文字
if (inline) {
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
}
const status = getConnectionStatus(currentRoom ?? null, connection);
if (compact) {
return (
<div className={cn('flex items-center', className)}>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 连接状态指示器 */}
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-1.5">
<StatusIcon
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className="text-sm text-slate-600 font-medium">WS</span>
</div>
<span className="text-slate-300 font-medium">|</span>
<div className="flex items-center gap-1.5">
<StatusIcon
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className="text-sm text-slate-600 font-medium">RTC</span>
</div>
</div>
</div>
);
}
return (
<div className={cn(className)}>
<div className="space-y-2">
{/* 主要状态 */}
<div className={cn('font-medium text-sm', getStatusColor(status.type))}>
{status.message}
</div>
<div className="text-xs text-muted-foreground">
{status.detail}
</div>
{/* 详细连接状态 */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<span className="text-slate-500 font-medium">WS</span>
<StatusIcon
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className={cn(
connection.isWebSocketConnected ? 'text-green-600' : 'text-slate-500'
)}>
{connection.isWebSocketConnected ? '已连接' : '未连接'}
</span>
</div>
<span className="text-slate-300">|</span>
<div className="flex items-center gap-2">
<span className="text-slate-500 font-medium">RTC</span>
<StatusIcon
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className={cn(
connection.isPeerConnected ? 'text-green-600' : 'text-slate-500'
)}>
{connection.isPeerConnected ? '已连接' : '未连接'}
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,199 +1,165 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Share, Monitor, Copy, Play, Square, Repeat, Users, Wifi, WifiOff } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
import DesktopViewer from '@/components/DesktopViewer';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import { Share, Monitor, AlertTriangle, ExternalLink } from 'lucide-react';
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
interface DesktopShareProps {
// 保留向后兼容性的props
onStartSharing?: () => Promise<string>;
onStopSharing?: () => Promise<void>;
// 保留向后兼容性的props(已废弃,但保留接口)
onJoinSharing?: (code: string) => Promise<void>;
}
export default function DesktopShare({
onStartSharing,
onStopSharing,
onJoinSharing
}: DesktopShareProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [mode, setMode] = useState<'share' | 'view'>('share');
const [inputCode, setInputCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const { showToast } = useToast();
// 检测是否支持屏幕分享
function useScreenShareSupport() {
const [isSupported, setIsSupported] = useState(true);
const [reason, setReason] = useState<string>('');
// 使用桌面共享业务逻辑
const desktopShare = useDesktopShareBusiness();
// 从URL参数中获取初始模式
useEffect(() => {
const urlMode = searchParams.get('mode');
const type = searchParams.get('type');
if (type === 'desktop' && urlMode) {
if (urlMode === 'send') {
setMode('share');
} else if (urlMode === 'receive') {
setMode('view');
const checkScreenShareSupport = async () => {
try {
// 首先检查是否存在 getDisplayMedia API
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
setIsSupported(false);
setReason('api-not-supported');
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
setIsSupported(false);
setReason('mobile');
return;
}
// 检查安全上下文 - getDisplayMedia 需要安全上下文HTTPS 或 localhost
if (!window.isSecureContext) {
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '[::1]';
if (!isLocalhost) {
setIsSupported(false);
setReason('insecure-context');
return;
}
return
}
setIsSupported(true);
setReason('');
}
}
catch (error) {
console.error('Error checking screen share support:', error);
setIsSupported(false);
setReason('unknown-error');
}
};
checkScreenShareSupport();
}, []);
return { isSupported, reason };
}
export default function DesktopShare({
onJoinSharing
}: DesktopShareProps) {
const [mode, setMode] = useState<'share' | 'view'>('share');
const { isSupported, reason } = useScreenShareSupport();
// 使用统一的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');
router.replace(currentUrl.pathname + currentUrl.search);
}, [router]);
// 获取初始房间代码(用于接收者模式)
const getInitialCode = useCallback(() => {
const code = getCurrentRoomCode();
console.log('[DesktopShare] getInitialCode 返回:', code);
return code;
}, [getCurrentRoomCode]);
// 复制房间代码
const copyCode = useCallback(async (code: string) => {
try {
await navigator.clipboard.writeText(code);
showToast('房间代码已复制到剪贴板', 'success');
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败,请手动复制', 'error');
// 连接状态变化处理 - 为了兼容现有的子组件接口,保留它
const handleConnectionChange = useCallback((connection: { isConnected: boolean; isWebSocketConnected: boolean }) => {
console.log('桌面共享连接状态变化:', connection);
}, []);
// 获取提示信息
const getWarningInfo = () => {
switch (reason) {
case 'mobile':
return {
title: '移动端不支持屏幕分享',
message: '移动端浏览器不支持获取桌面视频流,请使用桌面设备进行屏幕共享。'
};
case 'api-not-supported':
return {
title: '浏览器不支持屏幕分享',
message: '当前浏览器不支持 getDisplayMedia API请使用支持屏幕分享的现代浏览器如 Chrome、Firefox、Edge 等)。'
};
case 'insecure-context':
return {
title: '需要安全上下文',
message: '屏幕分享功能需要在安全上下文中使用HTTPS协议或localhost当前环境不支持。'
};
case 'detection-failed':
return {
title: '检测屏幕分享支持失败',
message: '无法检测屏幕分享支持情况,这可能是由于浏览器限制或权限问题。'
};
case 'unknown-error':
return {
title: '未知错误',
message: '检测屏幕分享支持时发生未知错误,请尝试刷新页面或使用其他浏览器。'
};
case 'ip-http':
return {
title: '当前环境不支持屏幕分享',
message: '使用IP地址访问时浏览器要求HTTPS协议才能进行屏幕分享。请配置HTTPS或使用localhost访问。'
};
case 'non-https':
return {
title: '需要HTTPS协议',
message: '屏幕分享功能需要在HTTPS环境下使用请使用HTTPS协议访问或在本地环境测试。'
};
default:
return null;
}
}, [showToast]);
// 创建房间
const handleCreateRoom = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShare] 用户点击创建房间');
const roomCode = await desktopShare.createRoom();
console.log('[DesktopShare] 房间创建成功:', roomCode);
showToast(`房间创建成功!代码: ${roomCode}`, 'success');
} catch (error) {
console.error('[DesktopShare] 创建房间失败:', error);
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 开始桌面共享
const handleStartSharing = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShare] 用户点击开始桌面共享');
await desktopShare.startSharing();
console.log('[DesktopShare] 桌面共享开始成功');
showToast('桌面共享已开始', 'success');
} catch (error) {
console.error('[DesktopShare] 开始桌面共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 切换桌面
const handleSwitchDesktop = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShare] 用户点击切换桌面');
await desktopShare.switchDesktop();
console.log('[DesktopShare] 桌面切换成功');
showToast('桌面切换成功', 'success');
} catch (error) {
console.error('[DesktopShare] 切换桌面失败:', error);
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 停止桌面共享
const handleStopSharing = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShare] 用户点击停止桌面共享');
await desktopShare.stopSharing();
console.log('[DesktopShare] 桌面共享停止成功');
showToast('桌面共享已停止', 'success');
} catch (error) {
console.error('[DesktopShare] 停止桌面共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 加入观看
const handleJoinViewing = useCallback(async () => {
if (!inputCode.trim()) {
showToast('请输入房间代码', 'error');
return;
}
try {
setIsLoading(true);
console.log('[DesktopShare] 用户加入观看房间:', inputCode);
await desktopShare.joinSharing(inputCode.trim().toUpperCase());
console.log('[DesktopShare] 加入观看成功');
showToast('已加入桌面共享', 'success');
} catch (error) {
console.error('[DesktopShare] 加入观看失败:', error);
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, inputCode, showToast]);
// 停止观看
const handleStopViewing = useCallback(async () => {
try {
setIsLoading(true);
await desktopShare.stopViewing();
showToast('已退出桌面共享', 'success');
setInputCode('');
} catch (error) {
console.error('[DesktopShare] 停止观看失败:', error);
showToast('退出失败', 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 连接状态指示器
const getConnectionStatus = () => {
if (desktopShare.isConnecting) return { icon: Wifi, text: '连接中...', color: 'text-yellow-600' };
if (desktopShare.isPeerConnected) return { icon: Wifi, text: 'P2P已连接', color: 'text-green-600' };
if (desktopShare.isWebSocketConnected) return { icon: Users, text: '等待对方加入', color: 'text-blue-600' };
return { icon: WifiOff, text: '未连接', color: 'text-gray-600' };
};
const connectionStatus = getConnectionStatus();
const warningInfo = getWarningInfo();
return (
<div className="space-y-4 sm:space-y-6">
{/* 环境不支持提示 */}
{!isSupported && warningInfo && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-amber-900 mb-1">{warningInfo.title}</h3>
<p className="text-amber-800 text-sm mb-3">{warningInfo.message}</p>
<Link
href="/help#desktop-share"
className="inline-flex items-center gap-2 text-sm text-amber-700 hover:text-amber-900 underline"
>
<ExternalLink className="w-4 h-4" />
</Link>
</div>
</div>
</div>
)}
{/* 模式选择器 */}
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
@@ -201,6 +167,7 @@ export default function DesktopShare({
variant={mode === 'share' ? 'default' : 'ghost'}
onClick={() => updateMode('share')}
className="px-6 py-2 rounded-lg"
disabled={!isSupported && mode === 'share'}
>
<Share className="w-4 h-4 mr-2" />
@@ -216,423 +183,15 @@ export default function DesktopShare({
</div>
</div>
{mode === 'share' ? (
/* 共享模式 */
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
{!desktopShare.connectionCode ? (
// 创建房间前的界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600"></p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${desktopShare.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={desktopShare.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${desktopShare.isPeerConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={desktopShare.isPeerConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
</div>
</div>
</div>
</div>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
<Monitor className="w-10 h-10 text-purple-500" />
</div>
<h3 className="text-xl font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
onClick={handleCreateRoom}
disabled={isLoading || desktopShare.isConnecting}
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Share className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
</div>
) : (
// 房间已创建,显示取件码和等待界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">
{desktopShare.isPeerConnected ? '✅ 接收方已连接,现在可以开始共享桌面' :
desktopShare.isWebSocketConnected ? '⏳ 房间已创建等待接收方加入建立P2P连接' :
'⚠️ 等待连接'}
</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${desktopShare.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
<span className={desktopShare.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${desktopShare.isPeerConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
<span className={desktopShare.isPeerConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
</div>
</div>
</div>
</div>
{/* 桌面共享控制区域 */}
{desktopShare.canStartSharing && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<Monitor className="w-5 h-5 mr-2" />
</h4>
{desktopShare.isSharing && (
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="font-medium"></span>
</div>
)}
</div>
<div className="space-y-4">
{!desktopShare.isSharing ? (
<div className="space-y-3">
<Button
onClick={handleStartSharing}
disabled={isLoading || !desktopShare.isPeerConnected}
className={`w-full px-8 py-3 text-lg font-medium rounded-xl shadow-lg ${
desktopShare.isPeerConnected
? 'bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
<Play className="w-5 h-5 mr-2" />
{isLoading ? '启动中...' : '选择并开始共享桌面'}
</Button>
{!desktopShare.isPeerConnected && (
<div className="text-center">
<p className="text-sm text-gray-500 mb-2">
P2P连接...
</p>
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
<span className="text-sm text-purple-600"></span>
</div>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center space-x-2 text-green-600 mb-4">
<Play className="w-5 h-5" />
<span className="font-semibold"></span>
</div>
<div className="flex justify-center space-x-3">
<Button
onClick={handleSwitchDesktop}
disabled={isLoading}
variant="outline"
size="sm"
>
<Repeat className="w-4 h-4 mr-2" />
{isLoading ? '切换中...' : '切换桌面'}
</Button>
<Button
onClick={handleStopSharing}
disabled={isLoading}
variant="destructive"
size="sm"
>
<Square className="w-4 h-4 mr-2" />
{isLoading ? '停止中...' : '停止共享'}
</Button>
</div>
</div>
)}
</div>
</div>
)}
{/* 取件码显示 - 和文件传输一致的风格 */}
<div className="border-t border-slate-200 pt-6">
{/* 左上角状态提示 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600"></p>
</div>
</div>
</div>
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:取件码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent tracking-wider">
{desktopShare.connectionCode}
</div>
</div>
<Button
onClick={() => copyCode(desktopShare.connectionCode)}
className="w-full px-4 py-2.5 bg-purple-500 hover:bg-purple-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
>
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
size={120}
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
使
</div>
</div>
</div>
{/* 底部:观看链接 */}
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
</div>
</div>
<Button
onClick={() => {
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
navigator.clipboard.writeText(link);
showToast('观看链接已复制', 'success');
}}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
</Button>
</div>
</div>
</div>
</div>
)}
</div>
) : (
/* 观看模式 */
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<div className="space-y-6">
{!desktopShare.isViewing ? (
// 输入房间代码界面 - 与文本消息风格一致
<div>
<div className="flex items-center mb-6 sm:mb-8">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">6</p>
</div>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
<div className="space-y-3">
<div className="relative">
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
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}
/>
</div>
<p className="text-center text-xs sm:text-sm text-slate-500">
{inputCode.length}/6
</p>
</div>
<div className="flex justify-center">
<Button
type="submit"
disabled={inputCode.length !== 6 || isLoading}
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 ? (
<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>
) : (
<div className="flex items-center space-x-2">
<Monitor className="w-5 h-5" />
<span></span>
</div>
)}
</Button>
</div>
</form>
</div>
) : (
// 已连接,显示桌面观看界面
<div className="space-y-6">
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">
<span className="text-emerald-600"> </span>
</p>
</div>
</div>
</div>
{/* 连接成功状态 */}
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
<h4 className="font-semibold text-emerald-800 mb-1"></h4>
<p className="text-emerald-700">: {inputCode}</p>
</div>
{/* 观看中的控制面板 */}
<div className="flex justify-center mb-4">
<div className="bg-white rounded-lg p-3 shadow-lg border flex items-center space-x-4">
<div className="flex items-center space-x-2 text-green-600">
<Monitor className="w-4 h-4" />
<span className="font-semibold"></span>
</div>
<Button
onClick={handleStopViewing}
disabled={isLoading}
variant="destructive"
size="sm"
>
<Square className="w-4 h-4 mr-2" />
{isLoading ? '退出中...' : '退出观看'}
</Button>
</div>
</div>
{/* 桌面显示区域 */}
{desktopShare.remoteStream ? (
<DesktopViewer
stream={desktopShare.remoteStream}
isConnected={desktopShare.isViewing}
connectionCode={inputCode}
onDisconnect={handleStopViewing}
/>
) : (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
<div className="text-center">
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
<p className="text-slate-600 mb-2">...</p>
<p className="text-sm text-slate-500"></p>
<div className="flex items-center justify-center space-x-2 mt-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
<span className="text-sm text-purple-600">...</span>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* 错误显示 */}
{desktopShare.error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{desktopShare.error}</p>
</div>
)}
{/* 调试信息 */}
<div className="mt-6">
<button
onClick={() => setShowDebug(!showDebug)}
className="text-xs text-gray-500 hover:text-gray-700"
>
{showDebug ? '隐藏' : '显示'}
</button>
{showDebug && (
<div className="mt-2 p-3 bg-gray-50 rounded text-xs text-gray-600 space-y-1">
<div>WebSocket连接: {desktopShare.isWebSocketConnected ? '✅' : '❌'}</div>
<div>P2P连接: {desktopShare.isPeerConnected ? '✅' : '❌'}</div>
<div>: {desktopShare.connectionCode || '未创建'}</div>
<div>: {desktopShare.isSharing ? '进行中' : '未共享'}</div>
<div>: {desktopShare.isViewing ? '观看中' : '未观看'}</div>
<div>: {desktopShare.isWaitingForPeer ? '是' : '否'}</div>
<div>: {desktopShare.remoteStream ? '已接收' : '无'}</div>
</div>
{/* 根据模式渲染对应的组件 */}
<div>
{mode === 'share' ? (
<WebRTCDesktopSender onConnectionChange={handleConnectionChange} />
) : (
<WebRTCDesktopReceiver
initialCode={getInitialCode()}
onConnectionChange={handleConnectionChange}
/>
)}
</div>
</div>

View File

@@ -1,8 +1,8 @@
"use client";
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Maximize, Minimize, Monitor, Play, Settings, Volume2, VolumeX, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface DesktopViewerProps {
stream: MediaStream | null;
@@ -11,17 +11,20 @@ interface DesktopViewerProps {
onDisconnect: () => void;
}
export default function DesktopViewer({
stream,
isConnected,
connectionCode,
onDisconnect
export default function DesktopViewer({
stream,
isConnected,
connectionCode,
onDisconnect
}: DesktopViewerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const [showControls, setShowControls] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [needsUserInteraction, setNeedsUserInteraction] = useState(false);
const hasAttemptedAutoplayRef = useRef(false);
const [videoStats, setVideoStats] = useState<{
resolution: string;
fps: number;
@@ -34,14 +37,104 @@ export default function DesktopViewer({
if (videoRef.current && stream) {
console.log('[DesktopViewer] 🎬 设置视频流,轨道数量:', stream.getTracks().length);
stream.getTracks().forEach(track => {
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, track.enabled, track.readyState);
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, '启用:', track.enabled, '状态:', track.readyState);
// 确保轨道已启用
if (!track.enabled) {
console.log('[DesktopViewer] 🔓 启用轨道:', track.id);
track.enabled = true;
}
});
videoRef.current.srcObject = stream;
console.log('[DesktopViewer] ✅ 视频元素已设置流');
videoRef.current.muted = true; // 确保默认静音
console.log('[DesktopViewer] ✅ 视频元素已设置流并静音');
// 重置状态
hasAttemptedAutoplayRef.current = false;
setNeedsUserInteraction(false);
setIsPlaying(false);
// 添加事件监听器来调试视频加载
const video = videoRef.current;
const handleLoadStart = () => console.log('[DesktopViewer] 📹 视频开始加载');
const handleLoadedMetadata = () => {
console.log('[DesktopViewer] 📹 视频元数据已加载');
console.log('[DesktopViewer] 📹 视频尺寸:', video.videoWidth, 'x', video.videoHeight);
};
const handleCanPlay = () => {
console.log('[DesktopViewer] 📹 视频可以开始播放');
// 只在还未尝试过自动播放时才尝试
if (!hasAttemptedAutoplayRef.current) {
hasAttemptedAutoplayRef.current = true;
video.play()
.then(() => {
console.log('[DesktopViewer] ✅ 视频自动播放成功');
setIsPlaying(true);
setNeedsUserInteraction(false);
})
.catch(e => {
console.log('[DesktopViewer] 📹 自动播放被阻止,需要用户交互:', e.message);
setIsPlaying(false);
setNeedsUserInteraction(true);
});
}
};
const handlePlay = () => {
console.log('[DesktopViewer] 📹 视频开始播放');
setIsPlaying(true);
setNeedsUserInteraction(false);
};
const handlePause = () => {
console.log('[DesktopViewer] 📹 视频暂停');
setIsPlaying(false);
};
const handleError = (e: Event) => {
console.error('[DesktopViewer] 📹 视频播放错误:', e);
// 尝试重新加载流
console.log('[DesktopViewer] 🔄 尝试重新加载视频流');
setTimeout(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = null;
videoRef.current.srcObject = stream;
if (!hasAttemptedAutoplayRef.current) {
hasAttemptedAutoplayRef.current = true;
videoRef.current.play()
.then(() => {
console.log('[DesktopViewer] ✅ 重新加载后视频播放成功');
setIsPlaying(true);
setNeedsUserInteraction(false);
})
.catch(err => {
console.log('[DesktopViewer] 📹 重新加载后自动播放仍被阻止:', err.message);
setIsPlaying(false);
setNeedsUserInteraction(true);
});
}
}
}, 1000);
};
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('error', handleError);
return () => {
video.removeEventListener('loadstart', handleLoadStart);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('error', handleError);
};
} else if (videoRef.current && !stream) {
console.log('[DesktopViewer] ❌ 清除视频流');
videoRef.current.srcObject = null;
setIsPlaying(false);
setNeedsUserInteraction(false);
hasAttemptedAutoplayRef.current = false;
}
}, [stream]);
@@ -76,32 +169,58 @@ export default function DesktopViewer({
const handleFullscreenChange = () => {
const isCurrentlyFullscreen = !!document.fullscreenElement;
setIsFullscreen(isCurrentlyFullscreen);
if (isCurrentlyFullscreen) {
// 全屏时自动隐藏控制栏,鼠标移动时显示
setShowControls(false);
} else {
// 退出全屏时显示控制栏
setShowControls(true);
// 延迟检查视频状态,确保全屏切换完成
setTimeout(() => {
if (videoRef.current && stream) {
console.log('[DesktopViewer] 🔄 退出全屏,检查视频状态');
// 确保视频流正确设置
const currentSrcObject = videoRef.current.srcObject;
if (!currentSrcObject || currentSrcObject !== stream) {
videoRef.current.srcObject = stream;
}
// 检查视频是否暂停
if (videoRef.current.paused) {
console.log('[DesktopViewer] ⏸️ 退出全屏后视频已暂停,显示播放按钮');
setIsPlaying(false);
setNeedsUserInteraction(true);
hasAttemptedAutoplayRef.current = true; // 标记已尝试过自动播放
} else {
console.log('[DesktopViewer] ▶️ 退出全屏后视频仍在播放');
setIsPlaying(true);
setNeedsUserInteraction(false);
}
}
}, 200); // 延迟200ms确保全屏切换完成
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
}, [stream]);
// 鼠标移动处理(全屏时)
const handleMouseMove = useCallback(() => {
if (isFullscreen) {
setShowControls(true);
// 清除之前的定时器
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
// 3秒后自动隐藏控制栏
hideControlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
@@ -136,7 +255,7 @@ export default function DesktopViewer({
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
@@ -144,13 +263,43 @@ export default function DesktopViewer({
// 切换全屏
const toggleFullscreen = useCallback(async () => {
if (!containerRef.current) return;
if (!videoRef.current) return;
try {
if (isFullscreen) {
await document.exitFullscreen();
// 退出全屏
if (document.fullscreenElement) {
await document.exitFullscreen();
}
// 退出iOS全屏模式
if ((document as any).webkitExitFullscreen) {
await (document as any).webkitExitFullscreen();
}
// 退出视频全屏模式
if ((videoRef.current as any).webkitExitFullscreen) {
await (videoRef.current as any).webkitExitFullscreen();
}
// 退出Android全屏模式
if ((videoRef.current as any).exitFullscreen) {
await (videoRef.current as any).exitFullscreen();
}
} else {
await containerRef.current.requestFullscreen();
// 进入标准全屏
if (videoRef.current.requestFullscreen) {
await videoRef.current.requestFullscreen();
}
// 进入iOS全屏模式
else if ((videoRef.current as any).webkitRequestFullscreen) {
await (videoRef.current as any).webkitRequestFullscreen();
}
// 进入iOS视频全屏模式
else if ((videoRef.current as any).webkitEnterFullscreen) {
await (videoRef.current as any).webkitEnterFullscreen();
}
// 进入Android全屏模式
else if ((videoRef.current as any).requestFullscreen) {
await (videoRef.current as any).requestFullscreen();
}
}
} catch (error) {
console.error('[DesktopViewer] 全屏切换失败:', error);
@@ -160,9 +309,22 @@ export default function DesktopViewer({
// 退出全屏
const exitFullscreen = useCallback(async () => {
try {
// 退出标准全屏
if (document.fullscreenElement) {
await document.exitFullscreen();
}
// 退出iOS全屏模式
if ((document as any).webkitExitFullscreen) {
await (document as any).webkitExitFullscreen();
}
// 退出视频全屏模式
if (videoRef.current && (videoRef.current as any).webkitExitFullscreen) {
await (videoRef.current as any).webkitExitFullscreen();
}
// 退出Android全屏模式
if (videoRef.current && (videoRef.current as any).exitFullscreen) {
await (videoRef.current as any).exitFullscreen();
}
} catch (error) {
console.error('[DesktopViewer] 退出全屏失败:', error);
}
@@ -176,6 +338,21 @@ export default function DesktopViewer({
}
}, []);
// 手动播放视频
const handleManualPlay = useCallback(() => {
if (videoRef.current) {
videoRef.current.play()
.then(() => {
console.log('[DesktopViewer] ✅ 手动播放成功');
setIsPlaying(true);
setNeedsUserInteraction(false);
})
.catch(e => {
console.error('[DesktopViewer] ❌ 手动播放失败:', e);
});
}
}, []);
// 清理定时器
useEffect(() => {
return () => {
@@ -217,12 +394,25 @@ export default function DesktopViewer({
playsInline
muted={isMuted}
className={`w-full h-full object-contain ${isFullscreen ? 'cursor-none' : ''}`}
style={{
style={{
aspectRatio: isFullscreen ? 'unset' : '16/9',
minHeight: isFullscreen ? '100vh' : '400px'
}}
/>
{/* 需要用户交互的播放覆盖层 - 在视频暂停时显示 */}
{((needsUserInteraction && !isPlaying) || (isConnected && !isPlaying && !needsUserInteraction && videoRef.current?.paused)) && (
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-white z-10">
<div className="text-center">
<div className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer" onClick={handleManualPlay}>
<Play className="w-10 h-10 text-white ml-1" />
</div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm opacity-75"></p>
</div>
</div>
)}
{/* 连接状态覆盖层 */}
{!isConnected && (
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center text-white">
@@ -236,27 +426,26 @@ export default function DesktopViewer({
{/* 控制栏 */}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-all duration-300 ${
showControls || !isFullscreen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-all duration-300 ${showControls || !isFullscreen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{/* 左侧信息 */}
<div className="flex items-center space-x-4 text-white text-sm">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span></span>
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
<span className="text-xs sm:text-sm">{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : isConnected ? '已暂停' : '连接中'}</span>
</div>
{videoStats.resolution !== '0x0' && (
<>
<div className="w-px h-4 bg-white/30"></div>
<span>{videoStats.resolution}</span>
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
<span className="text-xs sm:text-sm hidden sm:block">{videoStats.resolution}</span>
</>
)}
{connectionCode && (
<>
<div className="w-px h-4 bg-white/30"></div>
<span className="font-mono">{connectionCode}</span>
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
<span className="font-mono text-xs sm:text-sm hidden sm:block">{connectionCode}</span>
</>
)}
</div>
@@ -281,7 +470,7 @@ export default function DesktopViewer({
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/20"
className="text-white hover:bg-white/20 hidden sm:flex"
>
<Settings className="w-4 h-4" />
</Button>
@@ -322,6 +511,19 @@ export default function DesktopViewer({
)}
</div>
{/* 移动端浮动全屏按钮 - 在控制栏隐藏时显示 */}
{!isFullscreen && (
<Button
variant="ghost"
size="lg"
onClick={toggleFullscreen}
className="fixed bottom-20 right-4 z-40 md:hidden bg-black/60 text-white hover:bg-black/80 rounded-full p-3 shadow-lg"
title="全屏"
>
<Maximize className="w-5 h-5" />
</Button>
)}
{/* 加载状态 */}
{stream && !isConnected && (
<div className="absolute top-4 left-4 bg-black/60 text-white px-3 py-2 rounded-lg text-sm flex items-center space-x-2">
@@ -333,9 +535,8 @@ export default function DesktopViewer({
{/* 网络状态指示器 */}
<div className="absolute top-4 right-4 bg-black/60 text-white px-3 py-2 rounded-lg text-xs">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'
}`}></div>
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'
}`}></div>
<span>{isConnected ? '已连接' : '连接中'}</span>
</div>
</div>

View File

@@ -0,0 +1,64 @@
"use client";
import React from 'react';
import { Github, HelpCircle, MessageCircle, Bug } from 'lucide-react';
import Link from 'next/link';
export default function Footer() {
return (
<footer className="mt-auto py-4 shrink-0">
<div className="container mx-auto px-4">
{/* 分割线 */}
<div className="w-full h-px bg-gradient-to-r from-transparent via-slate-200 to-transparent mb-4"></div>
{/* 链接区域 */}
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 text-sm">
<Link
href="/help"
className="text-slate-500 hover:text-blue-500 transition-colors duration-200 flex items-center gap-1"
>
<HelpCircle className="w-3.5 h-3.5" />
</Link>
<a
href="https://github.com/MatrixSeven/file-transfer-go"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-slate-700 transition-colors duration-200 flex items-center gap-1"
>
<Github className="w-3.5 h-3.5" />
</a>
<a
href="https://x.com/_MatrixSeven"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-blue-400 transition-colors duration-200 flex items-center gap-1"
>
<MessageCircle className="w-3.5 h-3.5" />
X
</a>
<a
href="https://github.com/MatrixSeven/file-transfer-go/issues"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-orange-500 transition-colors duration-200 flex items-center gap-1"
>
<Bug className="w-3.5 h-3.5" />
Issue
</a>
</div>
{/* 版权信息 */}
<div className="text-center mt-3">
<p className="text-xs text-slate-400">
WebRTC
</p>
</div>
</div>
</footer>
);
}

View File

@@ -1,7 +1,8 @@
"use client";
import React from 'react';
import { Github } from 'lucide-react';
import { Github, HelpCircle } from 'lucide-react';
import Link from 'next/link';
export default function Hero() {
return (
@@ -10,12 +11,10 @@ export default function Hero() {
</h1>
<p className="text-sm sm:text-base text-slate-600 max-w-xl mx-auto leading-relaxed px-4 mb-3">
<br />
<span className="text-xs sm:text-sm text-slate-500">WebRTC的端到端服务 - </span>
</p>
{/* GitHub开源链接 */}
{/* GitHub开源链接和帮助 */}
<div className="flex items-center justify-center gap-2 mb-4">
<a
href="https://github.com/MatrixSeven/file-transfer-go"
@@ -24,21 +23,20 @@ export default function Hero() {
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-slate-600 hover:text-slate-800 bg-slate-100 hover:bg-slate-200 rounded-full transition-colors duration-200 border border-slate-200 hover:border-slate-300"
>
<Github className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium"></span>
<span className="font-medium"></span>
</a>
<span className="text-xs text-slate-400">|</span>
<a
href="https://github.com/MatrixSeven/file-transfer-go"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-slate-500 hover:text-slate-700 hover:underline transition-colors duration-200"
<Link
href="/help"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-blue-600 hover:text-blue-800 bg-blue-50 hover:bg-blue-100 rounded-full transition-colors duration-200 border border-blue-200 hover:border-blue-300"
>
https://github.com/MatrixSeven/file-transfer-go
</a>
<HelpCircle className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium">使</span>
</Link>
</div>
{/* 分割线 */}
<div className="w-64 sm:w-80 md:w-96 lg:w-[32rem] xl:w-[40rem] h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-2 mb-2 opacity-60"></div>
<div className="w-full max-w-2xl h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-2 mb-2 opacity-60"></div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import React from 'react';
import { Button } from '@/components/ui/button';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import { LucideIcon } from 'lucide-react';
interface RoomInfoDisplayProps {
// 房间信息
code: string;
link: string;
// 显示配置
icon: LucideIcon;
iconColor?: string; // 图标背景渐变色,如 'from-emerald-500 to-teal-500'
codeColor?: string; // 代码文字渐变色,如 'from-emerald-600 to-teal-600'
// 文案配置
title: string; // 如 "取件码生成成功!" 或 "房间码生成成功!"
subtitle: string; // 如 "分享以下信息给接收方" 或 "分享以下信息给观看方"
codeLabel: string; // 如 "取件码" 或 "房间代码"
qrLabel: string; // 如 "扫码传输" 或 "扫码观看"
copyButtonText: string; // 如 "复制取件码" 或 "复制房间代码"
copyButtonColor?: string; // 复制按钮颜色,如 'bg-emerald-500 hover:bg-emerald-600'
qrButtonText: string; // 如 "使用手机扫码快速访问" 或 "使用手机扫码快速观看"
linkButtonText: string; // 如 "复制取件链接" 或 "复制观看链接"
// 事件回调
onCopyCode: () => void;
onCopyLink: () => void;
// 样式配置
className?: string;
}
export default function RoomInfoDisplay({
code,
link,
icon: Icon,
iconColor = 'from-emerald-500 to-teal-500',
codeColor = 'from-emerald-600 to-teal-600',
title,
subtitle,
codeLabel,
qrLabel,
copyButtonText,
copyButtonColor = 'bg-emerald-500 hover:bg-emerald-600',
qrButtonText,
linkButtonText,
onCopyCode,
onCopyLink,
className = ''
}: RoomInfoDisplayProps) {
return (
<div className={`border-t border-slate-200 pt-6 ${className}`}>
{/* 左上角状态提示 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 bg-gradient-to-br ${iconColor} rounded-xl flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800">{title}</h3>
<p className="text-sm text-slate-600">{subtitle}</p>
</div>
</div>
</div>
{/* 中间区域:代码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:代码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3">{codeLabel}</label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className={`text-2xl font-bold font-mono bg-gradient-to-r ${codeColor} bg-clip-text text-transparent tracking-wider`}>
{code}
</div>
</div>
<Button
onClick={onCopyCode}
className={`w-full px-4 py-2.5 ${copyButtonColor} text-white rounded-lg font-medium shadow transition-all duration-200 mt-3`}
>
{copyButtonText}
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3">{qrLabel}</label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={link}
size={120}
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
{qrButtonText}
</div>
</div>
</div>
{/* 底部:链接 */}
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{link}
</div>
</div>
<Button
onClick={onCopyLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
{linkButtonText}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { Mic, MicOff } from 'lucide-react';
interface VoiceIndicatorProps {
volume: number; // 0-100
isSpeaking: boolean;
isMuted?: boolean;
className?: string;
}
export function VoiceIndicator({
volume,
isSpeaking,
isMuted = false,
className = '',
}: VoiceIndicatorProps) {
// 根据音量计算波纹大小
const rippleScale = 1 + (volume / 100) * 0.8; // 1.0 到 1.8
// 音量条数量5条
const barCount = 5;
const activeBars = Math.ceil((volume / 100) * barCount);
return (
<div className={`flex items-center space-x-2 ${className}`}>
{/* 麦克风图标和波纹效果 */}
<div className="relative flex items-center justify-center">
{/* 波纹动画 - 只在说话时显示 */}
{isSpeaking && !isMuted && (
<>
<div
className="absolute w-10 h-10 rounded-full bg-green-500 opacity-20 animate-ping"
style={{
animationDuration: '1s',
transform: `scale(${rippleScale})`,
}}
/>
<div
className="absolute w-10 h-10 rounded-full bg-green-400 opacity-30"
style={{
transform: `scale(${rippleScale})`,
transition: 'transform 0.1s ease-out',
}}
/>
</>
)}
{/* 麦克风图标 */}
<div
className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center transition-colors ${
isMuted
? 'bg-red-100 text-red-600'
: isSpeaking
? 'bg-green-100 text-green-600'
: 'bg-slate-100 text-slate-600'
}`}
>
{isMuted ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</div>
</div>
{/* 音量条 - 10个等级 */}
<div className="flex items-center space-x-0.5">
{Array.from({ length: barCount }).map((_, index) => {
const isActive = index < activeBars && !isMuted;
const height = 8 + index * 1.5; // 递增高度: 8, 9.5, 11, 12.5... 到 21.5
return (
<div
key={index}
className={`w-1 rounded-full transition-all duration-150 ${
isActive
? isSpeaking
? 'bg-green-500'
: 'bg-slate-400'
: 'bg-slate-200'
}`}
style={{
height: `${height}px`,
opacity: isActive ? 1 : 0.3,
}}
/>
);
})}
</div>
</div>
);
}

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/09/04/68b8f0d135edc.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

@@ -1,14 +1,14 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { WebRTCFileReceive } from '@/components/webrtc/WebRTCFileReceive';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { useConnectionState, useConnectManager, useRoomConnection } from '@/hooks/connection';
import { useFileListSync, useFileStateManager, useFileTransferBusiness } from '@/hooks/file-transfer';
import { useURLHandler } from '@/hooks/ui';
import { Download, Upload } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
interface FileInfo {
id: string;
@@ -20,30 +20,22 @@ 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);
// 创建共享连接
const connection = useSharedWebRTCManager();
const connection = useConnectManager();
const stableConnection = useMemo(() => connection, [connection.getConnectState().isConnected, connection.getConnectState().isConnecting, connection.getConnectState().isWebSocketConnected, connection.getConnectState().error]);
// 使用共享连接创建业务层
const {
@@ -59,244 +51,75 @@ export const WebRTCFileTransfer: React.FC = () => {
onFileReceived,
onFileListReceived,
onFileRequested,
onFileProgress
} = useFileTransferBusiness(connection);
onFileProgress,
clearSenderData
} = useFileTransferBusiness(stableConnection);
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
// 使用自定义 hooks
const { syncFileListToReceiver } = useFileListSync({
sendFileList,
mode,
pickupCode,
isConnected,
isPeerConnected: connection.getConnectState().isPeerConnected,
getChannelState: () => connection.getConnectState().state
});
const {
selectedFiles,
setSelectedFiles,
fileList,
setFileList,
downloadedFiles,
setDownloadedFiles,
handleFileSelect,
clearFiles,
resetFiles,
updateFileStatus,
updateFileProgress,
clearSenderData: clearFileStateData
} = useFileStateManager({
mode,
pickupCode,
syncFileListToReceiver,
isPeerConnected: connection.getConnectState().isPeerConnected
});
const { joinRoom: originalJoinRoom } = 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) {
@@ -308,21 +131,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();
@@ -332,12 +148,12 @@ export const WebRTCFileTransfer: React.FC = () => {
}
const code = data.code;
setPickupCode(code);
console.log('房间创建成功,取件码:', code);
// 连接WebRTC作为发送方
connect(code, 'sender');
// 连接WebRTC作为发送方,再设置取件码
// 这样可以确保UI状态与连接状态同步
await connect(code, 'sender');
setPickupCode(code);
showToast(`房间创建成功,取件码: ${code}`, "success");
} catch (error) {
@@ -369,23 +185,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);
@@ -395,6 +205,100 @@ 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;
}
// 更新当前传输文件信息
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;
}
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
// 在发送方的selectedFiles中查找对应文件
const file = selectedFiles.find(f => f.name === fileName);
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading - 统一使用updateFileStatus
updateFileStatus(fileId, 'downloading', 0);
// 发送文件
try {
sendFile(file, fileId);
// 移除不必要的Toast - 传输开始状态在UI中已经显示
} catch (sendError) {
console.error('发送文件失败:', sendError);
showToast(`发送文件失败: ${fileName}`, "error");
// 重置文件状态 - 统一使用updateFileStatus
updateFileStatus(fileId, 'ready', 0);
}
} else {
console.warn('接收模式下收到文件请求,忽略');
}
});
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error, showToast, updateFileStatus]);
// 处理连接错误
const [lastError, setLastError] = useState<string>('');
useEffect(() => {
@@ -439,156 +343,48 @@ export const WebRTCFileTransfer: React.FC = () => {
}
}, [error, mode, showToast, lastError]);
// 处理文件接收
// 监听连接状态变化和清理传输状态
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)));
// 更新文件状态
setFileList(prev => prev.map(item =>
item.id === fileData.id
? { ...item, status: 'completed' as const, progress: 100 }
: item
));
// 移除不必要的Toast - 文件完成状态在UI中已经显示
});
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) => {
console.log('=== 收到文件请求 ===');
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
if (mode === 'send') {
// 检查连接状态
if (!isConnected || error) {
console.log('连接已断开,无法发送文件');
showToast('连接已断开,无法发送文件', "error");
return;
}
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
// 在发送方的selectedFiles中查找对应文件
const file = selectedFiles.find(f => f.name === fileName);
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading
setFileList(prev => prev.map(item =>
item.id === fileId || item.name === fileName
? { ...item, status: 'downloading' as const, progress: 0 }
: item
));
// 发送文件
try {
sendFile(file, fileId);
// 移除不必要的Toast - 传输开始状态在UI中已经显示
} catch (sendError) {
console.error('发送文件失败:', sendError);
showToast(`发送文件失败: ${fileName}`, "error");
// 重置文件状态
setFileList(prev => prev.map(item =>
item.id === fileId || item.name === fileName
? { ...item, status: 'ready' as const, progress: 0 }
: item
));
}
} else {
console.warn('接收模式下收到文件请求,忽略');
}
});
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;
});
}
}
@@ -597,34 +393,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('连接状态:', {
@@ -636,65 +407,115 @@ 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.getConnectState().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);
const connectState = connection.getConnectState();
const isPeerConnected = connectState.isPeerConnected;
const isDataChannelConnected = connectState.isDataChannelConnected;
const isChannelOpen = connectState.state === 'open';
const isConnected = connectState.isConnected;
// 使用更宽松的条件检查连接状态
const isReady = isPeerConnected || isDataChannelConnected || isChannelOpen || isConnected;
if (isReady && mode === 'send' && fileList.length > 0) {
console.log('连接已建立,初始化文件列表:', {
isPeerConnected,
isDataChannelConnected,
isChannelOpen,
isConnected,
fileListLength: fileList.length
});
// 数据通道第一次打开时进行初始化
syncFileListToReceiver(fileList, '数据通道初始化');
}
}, [connection.isPeerConnected, mode, fileList.length, sendFileList]);
}, [connection.getConnectState().isPeerConnected, connection.getConnectState().isDataChannelConnected, connection.getConnectState().state, connection.getConnectState().isConnected, mode, fileList.length, syncFileListToReceiver]);
// 监听fileList大小变化并同步
useEffect(() => {
const connectState = connection.getConnectState();
const isPeerConnected = connectState.isPeerConnected;
const isDataChannelConnected = connectState.isDataChannelConnected;
const isChannelOpen = connectState.state === 'open';
const isConnected = connectState.isConnected;
// 使用更宽松的条件检查连接状态
const isReady = isPeerConnected || isDataChannelConnected || isChannelOpen || isConnected;
if (isReady && mode === 'send' && pickupCode) {
console.log('fileList大小变化同步到接收方:', {
fileListLength: fileList.length,
isPeerConnected,
isDataChannelConnected,
isChannelOpen,
isConnected
});
syncFileListToReceiver(fileList, 'fileList大小变化');
}
}, [fileList.length, connection.getConnectState().isPeerConnected, connection.getConnectState().isDataChannelConnected, connection.getConnectState().state, connection.getConnectState().isConnected, mode, pickupCode, syncFileListToReceiver]);
// 监听接收方离开房间事件
useEffect(() => {
const connectState = connection.getConnectState();
const isPeerConnected = connectState.isPeerConnected;
const isConnected = connectState.isConnected;
// 当接收方离开房间时P2P连接断开清除发送方数据
if (mode === 'send' && pickupCode && !isPeerConnected && !isConnected) {
console.log('[WebRTCFileTransfer] 检测到接收方离开房间,清除发送方数据');
// 清除文件传输业务逻辑中的数据
clearSenderData();
// 清除文件状态管理器中的数据
clearFileStateData();
}
}, [connection.getConnectState().isPeerConnected, connection.getConnectState().isConnected, mode, pickupCode, clearSenderData, clearFileStateData, showToast]);
// 监听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) => {
@@ -778,17 +599,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);
@@ -846,6 +656,8 @@ export const WebRTCFileTransfer: React.FC = () => {
{mode === 'send' ? (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
{/* 连接状态显示 */}
<WebRTCFileUpload
selectedFiles={selectedFiles}
fileList={fileList}
@@ -860,22 +672,21 @@ export const WebRTCFileTransfer: React.FC = () => {
onClearFiles={clearFiles}
onReset={resetRoom}
disabled={!!currentTransferFile}
isConnected={isConnected}
isWebSocketConnected={isWebSocketConnected}
/>
</div>
) : (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<WebRTCFileReceive
onJoinRoom={joinRoom}
files={fileList}
onDownloadFile={handleDownloadRequest}
isConnected={isConnected}
isConnecting={isConnecting}
isWebSocketConnected={isWebSocketConnected}
downloadedFiles={downloadedFiles}
error={error}
onReset={resetConnection}
pickupCode={pickupCode}
/>
</div>
)}

View File

@@ -0,0 +1,565 @@
"use client";
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import { useWebRTCStore } from '@/hooks/connection/state/webConnectStore';
import { IceServerConfig, useIceServersConfig } from '@/hooks/settings/useIceServersConfig';
import {
AlertTriangle,
Database,
Eye,
EyeOff,
Info,
Plus,
RotateCcw,
Save,
Server,
Settings,
Shield,
Trash2,
Wifi,
X
} from 'lucide-react';
import React, { useState } from 'react';
interface AddServerModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (config: Omit<IceServerConfig, 'id'>) => void;
validateServer: (config: Omit<IceServerConfig, 'id'>) => string[];
}
function AddServerModal({ isOpen, onClose, onSubmit, validateServer }: AddServerModalProps) {
const [formData, setFormData] = useState({
urls: '',
username: '',
credential: '',
type: 'stun' as 'stun' | 'turn',
enabled: true,
});
const [errors, setErrors] = useState<string[]>([]);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateServer(formData);
if (validationErrors.length > 0) {
setErrors(validationErrors);
return;
}
onSubmit(formData);
onClose();
// 重置表单
setFormData({
urls: '',
username: '',
credential: '',
type: 'stun',
enabled: true,
});
setErrors([]);
};
const handleTypeChange = (type: 'stun' | 'turn') => {
setFormData(prev => ({
...prev,
type,
username: type === 'stun' ? '' : prev.username,
credential: type === 'stun' ? '' : prev.credential,
}));
setErrors([]);
};
const handleClose = () => {
onClose();
setErrors([]);
setFormData({
urls: '',
username: '',
credential: '',
type: 'stun',
enabled: true,
});
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={(e) => {
// 点击背景关闭弹窗
if (e.target === e.currentTarget) {
handleClose();
}
}}
>
<div
className="bg-white rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto border border-gray-200 mx-4"
onClick={(e) => e.stopPropagation()} // 防止点击弹窗内容时关闭
>
<div className="p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 flex items-center gap-2">
<Plus className="w-5 h-5 text-blue-600" />
<span className="hidden sm:inline">ICE服务器</span>
<span className="sm:hidden"></span>
</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 服务器类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
value="stun"
checked={formData.type === 'stun'}
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
className="text-blue-600"
/>
<span className="text-sm">STUN</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
value="turn"
checked={formData.type === 'turn'}
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
className="text-blue-600"
/>
<span className="text-sm">TURN</span>
</label>
</div>
</div>
{/* 服务器地址 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<Input
value={formData.urls}
onChange={(e) => setFormData(prev => ({ ...prev, urls: e.target.value }))}
placeholder={formData.type === 'stun' ? 'stun:your-server.com:3478' : 'turn:your-server.com:3478'}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
{formData.type === 'stun' ?
'格式: stun:服务器地址:端口' :
'格式: turn:服务器地址:端口'
}
</p>
</div>
{/* TURN服务器认证信息 */}
{formData.type === 'turn' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<Input
value={formData.username}
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
placeholder="输入TURN服务器用户名"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={formData.credential}
onChange={(e) => setFormData(prev => ({ ...prev, credential: e.target.value }))}
placeholder="输入TURN服务器密码"
className="w-full pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 min-h-[44px] min-w-[44px] flex items-center justify-center"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</>
)}
{/* 错误信息 */}
{errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-red-800"></p>
<ul className="text-sm text-red-700 mt-1 space-y-1">
{errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button type="submit" className="flex-1 min-h-[44px] justify-center">
<Save className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="sm:w-auto min-h-[44px] justify-center"
>
</Button>
</div>
</form>
</div>
</div>
</div>
);
}
interface ServerItemProps {
server: IceServerConfig;
onRemove: (id: string) => void;
canRemove: boolean;
}
function ServerItem({ server, onRemove, canRemove }: ServerItemProps) {
return (
<div className="border rounded-lg p-3 sm:p-4 bg-white">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
{server.type?.toUpperCase() || 'STUN'}
</span>
{server.isDefault && (
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded whitespace-nowrap">
</span>
)}
<span className="text-sm text-gray-700 break-all">
{server.urls}
</span>
</div>
{server.username && (
<p className="text-xs text-gray-500">
: {server.username}
</p>
)}
</div>
<div className="flex items-center justify-end sm:justify-start sm:ml-4">
{canRemove && (
<Button
variant="outline"
onClick={() => onRemove(server.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 min-h-[44px] min-w-[44px] px-3 py-2 sm:min-h-[36px] sm:min-w-[36px]"
>
<Trash2 className="w-4 h-4" />
<span className="ml-2 sm:hidden"></span>
</Button>
)}
</div>
</div>
</div>
);
}
export default function WebRTCSettings() {
const {
iceServers,
isLoading,
addIceServer,
removeIceServer,
resetToDefault,
validateServer,
} = useIceServersConfig();
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
const { showToast } = useToast();
// 获取WebRTC连接状态
const {
isConnected,
isConnecting,
isPeerConnected,
currentRoom
} = useWebRTCStore();
// 检查是否有活跃连接
const hasActiveConnection = isConnected || isConnecting || isPeerConnected;
const handleAddServer = (config: Omit<IceServerConfig, 'id'>) => {
try {
addIceServer(config);
showToast('ICE服务器添加成功', 'success');
} catch (error) {
showToast('添加失败,请重试', 'error');
}
};
const handleRemoveServer = (id: string) => {
setServerToDelete(id);
setShowDeleteDialog(true);
};
const confirmDeleteServer = () => {
if (serverToDelete) {
try {
removeIceServer(serverToDelete);
showToast('ICE服务器删除成功', 'success');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '删除失败';
showToast(errorMessage, 'error');
} finally {
setServerToDelete(null);
setShowDeleteDialog(false);
}
}
};
const cancelDeleteServer = () => {
setServerToDelete(null);
setShowDeleteDialog(false);
};
const handleResetToDefault = () => {
try {
resetToDefault();
showToast('已恢复默认配置', 'success');
} catch (error) {
showToast('恢复默认配置失败', 'error');
}
};
if (isLoading) {
return (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 px-4 sm:px-0">
{/* 头部 */}
<div className="text-center">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="p-2 sm:p-3 bg-blue-100 rounded-xl">
<Settings className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
</div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">WebRTC </h2>
</div>
<p className="text-sm sm:text-base text-gray-600">
STUN/TURN服务器以优化网络连接性能
</p>
</div>
{/* 隐私提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 sm:p-4">
<div className="flex items-start gap-3">
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-blue-900 mb-1 text-sm sm:text-base"></h3>
<p className="text-blue-800 text-xs sm:text-sm">
</p>
</div>
</div>
</div>
{/* 连接状态警告 */}
{hasActiveConnection && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 sm:p-4">
<div className="flex items-start gap-3">
<Wifi className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-amber-900 mb-1 text-sm sm:text-base"></h3>
<div className="text-amber-800 text-xs sm:text-sm">
<p>WebRTC连接</p>
{currentRoom && (
<p className="mt-1">
: <span className="font-mono text-xs">{currentRoom.code}</span>
({currentRoom.role === 'sender' ? '发送方' : '接收方'})
</p>
)}
<p className="mt-2 text-xs">
ICE服务器配置不会影响现有连接
</p>
</div>
</div>
</div>
</div>
)}
{/* ICE服务器列表 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-3 sm:p-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3">
<Server className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
<div>
<h3 className="text-base sm:text-lg font-semibold text-gray-900">ICE </h3>
<p className="text-xs sm:text-sm text-gray-600">
{iceServers.length}
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Button
variant="outline"
onClick={handleResetToDefault}
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={() => setShowAddModal(true)}
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
</div>
</div>
</div>
<div className="p-3 sm:p-4">
{/* 服务器列表 */}
<div className="space-y-3">
{iceServers.map((server) => (
<ServerItem
key={server.id}
server={server}
onRemove={handleRemoveServer}
canRemove={iceServers.length > 1}
/>
))}
</div>
{/* 空状态 */}
{iceServers.length === 0 && (
<div className="text-center py-8">
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 mb-4">ICE服务器配置</p>
<Button
onClick={() => setShowAddModal(true)}
className="min-h-[44px] px-6"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</div>
</div>
{/* 配置说明 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
<div className="flex items-start gap-3">
<Info className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-gray-900 mb-2 text-sm sm:text-base"></h3>
<div className="space-y-3 text-xs sm:text-sm text-gray-700">
<div>
<h4 className="font-medium text-gray-900 mb-1">STUN:</h4>
<p className="text-gray-600">
IP地址和端口P2P连接
</p>
<p className="text-gray-600 mt-1">
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">stun:服务器地址:</code>
</p>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-1">TURN:</h4>
<p className="text-gray-600">
P2P连接失败时
</p>
<p className="text-gray-600 mt-1">
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">turn:服务器地址:</code>
</p>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-1">:</h4>
<p className="text-gray-600">
ICE服务器
</p>
</div>
</div>
</div>
</div>
</div>
{/* 添加服务器弹窗 */}
<AddServerModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddServer}
validateServer={validateServer}
/>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={showDeleteDialog}
onClose={cancelDeleteServer}
onConfirm={confirmDeleteServer}
title="删除ICE服务器"
message={(() => {
if (!serverToDelete) return "确定要删除这个ICE服务器吗";
const serverToDeleteInfo = iceServers.find(s => s.id === serverToDelete);
if (iceServers.length <= 1) {
return "这是最后一个ICE服务器删除后将无法建立WebRTC连接。确定要删除吗";
} else if (serverToDeleteInfo?.isDefault) {
return "这是一个默认ICE服务器删除后可能需要手动添加其他服务器。确定要删除吗";
} else {
return "确定要删除这个ICE服务器吗删除后将无法恢复。";
}
})()}
confirmText="删除"
cancelText="取消"
type="danger"
/>
</div>
);
}

View File

@@ -1,64 +1,45 @@
"use client";
import React, { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Send, Download, X } from 'lucide-react';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { useWebRTCStore } from '@/hooks/connection/state/webConnectStore';
import { useURLHandler } from '@/hooks/ui';
import { Download, Send, X } from 'lucide-react';
import React, { useCallback, useState } from '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 = (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 });
};
// 使用统一的URL处理器
const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({
featureType: 'message',
onModeChange: setMode
});
// 重新开始函数
const handleRestart = () => {
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 });
};
clearURLParams();
}, [clearURLParams]);
const code = searchParams.get('code') || '';
const code = getCurrentRoomCode();
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
console.log('连接状态变化:', connection);
}, []);
// 关闭图片预览
const closePreview = useCallback(() => {
setPreviewImage(null);
}, []);
return (
<div className="space-y-4 sm:space-y-6">
@@ -85,24 +66,31 @@ export const WebRTCTextImageTransfer: React.FC = () => {
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
{mode === 'send' ? (
<WebRTCTextSender onRestart={handleRestart} onPreviewImage={setPreviewImage} />
<WebRTCTextSender
onRestart={handleRestart}
onPreviewImage={setPreviewImage}
onConnectionChange={handleConnectionChange}
/>
) : (
<WebRTCTextReceiver
initialCode={code}
onPreviewImage={setPreviewImage}
onRestart={handleRestart}
onConnectionChange={handleConnectionChange}
/>
)}
</div>
{/* 图片预览模态框 */}
{previewImage && (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={() => setPreviewImage(null)}>
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={closePreview}>
<div className="relative max-w-4xl max-h-4xl">
<img src={previewImage} alt="预览" className="max-w-full max-h-full" />
<Button
onClick={() => setPreviewImage(null)}
onClick={closePreview}
className="absolute top-4 right-4 bg-white text-black hover:bg-gray-200"
size="sm"
>

View File

@@ -0,0 +1,215 @@
import { WebRTCSupport, getBrowserInfo, getRecommendedBrowsers } from '@/lib/webrtc-support';
import { AlertTriangle, Chrome, Download, Monitor, X } from 'lucide-react';
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/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto border border-gray-100">
{/* 头部 */}
<div className="flex items-center justify-between p-6 bg-gradient-to-r from-rose-50 to-orange-50 border-b border-orange-100 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-r from-orange-500 to-red-500 rounded-xl shadow-lg">
<AlertTriangle className="h-6 w-6 text-white" />
</div>
<h2 className="text-xl font-bold bg-gradient-to-r from-orange-600 to-red-600 bg-clip-text text-transparent">
</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1 transition-all duration-200"
>
<X className="h-5 w-5" />
</button>
</div>
{/* 内容 */}
<div className="p-6 space-y-6">
{/* 当前浏览器信息 */}
<div className="bg-gradient-to-r from-rose-50 to-orange-50 border border-orange-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-orange-800 mb-3 flex items-center gap-2">
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between p-3 bg-white/70 rounded-lg">
<span className="text-gray-700"><strong>:</strong> {browserInfo.name} {browserInfo.version}</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/70 rounded-lg">
<span className="text-gray-700"><strong>WebRTC :</strong></span>
<span className="px-3 py-1 bg-gradient-to-r from-orange-500 to-red-500 text-white rounded-full text-xs font-medium shadow-sm">
</span>
</div>
</div>
</div>
{/* 缺失的功能 */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
</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-3 p-3 bg-gradient-to-r from-gray-50 to-blue-50 rounded-lg border border-gray-200">
<div className="w-2 h-2 bg-gradient-to-r from-orange-400 to-red-500 rounded-full"></div>
<span className="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
</div>
{/* 功能说明 */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-blue-800 mb-4 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
WebRTC
</h3>
<div className="space-y-4 text-sm">
<div className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="p-2 bg-gradient-to-r from-blue-500 to-indigo-500 rounded-lg shadow-sm">
<Monitor className="h-4 w-4 text-white" />
</div>
<div>
<div className="font-medium text-blue-800"></div>
<div className="text-blue-600"></div>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="p-2 bg-gradient-to-r from-green-500 to-emerald-500 rounded-lg shadow-sm">
<Download className="h-4 w-4 text-white" />
</div>
<div>
<div className="font-medium text-blue-800"></div>
<div className="text-blue-600"></div>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="p-2 bg-gradient-to-r from-purple-500 to-pink-500 rounded-lg shadow-sm">
<Chrome className="h-4 w-4 text-white" />
</div>
<div>
<div className="font-medium text-blue-800"></div>
<div className="text-blue-600"></div>
</div>
</div>
</div>
</div>
{/* 浏览器推荐 */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
使
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recommendedBrowsers.map((browser, index) => (
<div
key={index}
className="group border border-gray-200 rounded-xl p-4 hover:border-blue-300 hover:shadow-lg transition-all duration-200 cursor-pointer bg-gradient-to-br from-white to-gray-50 hover:from-blue-50 hover:to-indigo-50"
onClick={() => handleBrowserDownload(browser.downloadUrl)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-900 group-hover:text-blue-800 transition-colors">{browser.name}</h4>
<p className="text-sm text-gray-600 group-hover:text-blue-600 transition-colors"> {browser.minVersion}</p>
</div>
<div className="p-2 bg-gradient-to-r from-blue-500 to-indigo-500 rounded-lg shadow-sm group-hover:shadow-md transition-all duration-200">
<Download className="h-5 w-5 text-white" />
</div>
</div>
</div>
))}
</div>
</div>
{/* 浏览器特定建议 */}
{browserInfo.recommendations && (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border border-amber-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
<div className="w-2 h-2 bg-amber-500 rounded-full"></div>
</h3>
<ul className="space-y-2 text-sm">
{browserInfo.recommendations.map((recommendation, index) => (
<li key={index} className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="w-1.5 h-1.5 bg-gradient-to-r from-amber-400 to-orange-500 rounded-full mt-2 flex-shrink-0"></div>
<span className="text-amber-700">{recommendation}</span>
</li>
))}
</ul>
</div>
)}
{/* 技术详情(可折叠) */}
<details className="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<summary className="p-4 cursor-pointer font-semibold text-gray-900 hover:bg-gradient-to-r hover:from-gray-50 hover:to-blue-50 transition-all duration-200">
🔧
</summary>
<div className="p-4 border-t border-gray-200 bg-gradient-to-r from-gray-50 to-blue-50 space-y-3 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-white rounded-lg shadow-sm">
<div className="flex items-center justify-between">
<strong className="text-gray-700">RTCPeerConnection</strong>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${webrtcSupport.details.rtcPeerConnection
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white'
: 'bg-gradient-to-r from-orange-500 to-red-500 text-white'
}`}>
{webrtcSupport.details.rtcPeerConnection ? '✓ 支持' : '✗ 不支持'}
</span>
</div>
</div>
<div className="p-3 bg-white rounded-lg shadow-sm">
<div className="flex items-center justify-between">
<strong className="text-gray-700">DataChannel</strong>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${webrtcSupport.details.dataChannel
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white'
: 'bg-gradient-to-r from-orange-500 to-red-500 text-white'
}`}>
{webrtcSupport.details.dataChannel ? '✓ 支持' : '✗ 不支持'}
</span>
</div>
</div>
</div>
</div>
</details>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 p-6 border-t border-gray-200 bg-gradient-to-r from-gray-50 to-blue-50 rounded-b-2xl">
<button
onClick={onClose}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-all duration-200 shadow-sm hover:shadow-md"
>
</button>
<button
onClick={() => handleBrowserDownload('https://www.google.com/chrome/')}
className="px-6 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
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

@@ -0,0 +1,513 @@
"use client";
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Monitor, Square, Mic, MicOff } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
import { useVoiceChatBusiness } from '@/hooks/desktop-share/useVoiceChatBusiness';
import { VoiceIndicator } from '@/components/VoiceIndicator';
import DesktopViewer from '@/components/DesktopViewer';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface WebRTCDesktopReceiverProps {
className?: string;
initialCode?: string; // 支持从URL参数传入的房间代码
onConnectionChange?: (connection: any) => void;
}
export default function WebRTCDesktopReceiver({ className, initialCode, onConnectionChange }: WebRTCDesktopReceiverProps) {
const [inputCode, setInputCode] = useState(initialCode || '');
const [isLoading, setIsLoading] = useState(false);
const [isJoiningRoom, setIsJoiningRoom] = useState(false); // 添加加入房间状态
const hasTriedAutoJoin = React.useRef(false); // 添加 ref 来跟踪是否已尝试自动加入
const { showToast } = useToast();
// 使用桌面共享业务逻辑
const desktopShare = useDesktopShareBusiness();
// 使用语音通话业务逻辑
const voiceChat = useVoiceChatBusiness(desktopShare.webRTCConnection);
// 远程音频元素引用
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
// 调试:监控语音状态变化(只监听状态,不监听实时音量)
useEffect(() => {
console.log('[DesktopShareReceiver] 🎤 语音状态变化:', {
isVoiceEnabled: voiceChat.isVoiceEnabled,
isRemoteVoiceActive: voiceChat.isRemoteVoiceActive,
debug: voiceChat._debug
});
}, [
voiceChat.isVoiceEnabled,
voiceChat.isRemoteVoiceActive
// 不监听 localVolume, remoteVolume, localIsSpeaking, remoteIsSpeaking
// 这些值每帧都在变化约60fps会导致过度渲染
]);
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange && desktopShare.webRTCConnection) {
onConnectionChange(desktopShare.webRTCConnection);
}
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
// 加入观看
const handleJoinViewing = useCallback(async () => {
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 {
console.log('[DesktopShareReceiver] 开始验证房间状态...');
// 先检查房间状态
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.is_room_full) {
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "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);
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, isJoiningRoom, showToast]);
// 停止观看桌面
const handleStopViewing = useCallback(async () => {
try {
setIsLoading(true);
await desktopShare.stopViewing();
showToast('已退出桌面共享', 'success');
setInputCode('');
} catch (error) {
console.error('[DesktopShareReceiver] 停止观看失败:', error);
showToast('退出失败', 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 开启语音
const handleEnableVoice = useCallback(async () => {
try {
console.log('[DesktopShareReceiver] 用户点击开启语音');
await voiceChat.enableVoice();
showToast('语音已开启', 'success');
} catch (error) {
console.error('[DesktopShareReceiver] 开启语音失败:', error);
let errorMessage = '开启语音失败';
if (error instanceof Error) {
if (error.message.includes('麦克风权限') || error.message.includes('Permission')) {
errorMessage = '无法访问麦克风,请检查浏览器权限设置';
} else if (error.message.includes('P2P连接')) {
errorMessage = '请先等待连接建立';
} else if (error.message.includes('NotFoundError') || error.message.includes('设备')) {
errorMessage = '未检测到麦克风设备';
} else if (error.message.includes('NotAllowedError')) {
errorMessage = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风';
} else {
errorMessage = error.message;
}
}
showToast(errorMessage, 'error');
}
}, [voiceChat, showToast]);
// 如果有初始代码且还未加入观看,自动尝试加入
React.useEffect(() => {
console.log('[WebRTCDesktopReceiver] useEffect 触发, 参数:', {
initialCode,
isViewing: desktopShare.isViewing,
isConnecting: desktopShare.isConnecting,
isJoiningRoom,
hasTriedAutoJoin: hasTriedAutoJoin.current
});
const autoJoin = async () => {
if (initialCode && !desktopShare.isViewing && !desktopShare.isConnecting && !isJoiningRoom && !hasTriedAutoJoin.current) {
hasTriedAutoJoin.current = true;
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.is_room_full) {
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
return;
}
console.log('[WebRTCDesktopReceiver] 房间验证通过,开始自动连接...');
setIsLoading(true);
await desktopShare.joinSharing(trimmedCode.toUpperCase());
console.log('[WebRTCDesktopReceiver] 自动加入观看成功');
showToast('已加入桌面共享', 'success');
} catch (error) {
console.error('[WebRTCDesktopReceiver] 自动加入观看失败:', 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 {
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, isJoiningRoom]); // 添加isJoiningRoom依赖
return (
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<div className="space-y-6">
{!desktopShare.isViewing ? (
// 输入房间代码界面 - 与文本消息风格一致
<div>
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">6</p>
</div>
</div>
<ConnectionStatus
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'receiver' } : null}
/>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
<div className="space-y-3">
<div className="relative">
<Input
value={inputCode}
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 || isJoiningRoom}
/>
</div>
<p className="text-center text-xs sm:text-sm text-slate-500">
{inputCode.length}/6
</p>
</div>
<div className="flex justify-center">
<Button
type="submit"
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"
>
{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>
</div>
) : (
<div className="flex items-center space-x-2">
<Monitor className="w-5 h-5" />
<span></span>
</div>
)}
</Button>
</div>
</form>
</div>
) : (
// 已连接,显示桌面观看界面
<div className="space-y-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {inputCode}</p>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={{ code: inputCode, role: 'receiver' }}
/>
</div>
{/* 观看中的控制面板 - 移动端优化 */}
<div className="mb-4">
<div className="bg-white rounded-lg p-3 shadow-lg border">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
{/* 状态指示 */}
<div className="flex items-center space-x-2 text-green-600">
<Monitor className="w-4 h-4" />
<span className="font-semibold"></span>
</div>
{/* 对方说话提示 - 移动端全宽 */}
{voiceChat.isRemoteVoiceActive && voiceChat.remoteIsSpeaking && (
<div className="flex items-center space-x-2 bg-green-50 text-green-700 px-3 py-1.5 rounded-lg border border-green-200 animate-pulse">
<div className="w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
<Mic className="w-3.5 h-3.5" />
<span className="text-sm font-medium"></span>
</div>
)}
{/* 按钮组 - 移动端全宽横向 */}
<div className="flex gap-2 sm:ml-auto w-full sm:w-auto">
<Button
onClick={voiceChat.isVoiceEnabled ? () => voiceChat.disableVoice() : handleEnableVoice}
variant="outline"
size="sm"
className={`flex-1 sm:flex-initial ${
voiceChat.isVoiceEnabled
? "text-green-600 border-green-300"
: "text-slate-600 border-slate-300"
}`}
disabled={!desktopShare.isPeerConnected && !voiceChat.isVoiceEnabled}
>
{voiceChat.isVoiceEnabled ? (
<>
<Mic className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline"></span>
</>
) : (
<>
<MicOff className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline"></span>
</>
)}
</Button>
<Button
onClick={handleStopViewing}
disabled={isLoading}
variant="destructive"
size="sm"
className="flex-1 sm:flex-initial"
>
<Square className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">{isLoading ? '退出中...' : '退出观看'}</span>
</Button>
</div>
</div>
</div>
</div>
{/* 桌面显示区域 */}
<div className="relative">
{desktopShare.remoteStream ? (
<DesktopViewer
stream={desktopShare.remoteStream}
isConnected={desktopShare.isViewing}
connectionCode={inputCode}
onDisconnect={handleStopViewing}
/>
) : (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
<div className="text-center">
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
<p className="text-slate-600 mb-2">...</p>
<p className="text-sm text-slate-500"></p>
<div className="flex items-center justify-center space-x-2 mt-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
<span className="text-sm text-purple-600">...</span>
</div>
</div>
</div>
)}
{/* 语音状态指示器 - 始终显示,点击切换 */}
{desktopShare.remoteStream && (
<div className="mt-4">
<div
className="bg-gradient-to-br from-slate-50 to-white rounded-xl p-3 shadow-lg border border-slate-200 cursor-pointer hover:shadow-xl transition-shadow"
onClick={voiceChat.isVoiceEnabled ? () => voiceChat.disableVoice() : handleEnableVoice}
title={voiceChat.isVoiceEnabled ? "点击关闭发言" : "点击开启发言"}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
voiceChat.isVoiceEnabled ? 'bg-blue-100' : 'bg-slate-100'
}`}>
{voiceChat.isVoiceEnabled ? (
<Mic className="w-4 h-4 text-blue-600" />
) : (
<MicOff className="w-4 h-4 text-slate-400" />
)}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${
voiceChat.isVoiceEnabled ? 'text-slate-700' : 'text-slate-500'
}`}></span>
<span className="text-xs text-slate-500">
{voiceChat.isVoiceEnabled ? '点击关闭' : '点击开启'}
</span>
</div>
</div>
{voiceChat.isVoiceEnabled && (
<VoiceIndicator
volume={voiceChat.localVolume}
isSpeaking={voiceChat.localIsSpeaking}
isMuted={false}
/>
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* 隐藏的音频元素用于播放远程音频 */}
<audio
ref={(el) => {
remoteAudioRef.current = el;
voiceChat.setRemoteAudioRef(el);
}}
autoPlay
style={{ display: 'none' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,504 @@
"use client";
import { ConnectionStatus } from '@/components/ConnectionStatus';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
import { useVoiceChatBusiness } from '@/hooks/desktop-share/useVoiceChatBusiness';
import { VoiceIndicator } from '@/components/VoiceIndicator';
import { Monitor, Repeat, Share, Square, Mic, MicOff } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface WebRTCDesktopSenderProps {
className?: string;
onConnectionChange?: (connection: any) => void;
}
export default function WebRTCDesktopSender({ className, onConnectionChange }: WebRTCDesktopSenderProps) {
const [isLoading, setIsLoading] = useState(false);
const { showToast } = useToast();
// 使用桌面共享业务逻辑
const desktopShare = useDesktopShareBusiness();
// 使用语音通话业务逻辑 - 传入同一个connection实例
const voiceChat = useVoiceChatBusiness(desktopShare.webRTCConnection);
// 调试:监控语音状态变化(只监听状态,不监听实时音量)
useEffect(() => {
console.log('[DesktopShareSender] 🎤 语音状态变化:', {
isVoiceEnabled: voiceChat.isVoiceEnabled,
isRemoteVoiceActive: voiceChat.isRemoteVoiceActive,
debug: voiceChat._debug
});
}, [
voiceChat.isVoiceEnabled,
voiceChat.isRemoteVoiceActive
// 不监听 localVolume, remoteVolume, localIsSpeaking, remoteIsSpeaking
// 这些值每帧都在变化约60fps会导致过度渲染
]);
// 调试监控localStream状态变化
useEffect(() => {
console.log('[DesktopShareSender] localStream状态变化:', {
hasLocalStream: !!desktopShare.localStream,
streamId: desktopShare.localStream?.id,
trackCount: desktopShare.localStream?.getTracks().length,
isSharing: desktopShare.isSharing,
canStartSharing: desktopShare.canStartSharing,
});
}, [desktopShare.localStream, desktopShare.isSharing, desktopShare.canStartSharing]);
// 保持本地视频元素的引用
const localVideoRef = useRef<HTMLVideoElement | null>(null);
// 设置远程音频元素的回调
const setRemoteAudioRef = useCallback((audioElement: HTMLAudioElement | null) => {
voiceChat.setRemoteAudioRef(audioElement);
}, [voiceChat]);
// 处理本地流变化,确保视频正确显示
useEffect(() => {
if (localVideoRef.current && desktopShare.localStream) {
console.log('[DesktopShareSender] 通过useEffect设置本地流到video元素');
localVideoRef.current.srcObject = desktopShare.localStream;
localVideoRef.current.muted = true;
localVideoRef.current.play().then(() => {
console.log('[DesktopShareSender] useEffect: 本地预览播放成功');
}).catch((e: Error) => {
console.warn('[DesktopShareSender] useEffect: 本地预览播放失败:', e);
});
} else if (localVideoRef.current && !desktopShare.localStream) {
console.log('[DesktopShareSender] 清除video元素的流');
localVideoRef.current.srcObject = null;
}
}, [desktopShare.localStream]);
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange && desktopShare.webRTCConnection) {
onConnectionChange(desktopShare.webRTCConnection);
}
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
// 监听连接状态变化当P2P连接断开时保持桌面共享状态
const prevPeerConnectedRef = useRef<boolean>(false);
useEffect(() => {
// 只有从连接状态变为断开状态时才处理
const wasPreviouslyConnected = prevPeerConnectedRef.current;
const isCurrentlyConnected = desktopShare.isPeerConnected;
// 更新ref
prevPeerConnectedRef.current = isCurrentlyConnected;
// 如果正在共享且从连接变为断开,保持桌面共享状态以便新用户加入
if (desktopShare.isSharing &&
wasPreviouslyConnected &&
!isCurrentlyConnected &&
desktopShare.connectionCode) {
console.log('[DesktopShareSender] 检测到P2P连接断开保持桌面共享状态等待新用户');
const handleDisconnect = async () => {
try {
await desktopShare.handlePeerDisconnect();
console.log('[DesktopShareSender] 已处理P2P断开保持桌面共享状态');
} catch (error) {
console.error('[DesktopShareSender] 处理P2P断开失败:', error);
}
};
handleDisconnect();
}
}, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode]); // 移除handlePeerDisconnect依赖
// 复制房间代码
const copyCode = useCallback(async (code: string) => {
try {
await navigator.clipboard.writeText(code);
showToast('房间代码已复制到剪贴板', 'success');
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败,请手动复制', 'error');
}
}, [showToast]);
// 创建房间并开始桌面共享
const handleCreateRoomAndStart = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击创建房间并开始共享');
const roomCode = await desktopShare.createRoomAndStartSharing();
console.log('[DesktopShareSender] 房间创建并桌面共享开始成功:', roomCode);
showToast(`房间创建成功!代码: ${roomCode},桌面共享已开始`, 'success');
} catch (error) {
console.error('[DesktopShareSender] 创建房间并开始共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '创建房间并开始共享失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 创建房间(保留原方法)
const handleCreateRoom = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击创建房间');
const roomCode = await desktopShare.createRoom();
console.log('[DesktopShareSender] 房间创建成功:', roomCode);
showToast(`房间创建成功!代码: ${roomCode}`, 'success');
} catch (error) {
console.error('[DesktopShareSender] 创建房间失败:', error);
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 开始桌面共享
const handleStartSharing = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击开始桌面共享');
await desktopShare.startSharing();
console.log('[DesktopShareSender] 桌面共享开始成功');
showToast('桌面共享已开始', 'success');
} catch (error) {
console.error('[DesktopShareSender] 开始桌面共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
showToast(errorMessage, 'error');
// 分享失败时重置状态,让用户重新选择桌面
try {
// await desktopShare.resetSharing();
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
} catch (resetError) {
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
}
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 切换桌面
const handleSwitchDesktop = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击切换桌面');
await desktopShare.switchDesktop();
console.log('[DesktopShareSender] 桌面切换成功');
showToast('桌面切换成功', 'success');
} catch (error) {
console.error('[DesktopShareSender] 切换桌面失败:', error);
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
showToast(errorMessage, 'error');
// 切换桌面失败时重置状态,让用户重新选择桌面
try {
await desktopShare.resetSharing();
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
} catch (resetError) {
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
}
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 停止桌面共享
const handleStopSharing = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击停止桌面共享');
await desktopShare.stopSharing();
console.log('[DesktopShareSender] 桌面共享停止成功');
showToast('桌面共享已停止', 'success');
} catch (error) {
console.error('[DesktopShareSender] 停止桌面共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 开启语音
const handleEnableVoice = useCallback(async () => {
try {
console.log('[DesktopShareSender] 用户点击开启语音');
await voiceChat.enableVoice();
showToast('语音已开启', 'success');
} catch (error) {
console.error('[DesktopShareSender] 开启语音失败:', error);
let errorMessage = '开启语音失败';
if (error instanceof Error) {
if (error.message.includes('麦克风权限') || error.message.includes('Permission')) {
errorMessage = '无法访问麦克风,请检查浏览器权限设置';
} else if (error.message.includes('P2P连接')) {
errorMessage = '请先等待对方加入';
} else if (error.message.includes('NotFoundError') || error.message.includes('设备')) {
errorMessage = '未检测到麦克风设备';
} else if (error.message.includes('NotAllowedError')) {
errorMessage = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风';
} else {
errorMessage = error.message;
}
}
showToast(errorMessage, 'error');
}
}, [voiceChat, showToast]);
return (
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
{!desktopShare.connectionCode ? (
// 创建房间前的界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600"></p>
</div>
</div>
<ConnectionStatus
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'sender' } : null}
/>
</div>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
<Monitor className="w-10 h-10 text-purple-500" />
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
onClick={handleCreateRoomAndStart}
disabled={isLoading || desktopShare.isConnecting}
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Share className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
</div>
) : (
// 房间已创建,显示取件码和等待界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">: {desktopShare.connectionCode}</p>
</div>
</div>
<ConnectionStatus
currentRoom={{ code: desktopShare.connectionCode, role: 'sender' }}
/>
</div>
{/* 桌面共享控制区域 */}
{desktopShare.canStartSharing && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<Monitor className="w-5 h-5 mr-2" />
</h4>
{/* 控制按钮 */}
{desktopShare.isSharing && (
<div className="flex items-center space-x-2">
<Button
onClick={handleSwitchDesktop}
disabled={isLoading}
variant="outline"
size="sm"
className="text-slate-700 border-slate-300"
>
<Repeat className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={handleStopSharing}
disabled={isLoading}
variant="destructive"
size="sm"
className="bg-red-500 hover:bg-red-600 text-white"
>
<Square className="w-4 h-4 mr-1" />
</Button>
{/* 语音控制按钮 */}
<Button
onClick={voiceChat.isVoiceEnabled ? voiceChat.disableVoice : handleEnableVoice}
disabled={isLoading}
variant="outline"
size="sm"
className={voiceChat.isVoiceEnabled
? "text-green-700 border-green-300 hover:bg-green-50"
: "text-slate-700 border-slate-300 hover:bg-slate-50"
}
>
{voiceChat.isVoiceEnabled ? (
<>
<Mic className="w-4 h-4 mr-1" />
</>
) : (
<>
<MicOff className="w-4 h-4 mr-1" />
</>
)}
</Button>
</div>
)}
</div>
<div className="space-y-4">
{/* 本地预览区域(显示正在共享的内容) */}
{desktopShare.isSharing && (
<div className="bg-black rounded-xl overflow-hidden relative">
{/* 共享状态指示器 */}
<div className="absolute top-2 left-2 z-10">
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="text-xs font-medium"></span>
</div>
</div>
{desktopShare.localStream ? (
<video
ref={localVideoRef}
key={desktopShare.localStream.id} // 使用key确保重新渲染
autoPlay
playsInline
muted
className="w-full aspect-video object-contain bg-black"
style={{ minHeight: '300px' }}
/>
) : (
<div className="w-full flex items-center justify-center text-white bg-black" style={{ minHeight: '300px' }}>
<div className="text-center">
<Monitor className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">...</p>
</div>
</div>
)}
{/* 语音状态指示器 - 始终显示,点击切换 */}
<div className="absolute bottom-2 right-2 z-10">
<div
className="bg-gradient-to-br from-slate-50/95 to-white/95 backdrop-blur rounded-xl p-3 shadow-xl border border-slate-200/50 cursor-pointer hover:shadow-2xl transition-shadow"
onClick={voiceChat.isVoiceEnabled ? voiceChat.disableVoice : handleEnableVoice}
title={voiceChat.isVoiceEnabled ? "点击关闭发言" : "点击开启发言"}
>
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
voiceChat.isVoiceEnabled ? 'bg-blue-100' : 'bg-slate-100'
}`}>
{voiceChat.isVoiceEnabled ? (
<Mic className="w-4 h-4 text-blue-600" />
) : (
<MicOff className="w-4 h-4 text-slate-400" />
)}
</div>
<div className="flex flex-col">
<span className={`text-xs font-medium ${
voiceChat.isVoiceEnabled ? 'text-slate-700' : 'text-slate-500'
}`}></span>
<span className="text-[10px] text-slate-500">
{voiceChat.isVoiceEnabled ? '点击关闭' : '点击开启'}
</span>
</div>
{voiceChat.isVoiceEnabled && (
<VoiceIndicator
volume={voiceChat.localVolume}
isSpeaking={voiceChat.localIsSpeaking}
isMuted={false}
/>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* 房间信息显示 */}
<RoomInfoDisplay
code={desktopShare.connectionCode}
link={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
icon={Monitor}
iconColor="from-emerald-500 to-teal-500"
codeColor="from-purple-600 to-indigo-600"
title="房间码生成成功!"
subtitle="分享以下信息给观看方"
codeLabel="房间代码"
qrLabel="扫码观看"
copyButtonText="复制房间代码"
copyButtonColor="bg-purple-500 hover:bg-purple-600"
qrButtonText="使用手机扫码快速观看"
linkButtonText="复制链接"
onCopyCode={() => copyCode(desktopShare.connectionCode)}
onCopyLink={() => {
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
navigator.clipboard.writeText(link);
showToast('观看链接已复制', 'success');
}}
/>
{/* 隐藏的远程音频播放元素 - 用于播放观看方的语音 */}
<audio
ref={setRemoteAudioRef}
autoPlay
playsInline
className="hidden"
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,10 +1,13 @@
"use client";
import React, { useState, useCallback } from 'react';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useReadConnectState } from '@/hooks/connection/state/useWebConnectStateManager';
import { TransferProgressTracker } from '@/lib/transfer-utils';
import { Archive, Clock, Download, FileText, Image, Music, Video, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
interface FileInfo {
id: string;
@@ -13,6 +16,8 @@ interface FileInfo {
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: number; // 传输开始时间
}
const getFileIcon = (mimeType: string) => {
@@ -35,28 +40,34 @@ interface WebRTCFileReceiveProps {
onJoinRoom: (code: string) => void;
files: FileInfo[];
onDownloadFile: (fileId: string) => void;
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected?: boolean;
downloadedFiles?: Map<string, File>;
error?: string | null;
onReset?: () => void;
pickupCode?: string;
}
export function WebRTCFileReceive({
onJoinRoom,
files,
onDownloadFile,
isConnected,
isConnecting,
isWebSocketConnected = false,
downloadedFiles,
error = null,
onReset
onReset,
pickupCode: propPickupCode
}: WebRTCFileReceiveProps) {
const [pickupCode, setPickupCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const { showToast } = useToast();
// 用于跟踪传输进度的trackers
const transferTrackers = useRef<Map<string, TransferProgressTracker>>(new Map());
// 使用传入的取件码或本地状态的取件码
const displayPickupCode = propPickupCode || pickupCode;
const { getConnectState } = useReadConnectState();
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
@@ -70,7 +81,16 @@ export function WebRTCFileReceive({
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
const errorMessage = data.message || '取件码验证失败';
let errorMessage = data.message || '取件码验证失败';
// 特殊处理房间人数已满的情况
if (data.message?.includes('房间人数已满') || data.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (data.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (data.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
// 显示toast错误提示
showToast(errorMessage, 'error');
@@ -79,6 +99,14 @@ export function WebRTCFileReceive({
return false;
}
// 检查房间是否已满
if (data.is_room_full) {
const errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
showToast(errorMessage, 'error');
console.log('房间已满:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
@@ -109,7 +137,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);
}
@@ -117,7 +145,7 @@ export function WebRTCFileReceive({
// 当验证失败时重置输入状态
React.useEffect(() => {
if (error && !isConnecting && !isConnected && !isValidating) {
if (error && !getConnectState().isConnecting && !getConnectState().isConnected && !isValidating) {
// 延迟重置,确保用户能看到错误信息
const timer = setTimeout(() => {
console.log('重置取件码输入');
@@ -126,76 +154,45 @@ export function WebRTCFileReceive({
return () => clearTimeout(timer);
}
}, [error, isConnecting, isConnected, isValidating]);
}, [error, getConnectState, isValidating]);
// 如果已经连接但没有文件,显示等待界面
if ((isConnected || isConnecting) && files.length === 0) {
if ((getConnectState().isConnected || getConnectState().isConnecting) && files.length === 0) {
return (
<div>
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">
{isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'}
</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">WS</span>
</>
)}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
)}
<div>
{getConnectState().isWebSocketConnected}
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
</div>
</div>
</div>
<div className="text-center">
<div className="flex items-center space-x-4">
<ConnectionStatus
currentRoom={displayPickupCode ? { code: displayPickupCode, role: 'receiver' } : null}
/>
<Button
onClick={onReset}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div> <div className="text-center">
{/* 连接状态指示器 */}
<div className="flex items-center justify-center space-x-4 mb-6">
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
<span className={`text-sm font-medium ${isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
{isConnected ? '连接已建立' : '连接中...'}
<div className={`w-3 h-3 rounded-full mr-2 ${getConnectState().isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
<span className={`text-sm font-medium ${getConnectState().isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
{getConnectState().isConnected ? '连接已建立' : '连接中...'}
</span>
</div>
</div>
@@ -226,67 +223,22 @@ export function WebRTCFileReceive({
return (
<div className="space-y-4 sm:space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">
{isConnected ? (
<span className="text-emerald-600"> </span>
) : (
<span className="text-amber-600"> ...</span>
)}
</p>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</>
)}
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
)}
</div>
</div>
<div className="mt-1 text-xs text-slate-400">
{files.length}
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={{ code: displayPickupCode, role: 'receiver' }}
/>
</div>
<div>
@@ -296,16 +248,44 @@ export function WebRTCFileReceive({
const isDownloading = file.status === 'downloading';
const isCompleted = file.status === 'completed';
const hasDownloadedFile = downloadedFiles?.has(file.id);
const currentProgress = file.progress;
console.log('文件状态:', {
fileName: file.name,
status: file.status,
progress: file.progress,
isDownloading,
currentProgress
isDownloading
});
// 计算传输进度信息
let transferInfo = null;
let currentProgress = 0; // 使用稳定的进度值
if (isDownloading && file) {
const fileKey = `${file.name}-${file.size}`;
let tracker = transferTrackers.current.get(fileKey);
// 如果tracker不存在创建一个新的
if (!tracker) {
tracker = new TransferProgressTracker(file.size);
transferTrackers.current.set(fileKey, tracker);
}
// 更新传输进度
const transferredBytes = (file.progress / 100) * file.size;
const progressInfo = tracker.update(transferredBytes);
transferInfo = progressInfo;
currentProgress = progressInfo.percentage; // 使用稳定的百分比
} else {
// 如果不在传输中,使用原始进度值
currentProgress = file.progress;
}
// 清理已完成的tracker
if (file.status === 'completed') {
const fileKey = `${file.name}-${file.size}`;
transferTrackers.current.delete(fileKey);
}
return (
<div key={file.id} className="bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition-all duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-3 gap-3">
@@ -320,13 +300,33 @@ export function WebRTCFileReceive({
<p className="text-xs text-emerald-600 font-medium"> </p>
)}
{isDownloading && (
<p className="text-xs text-blue-600 font-medium"> ...{currentProgress.toFixed(1)}%</p>
<div className="space-y-1">
{/* 传输速度和剩余时间信息 */}
{transferInfo && (
<div className="flex items-center space-x-3">
<div className="flex items-center gap-1 text-xs text-blue-600">
<Zap className="w-3 h-3 flex-shrink-0" />
<span className="w-3 font-mono text-right">{transferInfo.speed.displaySpeed}</span>
<span className='w-2'/>
<span className="w-3">{transferInfo.speed.unit}</span>
<span className='w-3'/>
</div>
{transferInfo.remainingTime.seconds < Infinity && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Clock className="w-3 h-3 flex-shrink-0" />
<span></span>
<span className="w-3 font-mono text-right">{transferInfo.remainingTime.display}</span>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!isConnected || isDownloading}
disabled={!getConnectState().isConnected || isDownloading}
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
hasDownloadedFile
? 'bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white'
@@ -343,7 +343,9 @@ export function WebRTCFileReceive({
{(isDownloading || isCompleted) && currentProgress > 0 && (
<div className="mt-3 space-y-2">
<div className="flex justify-between text-sm text-slate-600">
<span>{hasDownloadedFile ? '传输完成' : '正在传输...'}</span>
<span>
{hasDownloadedFile ? '传输完成' : '正在传输...'}
</span>
<span className="font-medium">{currentProgress.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
@@ -371,8 +373,8 @@ export function WebRTCFileReceive({
return (
<div>
{/* 功能标题和状态 */}
<div className="flex items-center mb-6 sm:mb-8">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
@@ -382,57 +384,10 @@ export function WebRTCFileReceive({
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isConnecting ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">WS</span>
</>
) : isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</>
)}
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : isConnecting ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</>
)}
</div>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={null}
/>
</div>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
@@ -444,7 +399,7 @@ export function WebRTCFileReceive({
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}
disabled={isValidating || isConnecting}
disabled={isValidating || getConnectState().isConnecting}
/>
<div className="absolute inset-x-0 -bottom-4 sm:-bottom-6 flex justify-center space-x-1 sm:space-x-2">
{[...Array(6)].map((_, i) => (
@@ -468,14 +423,14 @@ export function WebRTCFileReceive({
<Button
type="submit"
className="w-full h-10 sm:h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-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"
disabled={pickupCode.length !== 6 || isValidating || isConnecting}
disabled={pickupCode.length !== 6 || isValidating || getConnectState().isConnecting}
>
{isValidating ? (
<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>
) : isConnecting ? (
) : getConnectState().isConnecting ? (
<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

@@ -1,10 +1,12 @@
"use client";
import React, { useState, useRef, useCallback } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import { Upload, FileText, Image, Video, Music, Archive, X, Clock, Zap } from 'lucide-react';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { TransferProgressTracker, formatTransferSpeed, formatTime } from '@/lib/transfer-utils';
interface FileInfo {
id: string;
@@ -13,6 +15,8 @@ interface FileInfo {
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: number; // 传输开始时间
}
const getFileIcon = (mimeType: string) => {
@@ -45,8 +49,6 @@ interface WebRTCFileUploadProps {
onClearFiles?: () => void;
onReset?: () => void;
disabled?: boolean;
isConnected?: boolean;
isWebSocketConnected?: boolean;
}
export function WebRTCFileUpload({
@@ -62,12 +64,13 @@ export function WebRTCFileUpload({
onRemoveFile,
onClearFiles,
onReset,
disabled = false,
isConnected = false,
isWebSocketConnected = false
disabled = false
}: WebRTCFileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 用于跟踪传输进度的trackers
const transferTrackers = useRef<Map<string, TransferProgressTracker>>(new Map());
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -115,9 +118,9 @@ export function WebRTCFileUpload({
if (selectedFiles.length === 0 && !pickupCode) {
return (
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center">
<div className="flex items-center space-x-3 flex-1">
{/* 功能标题 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Upload className="w-5 h-5 text-white" />
</div>
@@ -127,29 +130,9 @@ export function WebRTCFileUpload({
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</div>
</div>
</div>
<ConnectionStatus
currentRoom={null}
/>
</div>
<div
@@ -197,63 +180,22 @@ export function WebRTCFileUpload({
{/* 文件列表 */}
<div>
{/* 功能标题和状态 */}
<div className="flex items-center mb-4 sm:mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<div className="flex items-center justify-between mb-6">
{/* 标题部分 */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">{selectedFiles.length} </p>
<p className="text-sm text-slate-600">{selectedFiles.length} </p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</>
)}
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : pickupCode ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</>
)}
</div>
</div>
</div>
{/* 使用 ConnectionStatus 组件 */}
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
/>
</div>
<div className="space-y-3 mb-4 sm:mb-6">
@@ -261,9 +203,38 @@ export function WebRTCFileUpload({
// 查找对应的文件信息(包含状态和进度)
const fileInfo = fileList.find(f => f.name === file.name && f.size === file.size);
const isTransferringThisFile = fileInfo?.status === 'downloading';
const currentProgress = fileInfo?.progress || 0;
const fileStatus = fileInfo?.status || 'ready';
// 计算传输进度信息
let transferInfo = null;
let currentProgress = 0; // 使用稳定的进度值
if (isTransferringThisFile && fileInfo) {
const fileKey = `${file.name}-${file.size}`;
let tracker = transferTrackers.current.get(fileKey);
// 如果tracker不存在创建一个新的
if (!tracker) {
tracker = new TransferProgressTracker(file.size);
transferTrackers.current.set(fileKey, tracker);
}
// 更新传输进度
const transferredBytes = (fileInfo.progress / 100) * file.size;
const progressInfo = tracker.update(transferredBytes);
transferInfo = progressInfo;
currentProgress = progressInfo.percentage; // 使用稳定的百分比
} else {
// 如果不在传输中,使用原始进度值
currentProgress = fileInfo?.progress || 0;
}
// 清理已完成的tracker
if (fileStatus === 'completed') {
const fileKey = `${file.name}-${file.size}`;
transferTrackers.current.delete(fileKey);
}
return (
<div
key={`${file.name}-${file.size}-${index}`}
@@ -291,6 +262,26 @@ export function WebRTCFileUpload({
</div>
)}
</div>
{/* 传输速度和剩余时间信息 */}
{transferInfo && (
<div className="flex items-center space-x-3 mt-1">
<div className="flex items-center gap-1 text-xs text-blue-600">
<Zap className="w-3 h-3 flex-shrink-0" />
<span className="w-3 font-mono text-right">{transferInfo.speed.displaySpeed}</span>
<span className='w-2'/>
<span className="w-3">{transferInfo.speed.unit}</span>
<span className='w-3'/>
</div>
{transferInfo.remainingTime.seconds < Infinity && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Clock className="w-3 h-3 flex-shrink-0" />
<span></span>
<span className="w-3 font-mono text-right">{transferInfo.remainingTime.display}</span>
</div>
)}
</div>
)}
</div>
</div>
<Button
@@ -309,7 +300,9 @@ export function WebRTCFileUpload({
<div className="px-3 sm:px-4 pb-3 sm:pb-4">
<div className="space-y-2">
<div className="flex justify-between text-xs text-slate-600">
<span>{fileStatus === 'downloading' ? '正在发送...' : '发送完成'}</span>
<span>
{fileStatus === 'downloading' ? '正在发送...' : '发送完成'}
</span>
<span className="font-medium">{currentProgress.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
@@ -397,80 +390,24 @@ export function WebRTCFileUpload({
</div>
{/* 取件码展示 */}
{pickupCode && (
<div className="border-t border-slate-200 pt-6">
{/* 左上角状态提示 - 类似已选择文件的风格 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600"></p>
</div>
</div>
</div>
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:取件码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
{pickupCode}
</div>
</div>
<Button
onClick={onCopyCode}
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
>
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
{pickupLink && (
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={pickupLink}
size={120}
title=""
className="w-auto"
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
使访
</div>
</div>
)}
</div>
{/* 底部:取件链接 */}
{pickupLink && (
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{pickupLink}
</div>
</div>
<Button
onClick={onCopyLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
</Button>
</div>
</div>
)}
</div>
{pickupCode && pickupLink && (
<RoomInfoDisplay
code={pickupCode}
link={pickupLink}
icon={FileText}
iconColor="from-emerald-500 to-teal-500"
codeColor="from-emerald-600 to-teal-600"
title="取件码生成成功!"
subtitle="分享以下信息给接收方"
codeLabel="取件码"
qrLabel="扫码传输"
copyButtonText="复制取件码"
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
qrButtonText="使用手机扫码快速访问"
linkButtonText="复制链接"
onCopyCode={onCopyCode || (() => {})}
onCopyLink={onCopyLink || (() => {})}
/>
)}
</div>
);

View File

@@ -1,24 +1,27 @@
"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 { ConnectionStatus } from '@/components/ConnectionStatus';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Download } from 'lucide-react';
import { useConnectManager } from '@/hooks/connection';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { Download, Image, MessageSquare } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface WebRTCTextReceiverProps {
initialCode?: string;
onPreviewImage: (imageUrl: string) => void;
onRestart?: () => void;
onConnectionChange?: (connection: any) => void;
}
export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
initialCode = '',
onPreviewImage,
onRestart
onRestart,
onConnectionChange
}) => {
const { showToast } = useToast();
@@ -29,12 +32,15 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
const [receivedImages, setReceivedImages] = useState<Array<{ id: string, content: string, fileName?: string }>>([]);
const [isTyping, setIsTyping] = useState(false);
const [isValidating, setIsValidating] = useState(false);
// Ref用于防止重复自动连接
const hasTriedAutoConnect = useRef(false);
// 创建共享连接
const connection = useConnectManager();
// 创建共享连接 [需要优化]
const connection = useSharedWebRTCManager();
const {getConnectState} = connection;
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
const fileTransfer = useFileTransferBusiness(connection);
@@ -42,116 +48,49 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
// 连接所有传输通道
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
console.log('=== 连接所有传输通道 ===', { code, role });
// 只需要连接一次,因为使用的是共享连接
await connection.connect(code, role);
// await Promise.all([
// textTransfer.connect(code, role),
// fileTransfer.connect(code, role)
// ]);
}, [textTransfer, fileTransfer]);
}, [connection]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange) {
onConnectionChange(connection);
}
}, [onConnectionChange, getConnectState().isConnected, getConnectState().isConnecting, getConnectState().isPeerConnected]);
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
// 监听连接错误并显示 toast
useEffect(() => {
if (hasAnyError) {
console.error('[WebRTCTextReceiver] 连接错误:', hasAnyError);
showToast(hasAnyError, 'error');
}
}, [hasAnyError, showToast]);
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
setIsValidating(true);
console.log('开始验证取件码:', code);
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
const errorMessage = data.message || '取件码验证失败';
showToast(errorMessage, 'error');
console.log('验证失败:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
console.error('验证取件码时发生错误:', error);
const errorMessage = '网络错误,请检查连接后重试';
showToast(errorMessage, 'error');
return false;
} finally {
setIsValidating(false);
}
};
// 重新开始
const restart = () => {
setPickupCode('');
setInputCode('');
setReceivedText('');
setReceivedImages([]);
setIsTyping(false);
// 断开连接
// 清理接收的图片URL
receivedImages.forEach(img => {
if (img.content.startsWith('blob:')) {
URL.revokeObjectURL(img.content);
}
});
setReceivedImages([]);
// 断开连接(只需要断开一次)
connection.disconnect();
if (onRestart) {
onRestart();
}
};
// 加入房间
const joinRoom = useCallback(async (code: string) => {
const trimmedCode = code.trim().toUpperCase();
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
if (isAnyConnecting || isValidating) {
console.log('已经在连接中,跳过重复请求');
return;
}
if (hasAnyConnection) {
console.log('已经连接,跳过重复请求');
return;
}
try {
console.log('=== 开始验证和连接房间 ===', trimmedCode);
const isValid = await validatePickupCode(trimmedCode);
if (!isValid) {
return;
}
setPickupCode(trimmedCode);
await connectAll(trimmedCode, 'receiver');
console.log('=== 房间连接成功 ===', trimmedCode);
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
} catch (error) {
console.error('加入房间失败:', error);
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
setPickupCode('');
}
}, [isAnyConnecting, hasAnyConnection, connectAll, showToast, isValidating, validatePickupCode]);
// 监听实时文本同步
useEffect(() => {
const cleanup = textTransfer.onTextSync((text: string) => {
@@ -174,25 +113,82 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
useEffect(() => {
const cleanup = fileTransfer.onFileReceived((fileData) => {
if (fileData.file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target?.result as string;
setReceivedImages(prev => [...prev, {
id: fileData.id,
content: imageData,
fileName: fileData.file.name
}]);
};
reader.readAsDataURL(fileData.file);
const imageUrl = URL.createObjectURL(fileData.file);
const imageId = Date.now().toString();
setReceivedImages(prev => [...prev, {
id: imageId,
content: imageUrl,
fileName: fileData.file.name
}]);
showToast(`收到图片: ${fileData.file.name}`, "success");
}
});
return cleanup;
}, [fileTransfer.onFileReceived]);
// 验证并加入房间
const joinRoom = useCallback(async (code: string) => {
if (!code || code.length !== 6) return;
setIsValidating(true);
try {
console.log('=== 开始加入房间 ===', code);
// 验证房间
const response = await fetch(`/api/room-info?code=${code}`);
const roomData = await response.json();
if (!response.ok) {
let errorMessage = roomData.error || '房间不存在或已过期';
// 特殊处理房间人数已满的情况
if (roomData.message?.includes('房间人数已满') || roomData.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (roomData.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (roomData.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
throw new Error(errorMessage);
}
// 检查房间是否已满
if (roomData.is_room_full) {
throw new Error('当前房间人数已满,正在传输中无法加入,请稍后再试');
}
console.log('=== 房间验证成功 ===', roomData);
setPickupCode(code);
// 连接到房间
await connectAll(code, 'receiver');
} catch (error: any) {
console.error('加入房间失败:', error);
showToast(error.message || '加入房间失败', "error");
} finally {
setIsValidating(false);
}
}, [connectAll, showToast]);
// 复制文本到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToast('已复制到剪贴板', "success");
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败', "error");
}
};
// 处理初始代码连接
useEffect(() => {
// initialCode isAutoConnected
console.log(`initialCode: ${initialCode}, hasTriedAutoConnect: ${hasTriedAutoConnect.current}`);
if (initialCode && initialCode.length === 6 && !hasTriedAutoConnect.current) {
console.log('=== 自动连接初始代码 ===', initialCode);
@@ -201,15 +197,15 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
joinRoom(initialCode);
return;
}
}, [initialCode]);
}, [initialCode, joinRoom]);
return (
<div className="space-y-6">
{!hasAnyConnection ? (
// 输入取件码界面
<div>
<div className="flex items-center mb-6 sm:mb-8">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
@@ -218,6 +214,12 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
<p className="text-sm text-slate-600">6</p>
</div>
</div>
<div className="text-left">
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
/>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); joinRoom(inputCode); }} className="space-y-4 sm:space-y-6">
@@ -225,7 +227,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}
@@ -266,94 +268,114 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
) : (
// 已连接,显示实时文本
<div className="space-y-6">
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">
<span className="text-emerald-600"> </span>
</p>
<p className="text-sm text-slate-600">: {pickupCode}</p>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<ConnectionStatus
{/* 连接成功状态 */}
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
<h4 className="font-semibold text-emerald-800 mb-1"></h4>
<p className="text-emerald-700">: {pickupCode}</p>
</div>
{/* 实时文本显示区域 */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<MessageSquare className="w-5 h-5 mr-2" />
</h4>
<div className="flex items-center space-x-3 text-sm">
<span className="text-slate-500">
{receivedText.length} / 50,000
</span>
{textTransfer.isConnected && (
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="font-medium">WebRTC实时同步</span>
</div>
)}
</div>
</div>
<div className="relative">
<textarea
value={receivedText}
readOnly
placeholder="等待对方发送文字内容...&#10;&#10;💡 实时同步显示,对方的编辑会立即显示在这里"
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg bg-slate-50 text-slate-700 placeholder-slate-400 resize-none"
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
/>
{!receivedText && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 rounded-lg border border-slate-300">
<div className="text-center">
<MessageSquare className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600">...</p>
<p className="text-sm text-slate-500 mt-2"></p>
</div>
</div>
<Button
onClick={restart}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div>
{/* 文本显示区域 */}
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
<MessageSquare className="w-4 h-4" />
<span></span>
</h4>
{receivedText && (
<Button
onClick={() => copyToClipboard(receivedText)}
size="sm"
variant="ghost"
className="text-slate-600 hover:text-slate-800 h-8 px-3"
>
<span></span>
</Button>
)}
</div>
{/* 打字状态提示 */}
{isTyping && (
<div className="flex items-center space-x-2 mt-3 text-sm text-slate-500">
<div className="flex space-x-1">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="w-1 h-1 bg-slate-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.1}s` }}
></div>
))}
<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 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">
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0ms'}}></div>
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '150ms'}}></div>
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '300ms'}}></div>
</div>
<span>...</span>
</div>
)}
</div>
<span className="italic">...</span>
</div>
)}
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
<MessageSquare className="w-12 h-12 text-slate-300" />
<p className="text-center">
{getConnectState().isPeerConnected ?
'等待对方发送文字内容...' :
'等待连接建立...'}
</p>
</div>
)}
</div>
</div>
{/* 接收到的图片 */}
{/* 图片显示区域 */}
{receivedImages.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-6 border border-slate-200">
<h4 className="text-lg font-semibold text-slate-800 mb-4"></h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
<Image className="w-4 h-4" />
<span> ({receivedImages.length})</span>
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{receivedImages.map((image) => (
<img
<div
key={image.id}
src={image.content}
alt={image.fileName}
className="w-full h-32 object-cover rounded-lg border cursor-pointer hover:opacity-80 transition-opacity"
className="group relative aspect-square bg-slate-50 rounded-xl overflow-hidden border border-slate-200 hover:border-slate-300 transition-all duration-200 cursor-pointer"
onClick={() => onPreviewImage(image.content)}
/>
>
<img
src={image.content}
alt={image.fileName || '接收的图片'}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="bg-white/90 rounded-lg px-3 py-1">
<span className="text-sm text-slate-700"></span>
</div>
</div>
</div>
</div>
))}
</div>
</div>

View File

@@ -1,20 +1,22 @@
"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 { ConnectionStatus } from '@/components/ConnectionStatus';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import { useConnectManager } from '@/hooks/connection';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { Image, MessageSquare, Send } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface WebRTCTextSenderProps {
onRestart?: () => void;
onPreviewImage?: (imageUrl: string) => void;
onConnectionChange?: (connection: any) => void;
}
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage }) => {
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage, onConnectionChange }) => {
const { showToast } = useToast();
// 状态管理
@@ -29,8 +31,10 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 创建共享连接
const connection = useSharedWebRTCManager();
const connection = useConnectManager();
const { getConnectState } = connection;
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
const fileTransfer = useFileTransferBusiness(connection);
@@ -41,15 +45,17 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 只需要连接一次,因为使用的是共享连接
await connection.connect(code, role);
}, [connection]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange) {
onConnectionChange(connection);
}
}, [onConnectionChange, getConnectState().isConnected, getConnectState().isConnecting, getConnectState().isPeerConnected]);
// 重新开始
const restart = () => {
@@ -106,21 +112,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();
@@ -138,7 +137,7 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 如果有初始文本,发送它
if (currentText) {
setTimeout(() => {
if (connection.isPeerConnected && textTransfer.isConnected) {
if (getConnectState().isPeerConnected && textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(currentText);
@@ -169,7 +168,7 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
textarea.style.height = `${newHeight}px`;
// 实时同步文本内容如果P2P连接已建立
if (connection.isPeerConnected && textTransfer.isConnected) {
if (getConnectState().isPeerConnected && textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(value);
@@ -212,10 +211,10 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
}]);
// 发送文件
if (connection.isPeerConnected && fileTransfer.isConnected) {
if (getConnectState().isPeerConnected && fileTransfer.isConnected) {
fileTransfer.sendFile(file);
showToast('图片发送中...', "success");
} else if (!connection.isPeerConnected) {
} else if (!getConnectState().isPeerConnected) {
showToast('等待对方加入P2P网络...', "error");
} else {
showToast('请先连接到房间', "error");
@@ -286,36 +285,17 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
</div>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
/>
</div>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<MessageSquare className="w-10 h-10 text-blue-500" />
</div>
<h3 className="text-xl font-semibold text-slate-800 mb-4"></h3>
<h3 className="text-lg font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
@@ -341,45 +321,22 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 房间已创建,显示取件码和文本传输界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">
{hasAnyConnection ? '实时编辑,对方可以同步看到' : '等待对方连接'}
</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
</div>
</div>
</div>
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
{/* 文字编辑区域 - 移到最上面 */}
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600"></p>
</div>
</div>
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
/>
</div> {/* 文字编辑区域 - 移到最上面 */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
@@ -402,19 +359,18 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
)}
</div>
</div>
<textarea
ref={textareaRef}
value={textInput}
onChange={handleTextInputChange}
onPaste={handlePaste}
disabled={!connection.isPeerConnected}
placeholder={connection.isPeerConnected
disabled={!getConnectState().isPeerConnected}
placeholder={getConnectState().isPeerConnected
? "在这里编辑文字内容...&#10;&#10;💡 支持实时同步编辑,对方可以看到你的修改&#10;💡 可以直接粘贴图片 (Ctrl+V)"
: "等待对方加入P2P网络...&#10;&#10;📡 建立连接后即可开始输入文字"
}
className={`w-full h-40 px-4 py-3 border rounded-lg resize-none text-slate-700 ${
connection.isPeerConnected
getConnectState().isPeerConnected
? "border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-400"
: "border-slate-200 bg-slate-50 cursor-not-allowed placeholder-slate-300"
}`}
@@ -426,9 +382,9 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
disabled={!connection.isPeerConnected}
disabled={!getConnectState().isPeerConnected}
className={`flex items-center space-x-1 ${
!connection.isPeerConnected ? 'cursor-not-allowed opacity-50' : ''
!getConnectState().isPeerConnected ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<Image className="w-4 h-4" />
@@ -471,74 +427,24 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
</div>
)}
{/* 取件码显示 - 和文件传输一致的风格 */}
<div className="border-t border-slate-200 pt-6">
{/* 左上角状态提示 - 类似已选择文件的风格 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600"></p>
</div>
</div>
</div>
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:取件码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
{pickupCode}
</div>
</div>
<Button
onClick={copyCode}
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
>
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={pickupLink}
size={120}
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
使访
</div>
</div>
</div>
{/* 底部:取件链接 */}
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{pickupLink}
</div>
</div>
<Button
onClick={copyShareLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
</Button>
</div>
</div>
</div>
{/* 取件码显示 */}
<RoomInfoDisplay
code={pickupCode}
link={pickupLink}
icon={MessageSquare}
iconColor="from-emerald-500 to-teal-500"
codeColor="from-emerald-600 to-teal-600"
title="取件码生成成功!"
subtitle="分享以下信息给接收方"
codeLabel="取件码"
qrLabel="扫码传输"
copyButtonText="复制取件码"
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
qrButtonText="使用手机扫码快速访问"
linkButtonText="复制链接"
onCopyCode={copyCode}
onCopyLink={copyShareLink}
/>
</div>
)}

View File

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

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import { Role } from '../types';
import { useWebRTCStore, type WebConnectState } from './webConnectStore';
/**
* WebRTC 状态管理器
* 负责连接状态的统一管理
*/
export interface IWebConnectStateManager {
// 获取当前状态
getState: () => Readonly<WebConnectState>;
// 更新状态
updateState: (updates: Partial<WebConnectState>) => void;
// 设置当前房间
setCurrentRoom: (room: { code: string; role: Role } | null) => void;
// 重置到初始状态
resetToInitial: () => void;
// 检查是否已连接到指定房间
isConnectedToRoom: (roomCode: string, role: Role) => boolean;
}
export interface IUseReadConnectState {
getConnectState: () => Readonly<WebConnectState>;
}
export function useReadConnectState(): IUseReadConnectState {
const webrtcStore = useWebRTCStore();
const getConnectState = useCallback((): Readonly<WebConnectState> => {
return webrtcStore;
}, [webrtcStore]);
return {
getConnectState
};
}
/**
* WebRTC 状态管理 Hook
* 封装对 webRTCStore 的操作,提供状态更新和查询的统一接口
*/
export function useWebConnectStateManager(): IWebConnectStateManager {
const webrtcStore = useWebRTCStore();
const getState = useCallback((): WebConnectState => {
return webrtcStore;
}, [webrtcStore]);
const updateState = useCallback((updates: Partial<WebConnectState>) => {
webrtcStore.updateState(updates);
}, [webrtcStore]);
const setCurrentRoom = useCallback((room: { code: string; role: Role } | null) => {
webrtcStore.setCurrentRoom(room);
}, [webrtcStore]);
const resetToInitial = useCallback(() => {
webrtcStore.resetToInitial();
}, [webrtcStore]);
const isConnectedToRoom = useCallback((roomCode: string, role: Role) => {
return webrtcStore.currentRoom?.code === roomCode &&
webrtcStore.currentRoom?.role === role &&
webrtcStore.isConnected;
}, [webrtcStore]);
return {
getState,
updateState,
setCurrentRoom,
resetToInitial,
isConnectedToRoom,
};
}

View File

@@ -0,0 +1,61 @@
import { create } from 'zustand';
import { Role } from '../types';
export interface WebConnectState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
isJoinedRoom: boolean;
isDataChannelConnected: boolean;
isMediaStreamConnected: boolean;
currentConnectType: 'webrtc' | 'websocket';
currentIsLocalNetWork: boolean; // 可选,表示当前是否在局域网内
state: RTCDataChannelState;
stateMsg: string | null;
error: string | null;
canRetry: boolean; // 新增:是否可以重试
currentRoom: { code: string; role: Role } | null;
}
interface WebRTCStore extends WebConnectState {
updateState: (updates: Partial<WebConnectState>) => void;
setCurrentRoom: (room: { code: string; role: Role } | null) => void;
reset: () => void;
resetToInitial: () => void; // 新增:完全重置到初始状态
}
const initialState: WebConnectState = {
isConnected: false,
isConnecting: false,
currentIsLocalNetWork: false,
isWebSocketConnected: false,
isJoinedRoom: false,
isPeerConnected: false,
error: null,
canRetry: false, // 初始状态下不需要重试
currentRoom: null,
stateMsg: null,
isDataChannelConnected: false,
isMediaStreamConnected: false,
currentConnectType: 'webrtc',
state: 'closed'
};
export const useWebRTCStore = create<WebRTCStore>((set) => ({
...initialState,
updateState: (updates) => set((state) => {
console.log('Updating WebRTC state:', updates);
return { ...state, ...updates };
}),
setCurrentRoom: (room) => set((state) => ({
...state,
currentRoom: room,
})),
reset: () => set(initialState),
resetToInitial: () => set(initialState), // 完全重置到初始状态
}));

View File

@@ -0,0 +1,124 @@
import { WebConnectState } from "./state/webConnectStore";
// 消息和数据处理器类型
export type MessageHandler = (message: IWebMessage) => void;
export type DataHandler = (data: ArrayBuffer) => void;
// 角色类型
export type Role = 'sender' | 'receiver';
export type ConnectType = 'webrtc' | 'websocket';
// 对外包装类型 暴露接口
export interface IRegisterEventHandler {
registerMessageHandler: (channel: string, handler: MessageHandler) => () => void;
registerDataHandler: (channel: string, handler: DataHandler) => () => void;
}
export interface IGetConnectState {
getConnectState: () => WebConnectState;
}
/***
*
* 对外包装类型 暴露接口
*
*/
// WebRTC 连接接口
export interface IWebConnection extends IRegisterEventHandler, IGetConnectState {
connectType: ConnectType;
// 操作方法
connect: (roomCode: string, role: Role) => Promise<void>;
disconnect: () => void;
retry: () => Promise<void>;
sendMessage: (message: IWebMessage, channel?: string) => boolean;
sendData: (data: ArrayBuffer) => boolean;
// 工具方法
getConnectState: () => WebConnectState;
isConnectedToRoom: (roomCode: string, role: Role) => boolean;
// 当前房间信息
currentRoom: { code: string; role: Role } | null;
// 媒体轨道方法
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
removeTrack: (sender: RTCRtpSender) => void;
onTrack: (callback: (event: RTCTrackEvent) => void) => () => void; // 返回清理函数
getPeerConnection: () => RTCPeerConnection | null;
createOfferNow: () => Promise<boolean>;
// 断开连接回调
setOnDisconnectCallback: (callback: () => void) => void;
}
// 消息类型
export interface IWebMessage {
type: string;
payload: any;
channel?: string;
}
/***
*
* 数据通道类型
* WebRTC 数据通道管理器
* 负责数据通道的创建和管理
*/
export interface WebRTCDataChannelManager extends IGetConnectState {
// 创建数据通道
createDataChannel: (pc: RTCPeerConnection, role: Role, isReconnect?: boolean) => void;
// 发送消息
sendMessage: (message: IWebMessage, channel?: string) => boolean;
// 发送二进制数据
sendData: (data: ArrayBuffer) => boolean;
// 处理数据通道消息
handleDataChannelMessage: (event: MessageEvent) => void;
}
/**
* WebRTC 媒体轨道管理器
* 负责媒体轨道的添加和移除
*/
export interface WebRTCTrackManager {
// 添加媒体轨道
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
// 移除媒体轨道
removeTrack: (sender: RTCRtpSender) => void;
// 设置轨道处理器 - 返回清理函数以移除处理器
onTrack: (handler: (event: RTCTrackEvent) => void) => () => void;
// 请求重新协商(通知 Core 层需要重新创建 Offer
requestOfferRenegotiation: () => Promise<boolean>;
// 触发重新协商
triggerRenegotiation: () => Promise<boolean>;
// 内部方法,供核心连接管理器调用
setPeerConnection: (pc: RTCPeerConnection | null) => void;
setWebSocket: (ws: WebSocket | null) => void;
}

View File

@@ -0,0 +1,355 @@
import { getWsUrl } from '@/lib/config';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useReadConnectState } from './state/useWebConnectStateManager';
import { WebConnectState } from "./state/webConnectStore";
import { ConnectType, DataHandler, IGetConnectState, IRegisterEventHandler, IWebConnection, IWebMessage, MessageHandler, Role } from "./types";
import { useSharedWebRTCManagerImpl } from './webrtc/useSharedWebRTCManager';
import { useWebSocketConnection } from './ws/useWebSocketConnection';
/**
* 连接管理器 - 统一管理 WebSocket 和 WebRTC 连接
* 提供统一的连接接口,内部可以在不同传输方式之间切换
* 统一管理 WebSocket 连接,为 WebRTC 和 WebSocket 传输提供共享的 WebSocket 实例
*/
export function useConnectManager(): IWebConnection & IRegisterEventHandler & IGetConnectState {
// 当前连接类型
const [currentConnectType, setCurrentConnectType] = useState<ConnectType>('webrtc');
// 统一的 WebSocket 连接引用
const wsRef = useRef<WebSocket | null>(null);
// 当前房间信息
const currentRoomRef = useRef<{ code: string; role: Role } | null>(null);
// 连接实例 - 初始化时不传入 WebSocket
const wsConnection = useWebSocketConnection();
const webrtcConnection = useSharedWebRTCManagerImpl();
// 当前活跃连接的引用 - 默认使用 WebRTC
const currentConnectionRef = useRef<IWebConnection>(webrtcConnection);
const { getConnectState: innerState } = useReadConnectState();
// 确保连接引用与连接类型保持一致
useEffect(() => {
const targetConnection = currentConnectType === 'webrtc' ? webrtcConnection : wsConnection;
if (currentConnectionRef.current !== targetConnection) {
currentConnectionRef.current = targetConnection;
}
}, [currentConnectType, webrtcConnection, wsConnection]);
// 连接状态管理
const connectionStateRef = useRef<WebConnectState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
isDataChannelConnected: false,
isMediaStreamConnected: false,
isJoinedRoom: false,
currentConnectType: 'webrtc',
state: 'closed',
error: null,
canRetry: false,
currentRoom: null,
stateMsg: null,
currentIsLocalNetWork: false
});
// 更新连接状态
const updateConnectionState = useCallback((updates: Partial<WebConnectState>) => {
connectionStateRef.current = {
...connectionStateRef.current,
...updates
};
}, []);
// 创建并管理 WebSocket 连接
const createWebSocketConnection = useCallback(async (roomCode: string, role: Role) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
console.log('[ConnectManager] 已存在 WebSocket 连接,先断开');
wsRef.current.close();
}
try {
// 构建 WebSocket URL
const baseWsUrl = getWsUrl();
if (!baseWsUrl) {
throw new Error('WebSocket URL未配置');
}
// 构建完整的WebSocket URL
const wsUrl = `${baseWsUrl}/api/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`;
console.log('[ConnectManager] 创建 WebSocket 连接:', wsUrl);
const ws = new WebSocket(wsUrl);
// 设置二进制数据类型为 ArrayBuffer避免默认的 Blob 类型
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
currentRoomRef.current = { code: roomCode, role };
// WebSocket 事件处理
ws.onopen = () => {
console.log('[ConnectManager] WebSocket 连接成功');
updateConnectionState({
isWebSocketConnected: true,
error: null
});
};
ws.onerror = (error) => {
console.error('[ConnectManager] WebSocket 连接错误:', error);
updateConnectionState({
isWebSocketConnected: false,
error: 'WebSocket 连接失败'
});
};
ws.onclose = (event) => {
console.log('[ConnectManager] WebSocket 连接关闭:', event.code, event.reason);
updateConnectionState({
isWebSocketConnected: false,
error: event.wasClean ? null : 'WebSocket 连接意外断开'
});
};
return ws;
} catch (error) {
console.error('[ConnectManager] 创建 WebSocket 连接失败:', error);
updateConnectionState({
isWebSocketConnected: false,
error: '无法建立 WebSocket 连接'
});
throw error;
}
}, [updateConnectionState]);
// 获取 WebSocket 连接
const getWebSocketConnection = useCallback(() => {
return wsRef.current;
}, []);
// 切换连接类型
const switchConnectionType = useCallback((type: ConnectType) => {
console.log('[ConnectManager] 切换连接类型:', currentConnectType, '->', type);
// 如果当前有连接,先断开
if (connectionStateRef.current.isConnected) {
currentConnectionRef.current.disconnect();
}
// 切换到新的连接类型
setCurrentConnectType(type);
currentConnectionRef.current = type === 'websocket' ? wsConnection : webrtcConnection;
updateConnectionState({
currentConnectType: type,
error: null
});
}, [currentConnectType, wsConnection, webrtcConnection, updateConnectionState]);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: Role) => {
console.log('[ConnectManager] 连接到房间:', roomCode, '角色:', role, '类型:', currentConnectType);
updateConnectionState({
isConnecting: true,
error: null,
currentRoom: { code: roomCode, role }
});
try {
// 首先创建统一的 WebSocket 连接
const ws = await createWebSocketConnection(roomCode, role);
if (currentConnectType === 'webrtc') {
// 将 WebSocket 注入到 WebRTC 连接中
webrtcConnection.injectWebSocket(ws);
currentConnectionRef.current = webrtcConnection;
await currentConnectionRef.current.connect(roomCode, role);
} else {
// WebSocket 连接也使用统一的 WebSocket 实例
wsConnection.injectWebSocket(ws);
currentConnectionRef.current = wsConnection;
await currentConnectionRef.current.connect(roomCode, role);
}
} catch (error) {
console.error('[ConnectManager] 连接失败:', error);
updateConnectionState({
isConnecting: false,
error: error instanceof Error ? error.message : '连接失败'
});
}
}, [currentConnectType, createWebSocketConnection, webrtcConnection, wsConnection, updateConnectionState]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[ConnectManager] 断开连接');
currentConnectionRef.current.disconnect();
// 断开 WebSocket 连接
if (wsRef.current) {
wsRef.current.close(1000, '用户主动断开');
wsRef.current = null;
}
currentRoomRef.current = null;
updateConnectionState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
isDataChannelConnected: false,
isMediaStreamConnected: false,
error: null,
canRetry: false,
currentRoom: null
});
}, [updateConnectionState]);
// 重试连接
const retry = useCallback(async () => {
console.log('[ConnectManager] 重试连接');
if (connectionStateRef.current.currentRoom) {
const { code, role } = connectionStateRef.current.currentRoom;
await connect(code, role);
}
}, [connect]);
// 发送消息
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
return currentConnectionRef.current.sendMessage(message, channel);
}, []);
// 发送数据
const sendData = useCallback((data: ArrayBuffer) => {
return currentConnectionRef.current.sendData(data);
}, []);
// 获取连接状态
const getConnectState = useCallback((): WebConnectState => {
// 合并当前连接的状态和管理器的状态
return innerState();
}, [innerState]);
// 检查是否连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: Role) => {
return currentConnectionRef.current.isConnectedToRoom(roomCode, role);
}, []);
// 媒体轨道方法(代理到当前连接)
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
return currentConnectionRef.current.addTrack(track, stream);
}, []);
const removeTrack = useCallback((sender: RTCRtpSender) => {
currentConnectionRef.current.removeTrack(sender);
}, []);
const onTrack = useCallback((callback: (event: RTCTrackEvent) => void) => {
return currentConnectionRef.current.onTrack(callback);
}, []); // 空依赖,使用 ref 确保总是获取最新的连接
const getPeerConnection = useCallback(() => {
return currentConnectionRef.current.getPeerConnection();
}, []);
const createOfferNow = useCallback(async () => {
return currentConnectionRef.current.createOfferNow();
}, []);
// 设置断开连接回调
const setOnDisconnectCallback = useCallback((callback: () => void) => {
currentConnectionRef.current.setOnDisconnectCallback(callback);
}, []);
// 扩展方法:切换连接类型
const switchToWebSocket = useCallback(() => {
switchConnectionType('websocket');
}, [switchConnectionType]);
const switchToWebRTC = useCallback(() => {
switchConnectionType('webrtc');
}, [switchConnectionType]);
// 获取连接统计信息
const getConnectionStats = useCallback(() => {
const state = getConnectState();
return {
currentType: currentConnectType,
isConnected: state.isConnected,
hasWebSocket: state.isWebSocketConnected,
hasWebRTC: state.isPeerConnected,
hasDataChannel: state.isDataChannelConnected,
hasMediaStream: state.isMediaStreamConnected,
room: state.currentRoom,
error: state.error,
canRetry: state.canRetry
};
}, [currentConnectType, innerState]);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[DataChannelManager] 注册消息处理器:', channel);
const webrtcConnectionUninstall = webrtcConnection.registerMessageHandler(channel, handler);
const wsConnectionUninstall = wsConnection.registerMessageHandler(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册消息处理器:', channel);
webrtcConnectionUninstall();
wsConnectionUninstall();
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[DataChannelManager] 注册数据处理器:', channel);
const webrtcConnectionUninstall = webrtcConnection.registerDataHandler(channel, handler);
const wsConnectionUninstall = wsConnection.registerDataHandler(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册数据处理器:', channel);
webrtcConnectionUninstall();
wsConnectionUninstall();
};
}, []);
return {
connectType: currentConnectType,
connect,
disconnect,
retry,
sendMessage,
sendData,
registerMessageHandler,
registerDataHandler,
getConnectState,
isConnectedToRoom,
currentRoom: connectionStateRef.current.currentRoom,
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
setOnDisconnectCallback,
// 扩展方法
switchToWebSocket,
switchToWebRTC,
getConnectionStats,
} as IWebConnection & {
switchToWebSocket: () => void;
switchToWebRTC: () => void;
getConnectionStats: () => any;
};
}

View File

@@ -0,0 +1,137 @@
import { useToast } from '@/components/ui/toast-simple';
import { useEffect, useState } from 'react';
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,111 @@
import { useToast } from '@/components/ui/toast-simple';
import { useCallback, useState } from 'react';
import { Role } from './types';
interface UseRoomConnectionProps {
connect: (code: string, role: Role) => 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.is_room_full) {
throw new Error('当前房间人数已满,正在传输中无法加入');
}
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 if (error.message.includes('房间人数已满') || error.message.includes('正在传输中无法加入')) {
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

@@ -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,83 @@
import { useCallback } from 'react';
import { useWebConnectStateManager } from '../state/useWebConnectStateManager';
import { IGetConnectState, IRegisterEventHandler, IWebConnection } from '../types';
import { useWebRTCConnectionCore } from './useWebRTCConnectionCore';
import { useWebRTCDataChannelManager } from './useWebRTCDataChannelManager';
import { useWebRTCTrackManager } from './useWebRTCTrackManager';
/**
* 共享 WebRTC 连接管理器
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
* 整合所有模块,提供统一的接口
*
* webrtc 实现 - 初始化时不需要 WebSocket通过 injectWebSocket 动态注入
*
*/
export function useSharedWebRTCManagerImpl(): IWebConnection & IRegisterEventHandler & IGetConnectState & {
injectWebSocket: (ws: WebSocket) => void;
} {
// 创建各个管理器实例
const stateManager = useWebConnectStateManager();
const dataChannelManager = useWebRTCDataChannelManager(stateManager);
const trackManager = useWebRTCTrackManager(stateManager);
const connectionCore = useWebRTCConnectionCore(
stateManager,
dataChannelManager,
trackManager
);
// 创建 createOfferNow 方法
const createOfferNow = useCallback(async () => {
const pc = connectionCore.getPeerConnection();
const ws = connectionCore.getWebSocket();
if (!pc || !ws) {
console.error('[SharedWebRTC] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
return await connectionCore.createOfferForMedia();
} catch (error) {
console.error('[SharedWebRTC] 创建 offer 失败:', error);
return false;
}
}, [connectionCore, trackManager]);
// 返回统一的接口,保持与当前 API 一致
return {
// 状态
connectType: 'webrtc',
// 操作方法
connect: connectionCore.connect,
disconnect: () => connectionCore.disconnect(true),
retry: connectionCore.retry,
sendMessage: dataChannelManager.sendMessage,
sendData: dataChannelManager.sendData,
// 处理器注册
registerMessageHandler: dataChannelManager.registerMessageHandler,
registerDataHandler: dataChannelManager.registerDataHandler,
// 工具方法
getConnectState: stateManager.getState,
isConnectedToRoom: stateManager.isConnectedToRoom,
// 媒体轨道方法
addTrack: trackManager.addTrack,
removeTrack: trackManager.removeTrack,
onTrack: trackManager.onTrack,
getPeerConnection: connectionCore.getPeerConnection,
createOfferNow,
// 断开连接回调
setOnDisconnectCallback: connectionCore.setOnDisconnectCallback,
// 当前房间信息
currentRoom: connectionCore.getCurrentRoom(),
// WebSocket 注入方法
injectWebSocket: connectionCore.injectWebSocket,
};
}

View File

@@ -0,0 +1,816 @@
import { useCallback, useRef } from 'react';
import { getIceServersConfig } from '../../settings/useIceServersConfig';
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
import { Role, WebRTCDataChannelManager, WebRTCTrackManager } from '../types';
/**
* WebRTC 核心连接管理器
* 负责基础的 WebRTC 连接管理
*/
export interface WebRTCConnectionCore {
// 连接到房间
connect: (roomCode: string, role: Role) => Promise<void>;
// 断开连接
disconnect: (shouldNotifyDisconnect?: boolean) => void;
// 重试连接
retry: () => Promise<void>;
// 获取 PeerConnection 实例
getPeerConnection: () => RTCPeerConnection | null;
// 获取 WebSocket 实例
getWebSocket: () => WebSocket | null;
// 获取当前房间信息
getCurrentRoom: () => { code: string; role: Role } | null;
// 设置断开连接回调
setOnDisconnectCallback: (callback: () => void) => void;
// 动态注入 WebSocket 连接
injectWebSocket: (ws: WebSocket) => void;
// 创建 Offer供外部调用
createOfferForMedia: () => Promise<boolean>;
}
/**
* WebRTC 核心连接管理 Hook
* 负责基础的 WebRTC 连接管理,包括 WebSocket 连接、PeerConnection 创建和管理
* 初始化时不需要 WebSocket可以通过 injectWebSocket 动态注入
*/
export function useWebRTCConnectionCore(
stateManager: IWebConnectStateManager,
dataChannelManager: WebRTCDataChannelManager,
trackManager: WebRTCTrackManager
): WebRTCConnectionCore {
// WebSocket 连接引用,初始为空
const wsRef = useRef<WebSocket | null>(null);
const isExternalWebSocket = useRef<boolean>(false);
const pcRef = useRef<RTCPeerConnection | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 当前连接的房间信息
const currentRoom = useRef<{ code: string; role: Role } | null>(null);
// 用于跟踪是否是用户主动断开连接
const isUserDisconnecting = useRef<boolean>(false);
// 断开连接回调
const onDisconnectCallback = useRef<(() => void) | null>(null);
// 清理连接
const cleanup = useCallback((shouldNotifyDisconnect: boolean = false) => {
console.log('[ConnectionCore] 清理连接, 是否发送断开通知:', shouldNotifyDisconnect);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
// 在清理 WebSocket 之前发送断开通知
if (shouldNotifyDisconnect && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({
type: 'disconnection',
payload: { reason: '用户主动断开' }
}));
console.log('[ConnectionCore] 📤 清理时已通知对方断开连接');
} catch (error) {
console.warn('[ConnectionCore] 清理时发送断开通知失败:', error);
}
}
// 如果是外部 WebSocket不关闭连接只是清理引用
// 外部 WebSocket 的生命周期由外部管理
if (!isExternalWebSocket.current && wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
currentRoom.current = null;
isUserDisconnecting.current = false; // 重置主动断开标志
}, []);
// 创建 Offer应该在 Core 层处理信令)
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
// 清理所有没有轨道的发送器(避免空 sender 干扰)
const allSenders = pc.getSenders();
console.log('[ConnectionCore] 🎬 开始创建offer总发送器数量:', allSenders.length);
// 移除所有 track 为 null 的 sender
const emptyRemovals = allSenders.filter(sender => !sender.track).map(async sender => {
try {
await pc.removeTrack(sender);
console.log('[ConnectionCore] 🗑️ 已清理空发送器');
} catch (e) {
console.warn('[ConnectionCore] ⚠️ 清理空发送器失败:', e);
}
});
if (emptyRemovals.length > 0) {
await Promise.all(emptyRemovals);
console.log('[ConnectionCore] 🧹 已清理', emptyRemovals.length, '个空发送器');
}
// 获取清理后的有效发送器
const activeSenders = pc.getSenders().filter(s => s.track);
console.log('[ConnectionCore] 📊 有效轨道数量:', activeSenders.length);
activeSenders.forEach((sender, index) => {
console.log(`[ConnectionCore] 发送器 ${index}:`, {
kind: sender.track?.kind,
id: sender.track?.id,
enabled: sender.track?.enabled,
readyState: sender.track?.readyState
});
});
// 确保连接状态稳定
if (pc.connectionState !== 'connecting' && pc.connectionState !== 'new') {
console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', pc.connectionState);
}
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
console.log('[ConnectionCore] 📝 Offer创建成功设置本地描述...');
await pc.setLocalDescription(offer);
console.log('[ConnectionCore] ✅ 本地描述设置完成');
// 等待ICE候选收集完成或超时
await new Promise<void>((resolve) => {
const iceTimeout = setTimeout(() => {
console.log('[ConnectionCore] ⏱️ ICE收集超时继续发送offer');
resolve();
}, 3000); // 减少超时时间到3秒
// 如果ICE收集已经完成立即发送
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
resolve();
} else {
// 创建一个临时的监听器等待ICE收集完成
const originalHandler = pc.onicegatheringstatechange;
pc.onicegatheringstatechange = (event) => {
console.log('[ConnectionCore] 🧊 ICE收集状态变化:', pc.iceGatheringState);
// 调用原始处理器(如果存在)
if (originalHandler) {
originalHandler.call(pc, event);
}
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
// 恢复原始处理器
pc.onicegatheringstatechange = originalHandler;
resolve();
}
};
}
});
// 发送offer
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[ConnectionCore] 📤 发送 offer');
}
} catch (error) {
console.error('[ConnectionCore] ❌ 创建 offer 失败:', error);
stateManager.updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
}
}, [stateManager]);
// 创建 PeerConnection 和相关设置
const createPeerConnection = useCallback((ws: WebSocket, role: 'sender' | 'receiver', isReconnect: boolean = false) => {
console.log('[ConnectionCore] 🔧 创建PeerConnection...', { role, isReconnect });
// 如果已经存在PeerConnection先关闭它
if (pcRef.current) {
console.log('[ConnectionCore] 🔧 关闭已存在的PeerConnection');
pcRef.current.close();
}
// 获取用户配置的ICE服务器
const iceServers = getIceServersConfig();
console.log('[ConnectionCore] 🧊 使用ICE服务器配置:', iceServers);
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: iceServers,
iceCandidatePoolSize: 10,
});
pcRef.current = pc;
// 设置轨道接收处理(对于接收方)
// 注意:这个处理器会在 TrackManager.onTrack() 中被业务逻辑覆盖
pc.ontrack = (event) => {
console.log('[ConnectionCore] 📥 PeerConnection收到远程轨道:', {
kind: event.track.kind,
id: event.track.id,
enabled: event.track.enabled,
readyState: event.track.readyState,
streamCount: event.streams.length,
streamId: event.streams[0]?.id
});
console.log('[ConnectionCore] ⚠️ 默认轨道处理器 - 业务层应该通过 TrackManager.onTrack() 设置自己的处理器');
};
// PeerConnection 事件处理
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice-candidate',
payload: event.candidate
}));
console.log('[ConnectionCore] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
} else if (!event.candidate) {
console.log('[ConnectionCore] 🏁 ICE 收集完成');
}
};
pc.oniceconnectionstatechange = () => {
console.log('[ConnectionCore] 🧊 ICE连接状态变化:', pc.iceConnectionState);
switch (pc.iceConnectionState) {
case 'checking':
console.log('[ConnectionCore] 🔍 正在检查ICE连接...');
break;
case 'connected':
case 'completed':
console.log('[ConnectionCore] ✅ ICE连接成功');
break;
case 'failed':
console.error('[ConnectionCore] ❌ ICE连接失败');
stateManager.updateState({ error: 'ICE连接失败可能是网络防火墙阻止了连接', isConnecting: false, canRetry: true });
break;
case 'disconnected':
console.log('[ConnectionCore] 🔌 ICE连接断开');
break;
case 'closed':
console.log('[ConnectionCore] 🚫 ICE连接已关闭');
break;
}
};
pc.onconnectionstatechange = () => {
console.log('[ConnectionCore] 🔗 WebRTC连接状态变化:', pc.connectionState);
switch (pc.connectionState) {
case 'connecting':
console.log('[ConnectionCore] 🔄 WebRTC正在连接中...');
stateManager.updateState({
isPeerConnected: false,
isConnecting: true,
isConnected: false
});
break;
case 'connected':
console.log('[ConnectionCore] 🎉 WebRTC P2P连接已完全建立可以进行媒体传输');
// 确保所有连接状态都正确更新
stateManager.updateState({
isWebSocketConnected: true,
isConnected: true,
isPeerConnected: true,
isConnecting: false,
error: null,
canRetry: false
});
// 如果是重新连接,触发数据同步
if (isReconnect) {
console.log('[ConnectionCore] 🔄 检测到重新连接,触发数据同步');
// 发送同步请求消息
setTimeout(() => {
const dc = pcRef.current?.createDataChannel('sync-channel');
if (dc && dc.readyState === 'open') {
dc.send(JSON.stringify({
type: 'sync-request',
payload: { timestamp: Date.now() }
}));
console.log('[ConnectionCore] 📤 发送数据同步请求');
dc.close();
}
}, 500); // 等待数据通道完全稳定
}
break;
case 'failed':
console.error('[ConnectionCore] ❌ WebRTC连接失败');
stateManager.updateState({
error: 'WebRTC连接失败请检查网络设置或重试',
isPeerConnected: false,
isConnecting: false,
isConnected: false,
canRetry: true
});
break;
case 'disconnected':
console.log('[ConnectionCore] 🔌 WebRTC连接已断开');
stateManager.updateState({
isPeerConnected: false,
isConnecting: false,
isConnected: false
});
break;
case 'closed':
console.log('[ConnectionCore] 🚫 WebRTC连接已关闭');
stateManager.updateState({
isPeerConnected: false,
isConnecting: false,
isConnected: false
});
break;
}
};
// 创建数据通道
dataChannelManager.createDataChannel(pc, role, isReconnect);
// 立即设置 TrackManager 的 PeerConnection 引用
trackManager.setPeerConnection(pc);
trackManager.setWebSocket(ws);
console.log('[ConnectionCore] ✅ PeerConnection创建完成角色:', role, '是否重新连接:', isReconnect);
console.log('[ConnectionCore] ✅ TrackManager 引用已设置');
return pc;
}, [stateManager, dataChannelManager, trackManager]);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: Role) => {
console.log('[ConnectionCore] 🚀 开始连接到房间:', roomCode, role);
// 如果正在连接中,避免重复连接
const state = stateManager.getState();
if (state.isConnecting) {
console.warn('[ConnectionCore] ⚠️ 正在连接中,跳过重复连接请求');
return;
}
// 检查是否是重新连接(页面关闭后重新打开)
const isReconnect = currentRoom.current?.code === roomCode && currentRoom.current?.role === role;
if (isReconnect) {
console.log('[ConnectionCore] 🔄 检测到重新连接,清理旧连接');
}
// 清理之前的连接
cleanup();
currentRoom.current = { code: roomCode, role };
stateManager.setCurrentRoom({ code: roomCode, role });
stateManager.updateState({ isConnecting: true, error: null });
// 重置主动断开标志
isUserDisconnecting.current = false;
try {
// 保存重新连接状态,供后续使用
const reconnectState = { isReconnect, role };
// 必须使用注入的 WebSocket 连接
if (!wsRef.current) {
throw new Error('WebSocket 连接未注入,请先调用 injectWebSocket 方法');
}
const ws = wsRef.current;
console.log('[ConnectionCore] 使用注入的 WebSocket 连接,状态:', ws.readyState);
// 检查 WebSocket 是否已经连接
if (ws.readyState === WebSocket.OPEN) {
console.log('[ConnectionCore] WebSocket 已连接,房间准备就绪');
stateManager.updateState({
isWebSocketConnected: true,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
} else if (ws.readyState === WebSocket.CONNECTING) {
// 如果 WebSocket 还在连接中,等待连接成功
console.log('[ConnectionCore] WebSocket 连接中,等待连接完成');
stateManager.updateState({ isConnecting: true, error: null });
// 设置 WebSocket 的事件处理
const originalOnOpen = ws.onopen;
ws.onopen = (event) => {
console.log('[ConnectionCore] ✅ WebSocket 连接已建立,房间准备就绪');
stateManager.updateState({
isWebSocketConnected: true,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
// 调用原始处理器
if (originalOnOpen) {
originalOnOpen.call(ws, event);
}
};
} else {
throw new Error('WebSocket 连接状态异常: ' + ws.readyState);
}
// 设置 WebSocket 消息处理
if (ws) {
// 如果是外部 WebSocket可能已经有事件处理器我们需要保存它们
const originalOnError = ws.onerror;
const originalOnClose = ws.onclose;
ws.onmessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
console.log('[ConnectionCore] 📨 收到信令消息:', message.type);
switch (message.type) {
case 'peer-joined':
// 对方加入房间的通知
console.log('[ConnectionCore] 👥 对方已加入房间,角色:', message.payload?.role);
if (role === 'sender' && message.payload?.role === 'receiver') {
console.log('[ConnectionCore] 🚀 接收方已连接发送方开始建立P2P连接');
// 确保WebSocket连接状态正确更新
stateManager.updateState({
isWebSocketConnected: true,
isConnected: true,
isJoinedRoom: true,
});
// 如果是重新连接先清理旧的PeerConnection
if (reconnectState.isReconnect && pcRef.current) {
console.log('[ConnectionCore] 🔄 重新连接清理旧的PeerConnection');
pcRef.current.close();
pcRef.current = null;
}
// 对方加入后创建PeerConnection
const pc = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pc);
trackManager.setWebSocket(ws);
// 发送方创建offer建立基础P2P连接
try {
console.log('[ConnectionCore] 📡 创建基础P2P连接offer');
await createOffer(pc, ws);
} catch (error) {
console.error('[ConnectionCore] 创建基础P2P连接失败:', error);
}
} else if (role === 'receiver' && message.payload?.role === 'sender') {
console.log('[ConnectionCore] 🚀 发送方已连接接收方准备接收P2P连接');
// 确保WebSocket连接状态正确更新
stateManager.updateState({
isWebSocketConnected: true,
isConnected: true,
isJoinedRoom: true,
});
// 如果是重新连接先清理旧的PeerConnection
if (reconnectState.isReconnect && pcRef.current) {
console.log('[ConnectionCore] 🔄 重新连接清理旧的PeerConnection');
pcRef.current.close();
pcRef.current = null;
}
// 对方加入后立即创建PeerConnection准备接收offer
const pc = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pc);
trackManager.setWebSocket(ws);
// 等待一小段时间确保PeerConnection完全初始化
setTimeout(() => {
console.log('[ConnectionCore] ✅ 接收方PeerConnection已准备就绪');
}, 100);
}
break;
case 'offer':
console.log('[ConnectionCore] 📬 处理offer...');
// 如果PeerConnection不存在先创建它
let pcOffer = pcRef.current;
if (!pcOffer) {
console.log('[ConnectionCore] 🔧 PeerConnection不存在先创建它');
pcOffer = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pcOffer);
trackManager.setWebSocket(ws);
// 等待一小段时间确保PeerConnection完全初始化
await new Promise(resolve => setTimeout(resolve, 100));
}
if (pcOffer) {
const currentState = pcOffer.signalingState;
console.log('[ConnectionCore] 当前信令状态:', currentState, '角色:', role);
// Perfect Negotiation 模式receiver 是 politesender 是 impolite
const isPolite = role === 'receiver';
// 处理交叉协商
if (currentState === 'have-local-offer') {
if (isPolite) {
// Polite peer (receiver) 回滚自己的 offer
console.log('[ConnectionCore] 🔄 [Polite-Receiver] 交叉协商,回滚本地 offer');
await pcOffer.setLocalDescription({ type: 'rollback' });
} else {
// Impolite peer (sender) 也需要接受对方的 offer
// 之前的逻辑错误:不应该直接 break而是也要回滚或等待
console.log('[ConnectionCore] 🔄 [Impolite-Sender] 交叉协商,回滚并接受对方 offer');
await pcOffer.setLocalDescription({ type: 'rollback' });
}
}
await pcOffer.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[ConnectionCore] ✅ 设置远程描述完成');
// 记录当前本地轨道
const currentSenders = pcOffer.getSenders();
console.log('[ConnectionCore] 📊 创建 answer 前的本地轨道数量:', currentSenders.length);
currentSenders.forEach((sender, index) => {
console.log(`[ConnectionCore] 本地发送器 ${index}:`, {
kind: sender.track?.kind,
id: sender.track?.id,
enabled: sender.track?.enabled
});
});
const answer = await pcOffer.createAnswer();
await pcOffer.setLocalDescription(answer);
console.log('[ConnectionCore] ✅ 创建并设置answer完成');
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('[ConnectionCore] 📤 发送 answer');
} else {
console.warn('[ConnectionCore] ⚠️ PeerConnection不存在');
}
break;
case 'answer':
console.log('[ConnectionCore] 📬 处理answer...');
let pcAnswer = pcRef.current;
try {
// 如果PeerConnection不存在先创建它
if (!pcAnswer) {
console.log('[ConnectionCore] 🔧 PeerConnection不存在先创建它');
pcAnswer = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pcAnswer);
trackManager.setWebSocket(ws);
// 等待一小段时间确保PeerConnection完全初始化
await new Promise(resolve => setTimeout(resolve, 100));
}
if (pcAnswer) {
const signalingState = pcAnswer.signalingState;
console.log('[ConnectionCore] 当前信令状态:', signalingState, '角色:', role);
if (signalingState === 'have-local-offer') {
// 正常的answer处理
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[ConnectionCore] ✅ answer 处理完成');
} else {
console.warn('[ConnectionCore] ⚠️ PeerConnection状态不是have-local-offer:', signalingState, '跳过answer处理');
}
}
} catch (error) {
console.error('[ConnectionCore] ❌ 处理answer失败:', error);
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
console.warn('[ConnectionCore] ⚠️ Answer处理失败可能是连接状态变化导致的');
// 清理连接状态,让客户端重新连接
stateManager.updateState({ error: 'WebRTC连接状态异常请重新连接', isPeerConnected: false });
}
}
break;
case 'renegotiate-request':
// 接收方请求重新协商(例如添加/移除音频轨道)
console.log('[ConnectionCore] 🔄 收到重新协商请求:', message.payload);
if (role === 'sender') {
// 只有发送方才能响应重新协商请求
const pcRenegotiate = pcRef.current;
if (pcRenegotiate) {
console.log('[ConnectionCore] 📡 [Sender] 响应重新协商请求,创建新的 offer');
try {
await createOffer(pcRenegotiate, ws);
console.log('[ConnectionCore] ✅ [Sender] 重新协商 offer 发送完成');
} catch (error) {
console.error('[ConnectionCore] ❌ [Sender] 重新协商失败:', error);
}
} else {
console.warn('[ConnectionCore] ⚠️ [Sender] PeerConnection 不存在,无法重新协商');
}
} else {
console.warn('[ConnectionCore] ⚠️ [Receiver] 收到重新协商请求但角色不是 sender');
}
break;
case 'ice-candidate':
let pcIce = pcRef.current;
if (!pcIce) {
console.log('[ConnectionCore] 🔧 PeerConnection不存在先创建它');
pcIce = createPeerConnection(ws, role, reconnectState.isReconnect);
// 等待一小段时间确保PeerConnection完全初始化
await new Promise(resolve => setTimeout(resolve, 100));
}
if (pcIce && message.payload) {
try {
// 即使远程描述未设置也可以先缓存ICE候选
if (pcIce.remoteDescription) {
await pcIce.addIceCandidate(new RTCIceCandidate(message.payload));
console.log('[ConnectionCore] ✅ 添加 ICE 候选成功');
} else {
console.log('[ConnectionCore] 📝 远程描述未设置缓存ICE候选');
// 可以在这里实现ICE候选缓存机制等远程描述设置后再添加
}
} catch (err) {
console.warn('[ConnectionCore] ⚠️ 添加 ICE 候选失败:', err);
}
} else {
console.warn('[ConnectionCore] ⚠️ ICE候选无效或PeerConnection不存在');
}
break;
case 'error':
const errorMessage = message.error || '信令服务器返回未知错误';
console.error('[ConnectionCore] ❌ 信令服务器错误:', errorMessage);
stateManager.updateState({ error: errorMessage, isConnecting: false, canRetry: true });
break;
case 'disconnection':
console.log('[ConnectionCore] 🔌 对方主动断开连接');
// 对方断开连接的处理
stateManager.updateState({
isPeerConnected: false,
isDataChannelConnected: false,
isConnected: false, // 添加这个状态
isJoinedRoom: false,
error: '对方已离开房间',
canRetry: true
});
// 清理P2P连接但保持WebSocket连接允许重新连接
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
// 调用断开连接回调,通知上层应用清除数据
if (onDisconnectCallback.current) {
console.log('[ConnectionCore] 📞 调用断开连接回调');
onDisconnectCallback.current();
}
break;
default:
console.warn('[ConnectionCore] ⚠️ 未知消息类型:', message.type);
}
} catch (error) {
console.error('[ConnectionCore] ❌ 处理信令消息失败:', error);
// stateManager.updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
}
};
// 对于外部WebSocket需要设置错误和关闭事件处理器
if (isExternalWebSocket.current) {
ws.onerror = (error: Event) => {
console.error('[ConnectionCore] ❌ WebSocket 错误:', error);
stateManager.updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
// 调用原始错误处理器
if (originalOnError) {
originalOnError.call(ws, error);
}
};
ws.onclose = (event: CloseEvent) => {
console.log('[ConnectionCore] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
stateManager.updateState({ isWebSocketConnected: false });
// 检查是否是用户主动断开
if (isUserDisconnecting.current) {
console.log('[ConnectionCore] ✅ 用户主动断开,正常关闭');
// 用户主动断开时不显示错误消息
return;
}
// 只有在非正常关闭且不是用户主动断开时才显示错误
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
stateManager.updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
}
// 调用原始关闭处理器
if (originalOnClose) {
originalOnClose.call(ws, event);
}
};
}
}
} catch (error) {
console.error('[ConnectionCore] 连接失败:', error);
stateManager.updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false,
canRetry: true
});
}
}, [stateManager, cleanup, createPeerConnection]);
// 断开连接
const disconnect = useCallback((shouldNotifyDisconnect: boolean = false) => {
console.log('[ConnectionCore] 主动断开连接');
// 设置主动断开标志
isUserDisconnecting.current = true;
// 清理连接并发送断开通知
cleanup(shouldNotifyDisconnect);
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
stateManager.resetToInitial();
console.log('[ConnectionCore] ✅ 连接已断开并清理完成');
}, [cleanup, stateManager]);
// 重试连接
const retry = useCallback(async () => {
const room = currentRoom.current;
if (!room) {
console.warn('[ConnectionCore] 没有当前房间信息,无法重试');
stateManager.updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
return;
}
console.log('[ConnectionCore] 🔄 重试连接到房间:', room.code, room.role);
// 清理当前连接
cleanup();
// 重新连接
await connect(room.code, room.role);
}, [cleanup, connect, stateManager]);
// 获取 PeerConnection 实例
const getPeerConnection = useCallback(() => {
return pcRef.current;
}, []);
// 获取 WebSocket 实例
const getWebSocket = useCallback(() => {
return wsRef.current;
}, []);
// 获取当前房间信息
const getCurrentRoom = useCallback(() => {
return currentRoom.current;
}, []);
// 设置断开连接回调
const setOnDisconnectCallback = useCallback((callback: () => void) => {
onDisconnectCallback.current = callback;
}, []);
// 动态注入 WebSocket 连接
const injectWebSocket = useCallback((ws: WebSocket) => {
console.log('[ConnectionCore] 注入外部 WebSocket 连接');
wsRef.current = ws;
isExternalWebSocket.current = true;
}, []);
// 供外部调用的创建 Offer 方法
const createOfferForMedia = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[ConnectionCore] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
await createOffer(pc, ws);
return true;
} catch (error) {
console.error('[ConnectionCore] 创建媒体 offer 失败:', error);
return false;
}
}, [createOffer]);
return {
connect,
disconnect,
retry,
getPeerConnection,
getWebSocket,
getCurrentRoom,
setOnDisconnectCallback,
injectWebSocket,
createOfferForMedia,
};
}

View File

@@ -0,0 +1,384 @@
import { useCallback, useEffect, useRef } from 'react';
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
import { DataHandler, IRegisterEventHandler, IWebMessage, MessageHandler, Role, WebRTCDataChannelManager } from '../types';
/**
* WebRTC 数据通道管理 Hook
* 负责数据通道的创建和管理,处理数据通道消息的发送和接收
*/
export function useWebRTCDataChannelManager(
stateManager: IWebConnectStateManager
): WebRTCDataChannelManager & IRegisterEventHandler {
const dcRef = useRef<RTCDataChannel | null>(null);
const stateManagerRef = useRef(stateManager);
stateManagerRef.current = stateManager;
// 多通道消息处理器
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// 创建数据通道
const createDataChannel = useCallback((
pc: RTCPeerConnection,
role: Role,
isReconnect: boolean = false
) => {
console.log('[DataChannelManager] 创建数据通道...', { role, isReconnect });
// 如果已经存在数据通道,先关闭它
if (dcRef.current) {
console.log('[DataChannelManager] 关闭已存在的数据通道');
dcRef.current.close();
dcRef.current = null;
}
// 数据通道处理
if (role === 'sender') {
const dataChannel = pc.createDataChannel('shared-channel', {
ordered: true,
maxRetransmits: 3
});
dcRef.current = dataChannel;
dataChannel.onopen = (event) => {
console.log('[DataChannelManager] 数据通道已打开 (发送方)');
// 确保所有连接状态都正确更新
stateManagerRef.current.updateState({
isDataChannelConnected: true,
isConnected: true,
isPeerConnected: true,
error: null,
isConnecting: false,
canRetry: false,
state: 'open',
stateMsg: "数据通道已打开"
});
// 如果是重新连接,触发数据同步
if (isReconnect) {
console.log('[DataChannelManager] 发送方重新连接,数据通道已打开,准备同步数据');
// 发送同步请求消息
setTimeout(() => {
if (dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify({
type: 'sync-request',
payload: { timestamp: Date.now() }
}));
console.log('[DataChannelManager] 发送方发送数据同步请求');
}
}, 300); // 等待数据通道完全稳定
}
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[DataChannelManager] 数据通道错误:', 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状态
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(`[DataChannelManager] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
stateManagerRef.current.updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
isDataChannelConnected: false,
canRetry: shouldRetry // 设置是否可以重试
});
};
} else {
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dcRef.current = dataChannel;
dataChannel.onopen = (event) => {
console.log('[DataChannelManager] 数据通道已打开 (接收方)');
// 确保所有连接状态都正确更新
stateManagerRef.current.updateState({
isWebSocketConnected: true,
isDataChannelConnected: true,
isConnected: true,
isPeerConnected: true,
error: null,
isConnecting: false,
canRetry: false,
state: 'open'
});
// 如果是重新连接,触发数据同步
if (isReconnect) {
console.log('[DataChannelManager] 接收方重新连接,数据通道已打开,准备同步数据');
// 发送同步请求消息
setTimeout(() => {
if (dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify({
type: 'sync-request',
payload: { timestamp: Date.now() }
}));
console.log('[DataChannelManager] 接收方发送数据同步请求');
}
}, 300); // 等待数据通道完全稳定
}
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[DataChannelManager] 数据通道错误 (接收方):', 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状态
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(`[DataChannelManager] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
stateManagerRef.current.updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
isDataChannelConnected: false,
canRetry: shouldRetry // 设置是否可以重试
});
};
};
}
console.log('[DataChannelManager] 数据通道创建完成,角色:', role, '是否重新连接:', isReconnect);
}, [stateManager]);
// 处理数据通道消息
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
console.log('[DataChannelManager] 收到数据通道消息,类型:', typeof event.data);
console.log('[DataChannelManager] 数据通道当前状态:', messageHandlers.current);
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as IWebMessage;
console.log('[DataChannelManager] 收到消息:', message.type, message.channel || 'default');
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 兼容旧版本,广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} catch (error) {
console.error('[DataChannelManager] 解析消息失败:', error);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('[DataChannelManager] 收到数据:', event.data.byteLength, 'bytes');
// 数据优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 如果没有文件处理器,发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[DataChannelManager] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[DataChannelManager] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 发送消息
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[DataChannelManager] 数据通道未准备就绪');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
dataChannel.send(JSON.stringify(messageWithChannel));
console.log('[DataChannelManager] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[DataChannelManager] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[DataChannelManager] 数据通道未准备就绪');
return false;
}
try {
dataChannel.send(data);
console.log('[DataChannelManager] 发送数据:', data.byteLength, 'bytes');
return true;
} catch (error) {
console.error('[DataChannelManager] 发送数据失败:', error);
return false;
}
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
return stateManagerRef.current.getState();
}, []);
// 实时更新数据通道状态
useEffect(() => {
const updateChannelState = () => {
const readyState = dcRef.current?.readyState || 'closed';
console.log('[DataChannelManager] 数据通道状态更新:', readyState);
// 更新状态存储中的数据通道状态
stateManagerRef.current.updateState({
state: readyState,
isDataChannelConnected: readyState === 'open'
});
};
// 立即更新一次
updateChannelState();
// 如果数据通道存在,设置状态变化监听
if (dcRef.current) {
const dc = dcRef.current;
const originalOnOpen = dc.onopen;
const originalOnClose = dc.onclose;
const originalOnError = dc.onerror;
dc.onopen = (event) => {
console.log('[DataChannelManager] 数据通道打开事件触发');
updateChannelState();
if (originalOnOpen) originalOnOpen.call(dc, event);
};
dc.onclose = (event) => {
console.log('[DataChannelManager] 数据通道关闭事件触发');
updateChannelState();
if (originalOnClose) originalOnClose.call(dc, event);
};
dc.onerror = (error) => {
console.log('[DataChannelManager] 数据通道错误事件触发');
updateChannelState();
if (originalOnError) originalOnError.call(dc, error);
};
}
// 清理函数
return () => {
if (dcRef.current) {
// 恢复原始事件处理器
// 注意:在实际应用中,可能需要更复杂的事件处理器管理
}
};
}, []);
return {
createDataChannel,
sendMessage,
sendData,
getConnectState: getChannelState,
registerDataHandler,
registerMessageHandler,
handleDataChannelMessage,
};
}

View File

@@ -0,0 +1,196 @@
import { useCallback, useRef } from 'react';
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
import { WebRTCTrackManager } from '../types';
/**
* WebRTC 媒体轨道管理 Hook
* 负责媒体轨道的添加和移除,处理轨道事件
* 信令相关功能(如 createOffer已移至 ConnectionCore
*/
export function useWebRTCTrackManager(
stateManager: IWebConnectStateManager
): WebRTCTrackManager {
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const retryInProgressRef = useRef<boolean>(false); // 防止多个重试循环
// 媒体协商:通知 Core 层需要重新创建 Offer
// 这个方法由业务层调用,用于添加媒体轨道后的重新协商
const requestOfferRenegotiation = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用,无法请求重新协商');
return false;
}
try {
// 触发重新协商事件(应该由 Core 层监听)
return true;
} catch (error) {
console.error('[TrackManager] 请求重新协商失败:', error);
return false;
}
}, []);
// 添加媒体轨道
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
const pc = pcRef.current;
if (!pc) {
console.error('[TrackManager] PeerConnection 不可用');
return null;
}
try {
return pc.addTrack(track, stream);
} catch (error) {
console.error('[TrackManager] 添加轨道失败:', error);
return null;
}
}, []);
// 移除媒体轨道
const removeTrack = useCallback((sender: RTCRtpSender) => {
const pc = pcRef.current;
if (!pc) {
console.error('[TrackManager] PeerConnection 不可用');
return;
}
try {
pc.removeTrack(sender);
} catch (error) {
console.error('[TrackManager] 移除轨道失败:', error);
}
}, []);
// 存储多个轨道处理器
const trackHandlersRef = useRef<Set<(event: RTCTrackEvent) => void>>(new Set());
// 设置轨道处理器 - 返回清理函数
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void): (() => void) => {
// 添加到处理器集合
trackHandlersRef.current.add(handler);
const pc = pcRef.current;
if (!pc) {
// 检查是否已有重试在进行,避免多个重试循环
if (retryInProgressRef.current) {
// 返回清理函数
return () => {
trackHandlersRef.current.delete(handler);
console.log('[TrackManager] 🗑️ 移除轨道处理器,剩余处理器数量:', trackHandlersRef.current.size);
};
}
// 检查WebSocket连接状态只有连接后才尝试设置
const state = stateManager.getState();
if (!state.isWebSocketConnected) {
// 返回清理函数
return () => {
trackHandlersRef.current.delete(handler);
};
}
retryInProgressRef.current = true;
// 延迟设置等待PeerConnection准备就绪
let retryCount = 0;
const maxRetries = 20; // 减少重试次数到20次即2秒
const checkAndSetTrackHandler = () => {
const currentPc = pcRef.current;
if (currentPc) {
// 设置多路复用处理器
currentPc.ontrack = (event: RTCTrackEvent) => {
trackHandlersRef.current.forEach(h => {
try {
h(event);
} catch (error) {
console.error('[TrackManager] 轨道处理器执行错误:', error);
}
});
};
retryInProgressRef.current = false;
} else {
retryCount++;
if (retryCount < maxRetries) {
setTimeout(checkAndSetTrackHandler, 100);
} else {
console.error('[TrackManager] ❌ PeerConnection 长时间未准备就绪,停止重试');
retryInProgressRef.current = false; // 失败后也要重置标记
}
}
};
checkAndSetTrackHandler();
// 返回清理函数
return () => {
trackHandlersRef.current.delete(handler);
};
}
// 设置多路复用处理器
pc.ontrack = (event: RTCTrackEvent) => {
trackHandlersRef.current.forEach(h => {
try {
h(event);
} catch (error) {
console.error('[TrackManager] 轨道处理器执行错误:', error);
}
});
};
// 返回清理函数
return () => {
trackHandlersRef.current.delete(handler);
};
}, [stateManager]);
// 立即触发重新协商(用于媒体轨道添加后的重新协商)
const triggerRenegotiation = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
// 实际的 offer 创建应该由 Core 层处理
// 这里只是一个触发器,通知需要重新协商
return true;
} catch (error) {
console.error('[TrackManager] 触发重新协商失败:', error);
return false;
}
}, []);
// 设置 PeerConnection 引用
const setPeerConnection = useCallback((pc: RTCPeerConnection | null) => {
pcRef.current = pc;
// 当PeerConnection设置时重置重试标记
if (pc) {
retryInProgressRef.current = false;
}
}, []);
// 设置 WebSocket 引用
const setWebSocket = useCallback((ws: WebSocket | null) => {
wsRef.current = ws;
}, []);
return {
addTrack,
removeTrack,
onTrack,
requestOfferRenegotiation,
triggerRenegotiation,
// 内部方法,供核心连接管理器调用
setPeerConnection,
setWebSocket,
};
}

View File

@@ -0,0 +1,2 @@
// WebSocket 连接相关导出
export { useWebSocketConnection } from './useWebSocketConnection';

View File

@@ -0,0 +1,452 @@
import { useCallback, useEffect, useRef } from 'react';
import { useWebConnectStateManager } from '../state/useWebConnectStateManager';
import { WebConnectState } from '../state/webConnectStore';
import { ConnectType, DataHandler, IWebConnection, IWebMessage, MessageHandler, Role } from '../types';
/**
* WebSocket 连接管理器
* 实现 IWebConnection 接口,提供基于 WebSocket 的数据传输
* 支持注入外部 WebSocket 连接
*/
export function useWebSocketConnection(): IWebConnection & { injectWebSocket: (ws: WebSocket) => void } {
const wsRef = useRef<WebSocket | null>(null);
const currentRoomRef = useRef<{ code: string; role: Role } | null>(null);
// 事件处理器存储
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// 断开连接回调
const onDisconnectCallback = useRef<(() => void) | null>(null);
// 全局状态管理器
const stateManager = useWebConnectStateManager();
// 创建稳定的状态管理器引用,避免无限循环
const stateManagerRef = useRef(stateManager);
stateManagerRef.current = stateManager;
// 缓存上次的状态,用于比较是否真正改变
const lastStateRef = useRef<Partial<WebConnectState>>({});
// 智能状态更新 - 只在状态真正改变时才更新,使用稳定引用
const updateState = useCallback((updates: Partial<WebConnectState>) => {
// 检查状态是否真正改变
const hasChanged = Object.keys(updates).some(key => {
const typedKey = key as keyof WebConnectState;
return lastStateRef.current[typedKey] !== updates[typedKey];
});
if (hasChanged) {
console.log('[WebSocket] 状态更新:', updates);
lastStateRef.current = { ...lastStateRef.current, ...updates };
stateManagerRef.current.updateState(updates);
} else {
console.log('[WebSocket] 状态未改变,跳过更新:', updates);
}
}, []); // 空依赖数组,使用 ref 访问最新的 stateManager
// 连接到房间
const connect = useCallback(async (roomCode: string, role: Role) => {
// 检查是否已经注入了 WebSocket
if (!wsRef.current) {
throw new Error('[WebSocket] 尚未注入 WebSocket 连接,请先调用 injectWebSocket');
}
const ws = wsRef.current;
// 检查 WebSocket 状态
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
throw new Error('[WebSocket] 注入的 WebSocket 连接已关闭');
}
updateState({ isConnecting: true, error: null, canRetry: false });
currentRoomRef.current = { code: roomCode, role };
try {
console.log('[WebSocket] 使用注入的 WebSocket 连接到房间:', roomCode, '角色:', role);
// 如果 WebSocket 已经连接,直接更新状态
if (ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] WebSocket 已连接,直接设置为已连接状态');
updateState({
isConnected: true,
isConnecting: false,
isWebSocketConnected: true,
isPeerConnected: true, // 欺骗 UI让 WebRTC 相关功能正常工作
isDataChannelConnected: true, // 欺骗 UIWebSocket 也能传输数据
isMediaStreamConnected: true, // 欺骗 UI保证所有功能可用
state: 'open', // RTCDataChannelState.open
error: null,
canRetry: false
});
} else if (ws.readyState === WebSocket.CONNECTING) {
console.log('[WebSocket] WebSocket 正在连接中,等待连接完成');
// WebSocket 正在连接中,等待 onopen 事件
} else {
throw new Error('[WebSocket] WebSocket 状态异常: ' + ws.readyState);
}
} catch (error) {
console.error('[WebSocket] 连接异常:', error);
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: error instanceof Error ? error.message : '无法使用注入的 WebSocket 连接',
canRetry: true
});
throw error;
}
}, [updateState]);
// 处理收到的消息
const handleMessage = useCallback(async (event: MessageEvent) => {
try {
console.log('[WebSocket] 收到消息事件:', typeof event.data, event.data.constructor?.name,
event.data instanceof ArrayBuffer ? `ArrayBuffer ${event.data.byteLength} bytes` :
event.data instanceof Blob ? `Blob ${event.data.size} bytes` : 'JSON');
// 处理二进制数据 - 支持 ArrayBuffer 和 Blob
if (event.data instanceof ArrayBuffer) {
// 直接的 ArrayBuffer 数据
console.log('[WebSocket] 收到 ArrayBuffer 数据:', event.data.byteLength, 'bytes');
// 优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
} else if (event.data instanceof Blob) {
// Blob 数据,需要转换为 ArrayBuffer
console.log('[WebSocket] 收到 Blob 数据:', event.data.size, 'bytes正在转换为 ArrayBuffer');
try {
const arrayBuffer = await event.data.arrayBuffer();
console.log('[WebSocket] Blob 转换完成ArrayBuffer 大小:', arrayBuffer.byteLength, 'bytes');
// 优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(arrayBuffer);
} else {
// 发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(arrayBuffer);
}
}
} catch (blobError) {
console.error('[WebSocket] Blob 转换为 ArrayBuffer 失败:', blobError);
}
} else if (typeof event.data === 'string') {
// JSON 消息
const message = JSON.parse(event.data) as IWebMessage;
// 特殊处理 disconnection 消息 - 与 WebRTC 保持一致
if (message.type === 'disconnection') {
console.log('[WebSocket] 🔌 对方主动断开连接');
// 更新连接状态
updateState({
isPeerConnected: false,
isConnected: false,
isDataChannelConnected: false,
error: '对方已离开房间',
stateMsg: null,
canRetry: true
});
// 调用断开连接回调,通知上层应用清除数据
if (onDisconnectCallback.current) {
console.log('[WebSocket] 📞 调用断开连接回调');
onDisconnectCallback.current();
}
}
if (message.type === 'peer-joined') {
console.log('[WebSocket] 🎉 对方加入房间')
updateState({
isPeerConnected: true,
isConnected: true,
isWebSocketConnected: true,
currentConnectType: 'websocket',
error: null,
stateMsg: '对方已经加入房间',
canRetry: true
});
}
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} else {
console.warn('[WebSocket] 收到未知数据类型:', typeof event.data, event.data.constructor?.name, event.data);
}
} catch (error) {
console.error('[WebSocket] 处理消息失败:', error);
}
}, []);
// 断开连接
const disconnect = useCallback(() => {
if (wsRef.current) {
console.log('[WebSocket] 主动断开连接');
wsRef.current.close(1000, '用户主动断开');
wsRef.current = null;
}
currentRoomRef.current = null;
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: null,
canRetry: false
});
}, [updateState]);
// 重试连接
const retry = useCallback(async () => {
if (currentRoomRef.current) {
console.log('[WebSocket] 重试连接');
await connect(currentRoomRef.current.code, currentRoomRef.current.role);
}
}, [connect]);
// 发送消息
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('[WebSocket] 连接未就绪,无法发送消息');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
ws.send(JSON.stringify(messageWithChannel));
console.log('[WebSocket] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[WebSocket] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('[WebSocket] 连接未就绪,无法发送数据');
return false;
}
try {
ws.send(data);
return true;
} catch (error) {
console.error('[WebSocket] 发送数据失败:', error);
return false;
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[WebSocket] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[WebSocket] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[WebSocket] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[WebSocket] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 获取连接状态
const getConnectState = useCallback((): WebConnectState => {
return { ...stateManagerRef.current.getState() };
}, []);
// 检查是否连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: Role) => {
return stateManagerRef.current.isConnectedToRoom(roomCode, role);
}, []);
// 媒体轨道方法WebSocket 不支持,返回 null
const addTrack = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
return null;
}, []);
const removeTrack = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
}, []);
const onTrack = useCallback((callback: (event: RTCTrackEvent) => void): (() => void) => {
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
// 返回清理函数以符合接口预期
return () => {};
}, []);
const getPeerConnection = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持 PeerConnection');
return null;
}, []);
const createOfferNow = useCallback(async () => {
console.warn('[WebSocket] WebSocket 不支持创建 Offer');
return false;
}, []);
// 注入外部 WebSocket 连接
const injectWebSocket = useCallback((ws: WebSocket) => {
console.log('[WebSocket] 注入外部 WebSocket 连接');
// 如果已有连接,先断开
if (wsRef.current) {
wsRef.current.close();
}
wsRef.current = ws;
// 设置事件处理器
ws.onopen = () => {
console.log('[WebSocket] 注入的 WebSocket 连接成功');
updateState({
currentConnectType: 'websocket',
isConnected: true,
isConnecting: false,
isWebSocketConnected: true,
isPeerConnected: true, // 欺骗 UI让 WebRTC 相关功能正常工作
isDataChannelConnected: true, // 欺骗 UIWebSocket 也能传输数据
isMediaStreamConnected: true, // 欺骗 UI保证所有功能可用
state: 'open', // RTCDataChannelState.open
error: null,
canRetry: false,
});
};
ws.onmessage = (event) => {
handleMessage(event);
};
ws.onerror = (error) => {
console.error('[WebSocket] 注入的 WebSocket 连接错误:', error);
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: 'WebSocket 连接失败',
canRetry: true
});
};
ws.onclose = (event) => {
console.log('[WebSocket] 注入的 WebSocket 连接关闭:', event.code, event.reason);
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: event.wasClean ? null : 'WebSocket 连接意外断开',
canRetry: !event.wasClean
});
// 调用断开连接回调
if (onDisconnectCallback.current) {
console.log('[WebSocket] 调用断开连接回调');
onDisconnectCallback.current();
}
};
// 如果 WebSocket 已经连接,立即更新状态
if (ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] 注入的 WebSocket 已连接,立即更新状态');
updateState({
isConnected: true,
isConnecting: false,
isWebSocketConnected: true,
isPeerConnected: true, // 欺骗 UI让 WebRTC 相关功能正常工作
isDataChannelConnected: true, // 欺骗 UIWebSocket 也能传输数据
isMediaStreamConnected: true, // 欺骗 UI保证所有功能可用
state: 'open', // RTCDataChannelState.open
error: null,
canRetry: false
});
}
}, [handleMessage, updateState]);
// 设置断开连接回调
const setOnDisconnectCallback = useCallback((callback: () => void) => {
onDisconnectCallback.current = callback;
}, []);
// 清理连接
useEffect(() => {
return () => {
// 清理时直接关闭 WebSocket不调用 disconnect 避免状态更新循环
if (wsRef.current) {
console.log('[WebSocket] 组件卸载,清理 WebSocket 连接');
wsRef.current.close(1000, '组件卸载');
wsRef.current = null;
}
currentRoomRef.current = null;
};
}, []); // 空依赖数组,只在组件挂载和卸载时执行
return {
connectType: 'websocket' as ConnectType,
connect,
disconnect,
retry,
sendMessage,
sendData,
registerMessageHandler,
registerDataHandler,
getConnectState,
isConnectedToRoom,
currentRoom: currentRoomRef.current,
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
setOnDisconnectCallback,
injectWebSocket,
};
}

View File

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

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
interface AudioVisualizerState {
volume: number; // 0-100
isSpeaking: boolean;
}
export function useAudioVisualizer(stream: MediaStream | null) {
const [state, setState] = useState<AudioVisualizerState>({
volume: 0,
isSpeaking: false,
});
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
if (!stream) {
// 清理状态
setState({ volume: 0, isSpeaking: false });
return;
}
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
return;
}
try {
// 创建音频上下文
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
// 创建分析器节点
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
analyserRef.current = analyser;
// 连接音频流到分析器
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
// 创建数据数组
const dataArray = new Uint8Array(analyser.frequencyBinCount);
// 音量检测阈值
const SPEAKING_THRESHOLD = 10; // 说话阈值
const SILENCE_FRAMES = 10; // 连续多少帧低于阈值才认为停止说话
let silenceFrameCount = 0;
// 分析音频数据
const analyzeAudio = () => {
if (!analyserRef.current) return;
analyser.getByteFrequencyData(dataArray);
// 计算平均音量
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i];
}
const average = sum / dataArray.length;
// 归一化到 0-100
const normalizedVolume = Math.min(100, Math.round((average / 255) * 100));
// 判断是否在说话
const currentlySpeaking = normalizedVolume > SPEAKING_THRESHOLD;
if (currentlySpeaking) {
silenceFrameCount = 0;
setState(prev => ({
volume: normalizedVolume,
isSpeaking: true,
}));
} else {
silenceFrameCount++;
if (silenceFrameCount >= SILENCE_FRAMES) {
setState(prev => ({
volume: normalizedVolume,
isSpeaking: false,
}));
} else {
// 保持说话状态,但更新音量
setState(prev => ({
volume: normalizedVolume,
isSpeaking: prev.isSpeaking,
}));
}
}
animationFrameRef.current = requestAnimationFrame(analyzeAudio);
};
// 开始分析
analyzeAudio();
// 清理函数
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
setState({ volume: 0, isSpeaking: false });
};
} catch (error) {
console.error('[AudioVisualizer] 初始化音频分析器失败:', error);
}
}, [stream]);
return state;
}

View File

@@ -0,0 +1,759 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useConnectManager } from '../connection';
interface DesktopShareState {
isSharing: boolean;
isViewing: boolean;
connectionCode: string;
remoteStream: MediaStream | null;
localStream: MediaStream | null; // 添加到状态中以触发重新渲染
error: string | null;
isWaitingForPeer: boolean; // 新增:是否等待对方连接
}
export function useDesktopShareBusiness() {
const webRTC = useConnectManager();
const [state, setState] = useState<DesktopShareState>({
isSharing: false,
isViewing: false,
connectionCode: '',
remoteStream: null,
localStream: null,
error: null,
isWaitingForPeer: false,
});
const localStreamRef = useRef<MediaStream | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
const currentSenderRef = useRef<RTCRtpSender | null>(null);
const updateState = useCallback((updates: Partial<DesktopShareState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 处理远程流
const handleRemoteStream = useCallback((stream: MediaStream) => {
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
setState(prev => ({ ...prev, remoteStream: stream }));
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = stream;
}
}, []); // 移除updateState依赖直接使用setState
// 设置远程轨道处理器(始终监听)
const handleRemoteTrack = useCallback((event: RTCTrackEvent) => {
// 只处理视频轨道,音频轨道由 useVoiceChatBusiness 处理
if (event.track.kind !== 'video') {
console.log('[DesktopShare] ⏭️ 跳过非视频轨道:', event.track.kind, event.track.id);
return;
}
console.log('[DesktopShare] 🎥 收到远程视频轨道:', event.track.id, '状态:', event.track.readyState);
console.log('[DesktopShare] 远程流数量:', event.streams.length);
if (event.streams.length > 0) {
const remoteStream = event.streams[0];
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
// 只提取视频轨道创建新的视频流
const videoTracks = remoteStream.getVideoTracks();
if (videoTracks.length > 0) {
const videoStream = new MediaStream(videoTracks);
console.log('[DesktopShare] 📹 创建纯视频流,视频轨道数:', videoTracks.length);
videoTracks.forEach(track => {
console.log('[DesktopShare] 视频轨道:', track.id, '启用:', track.enabled, '状态:', track.readyState);
// 确保轨道已启用
if (!track.enabled) {
console.log('[DesktopShare] 🔓 启用视频轨道:', track.id);
track.enabled = true;
}
});
// 直接使用setState
setState(prev => ({ ...prev, remoteStream: videoStream }));
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = videoStream;
}
}
} else {
console.warn('[DesktopShare] ⚠️ 收到视频轨道但没有关联的流');
// 尝试从轨道创建流
try {
const newStream = new MediaStream([event.track]);
console.log('[DesktopShare] 🔄 从视频轨道创建新流:', newStream.id);
// 确保轨道已启用
if (!event.track.enabled) {
console.log('[DesktopShare] 🔓 启用视频轨道:', event.track.id);
event.track.enabled = true;
}
// 直接使用setState
setState(prev => ({ ...prev, remoteStream: newStream }));
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = newStream;
}
} catch (error) {
console.error('[DesktopShare] ❌ 从轨道创建流失败:', error);
}
}
}, []);
useEffect(() => {
if (!webRTC) return;
const cleanup = webRTC.onTrack(handleRemoteTrack);
// 返回清理函数
return () => {
if (cleanup) {
cleanup();
}
};
}, [webRTC, handleRemoteTrack]); // 依赖 webRTC 和稳定的处理器函数
// 获取桌面共享流
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor',
} as DisplayMediaStreamOptions['video'],
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
} as DisplayMediaStreamOptions['audio'],
});
console.log('[DesktopShare] 获取桌面流成功:', stream.getTracks().length, '个轨道');
return stream;
} catch (error) {
console.error('[DesktopShare] 获取桌面流失败:', error);
throw new Error('无法获取桌面共享权限,请确保允许屏幕共享');
}
}, []);
// 设置视频轨道发送
const setupVideoSending = useCallback(async (stream: MediaStream) => {
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
// 检查P2P连接状态
if (!webRTC.getConnectState().isPeerConnected) {
console.warn('[DesktopShare] ⚠️ P2P连接尚未完全建立等待连接稳定...');
// 等待连接稳定
await new Promise(resolve => setTimeout(resolve, 1000));
// 再次检查
if (!webRTC.getConnectState().isPeerConnected) {
console.error('[DesktopShare] ❌ P2P连接仍未建立无法开始媒体传输');
throw new Error('P2P连接尚未建立');
}
}
// 移除之前的轨道(如果存在)
if (currentSenderRef.current) {
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 添加新的视频轨道到PeerConnection
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
if (videoTrack) {
console.log('[DesktopShare] 📹 添加视频轨道:', videoTrack.id, videoTrack.readyState);
const videoSender = webRTC.addTrack(videoTrack, stream);
if (videoSender) {
currentSenderRef.current = videoSender;
console.log('[DesktopShare] ✅ 视频轨道添加成功');
} else {
console.warn('[DesktopShare] ⚠️ 视频轨道添加返回null');
}
} else {
console.error('[DesktopShare] ❌ 未找到视频轨道');
throw new Error('未找到视频轨道');
}
if (audioTrack) {
try {
console.log('[DesktopShare] 🎵 添加音频轨道:', audioTrack.id, audioTrack.readyState);
const audioSender = webRTC.addTrack(audioTrack, stream);
if (audioSender) {
console.log('[DesktopShare] ✅ 音频轨道添加成功');
} else {
console.warn('[DesktopShare] ⚠️ 音频轨道添加返回null');
}
} catch (error) {
console.warn('[DesktopShare] ⚠️ 音频轨道添加失败,继续视频共享:', error);
}
} else {
console.log('[DesktopShare] 未检测到音频轨道(这通常是正常的)');
}
// 轨道添加完成,现在需要重新协商以包含媒体轨道
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
// 获取PeerConnection实例以便调试
const pc = webRTC.getPeerConnection();
if (pc) {
console.log('[DesktopShare] 🔍 当前连接状态:', {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState,
senders: pc.getSenders().length
});
}
// 等待一小段时间确保轨道完全添加
await new Promise(resolve => setTimeout(resolve, 500));
// 创建新的offer包含媒体轨道
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
const success = await webRTC.createOfferNow();
if (success) {
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
// 等待重新协商完成
console.log('[DesktopShare] ⏳ 等待重新协商完成...');
await new Promise(resolve => setTimeout(resolve, 2000));
// 检查连接状态
if (pc) {
console.log('[DesktopShare] 🔍 重新协商后连接状态:', {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState
});
}
} else {
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
throw new Error('媒体轨道重新协商失败');
}
// 监听流结束事件(用户停止共享)
const handleStreamEnded = () => {
console.log('[DesktopShare] 🛑 用户停止了屏幕共享');
stopSharing();
};
videoTrack?.addEventListener('ended', handleStreamEnded);
audioTrack?.addEventListener('ended', handleStreamEnded);
return () => {
videoTrack?.removeEventListener('ended', handleStreamEnded);
audioTrack?.removeEventListener('ended', handleStreamEnded);
};
}, [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 createRoomAndStartSharing = useCallback(async (): Promise<string> => {
try {
setState(prev => ({ ...prev, error: null, isWaitingForPeer: false }));
// 从后端获取房间代码
const roomCode = await createRoomFromBackend();
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
// 建立WebRTC连接作为发送方
console.log('[DesktopShare] 📡 正在建立WebRTC连接...');
await webRTC.connect(roomCode, 'sender');
console.log('[DesktopShare] ✅ WebSocket连接已建立');
setState(prev => ({
...prev,
connectionCode: roomCode,
}));
// 立即开始桌面共享不等待P2P连接
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
const stream = await getDesktopStream();
// 停止之前的流(如果有)
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = stream;
console.log('[DesktopShare] ✅ 桌面流获取成功流ID:', stream.id, '轨道数:', stream.getTracks().length);
// 确保状态更新能正确触发重新渲染
setState(prev => ({
...prev,
isSharing: true,
isWaitingForPeer: true, // 等待观看者加入
localStream: stream, // 更新状态以触发组件重新渲染
}));
// 再次确认状态已更新(用于调试)
console.log('[DesktopShare] 🎉 桌面共享已开始,状态已更新,等待观看者加入');
return roomCode;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
console.error('[DesktopShare] ❌ 创建房间失败:', error);
setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false, isSharing: false }));
throw error;
}
}, [webRTC, createRoomFromBackend, getDesktopStream]); // 移除updateState依赖
// 创建房间(保留原有方法以兼容性)
const createRoom = useCallback(async (): Promise<string> => {
try {
setState(prev => ({ ...prev, error: null, isWaitingForPeer: false }));
// 从后端获取房间代码
const roomCode = await createRoomFromBackend();
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
// 建立WebRTC连接作为发送方
console.log('[DesktopShare] 📡 正在建立WebRTC连接...');
await webRTC.connect(roomCode, 'sender');
console.log('[DesktopShare] ✅ WebSocket连接已建立');
setState(prev => ({
...prev,
connectionCode: roomCode,
isWaitingForPeer: true, // 标记为等待对方连接
}));
console.log('[DesktopShare] 🎯 房间创建完成等待对方加入建立P2P连接');
return roomCode;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
console.error('[DesktopShare] ❌ 创建房间失败:', error);
setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false }));
throw error;
}
}, [webRTC, createRoomFromBackend]); // 移除updateState依赖
// 开始桌面共享支持有或无P2P连接状态
const startSharing = useCallback(async (): Promise<void> => {
try {
setState(prev => ({ ...prev, error: null }));
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
// 获取桌面流
const stream = await getDesktopStream();
// 停止之前的流(如果有)
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = stream;
console.log('[DesktopShare] ✅ 桌面流获取成功流ID:', stream.id, '轨道数:', stream.getTracks().length);
// 如果P2P连接已建立立即设置视频发送
if (webRTC.getConnectState().isPeerConnected) {
await setupVideoSending(stream);
console.log('[DesktopShare] ✅ 桌面共享开始完成P2P已连接');
setState(prev => ({
...prev,
isSharing: true,
isWaitingForPeer: false,
localStream: stream,
}));
} else {
// P2P连接未建立等待观看者加入
console.log('[DesktopShare] 📱 桌面流已准备等待观看者加入建立P2P连接');
setState(prev => ({
...prev,
isSharing: true,
isWaitingForPeer: true,
localStream: stream,
}));
}
console.log('[DesktopShare] 🎉 桌面共享已开始');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
console.error('[DesktopShare] ❌ 开始共享失败:', error);
setState(prev => ({
...prev,
error: errorMessage,
isSharing: false,
localStream: null,
}));
// 清理资源
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
localStreamRef.current = null;
}
throw error;
}
}, [webRTC, getDesktopStream]); // 移除setupVideoSending和updateState依赖
// 切换桌面共享(重新选择屏幕)
const switchDesktop = useCallback(async (): Promise<void> => {
try {
if (!state.isSharing) {
throw new Error('当前未在共享桌面');
}
setState(prev => ({ ...prev, error: null }));
console.log('[DesktopShare] 🔄 正在切换桌面共享...');
// 获取新的桌面流
const newStream = await getDesktopStream();
// 停止之前的流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = newStream;
console.log('[DesktopShare] ✅ 新桌面流获取成功流ID:', newStream.id, '轨道数:', newStream.getTracks().length);
// 更新状态中的本地流
setState(prev => ({ ...prev, localStream: newStream }));
// 如果有P2P连接设置新的视频发送
if (webRTC.getConnectState().isPeerConnected) {
await setupVideoSending(newStream);
console.log('[DesktopShare] ✅ 桌面切换完成(已推流给观看者)');
} else {
console.log('[DesktopShare] ✅ 桌面切换完成(等待观看者加入)');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
console.error('[DesktopShare] ❌ 切换桌面失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
throw error;
}
}, [webRTC, state.isSharing, getDesktopStream]); // 移除setupVideoSending和updateState依赖
// 停止桌面共享
const stopSharing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 停止桌面共享');
// 停止本地流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => {
track.stop();
console.log('[DesktopShare] 停止轨道:', track.kind);
});
localStreamRef.current = null;
}
// 移除发送器
if (currentSenderRef.current) {
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 断开WebRTC连接
webRTC.disconnect();
setState(prev => ({
...prev,
isSharing: false,
connectionCode: '',
error: null,
isWaitingForPeer: false,
localStream: null,
}));
console.log('[DesktopShare] 桌面共享已停止');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
console.error('[DesktopShare] 停止共享失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖直接使用setState
// 重置桌面共享到初始状态(让用户重新选择桌面)
const resetSharing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 重置桌面共享到初始状态');
// 停止本地流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => {
track.stop();
console.log('[DesktopShare] 停止轨道:', track.kind);
});
localStreamRef.current = null;
}
// 移除发送器
if (currentSenderRef.current) {
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 保留WebSocket连接和房间代码但重置共享状态
setState(prev => ({
...prev,
isSharing: false,
error: null,
isWaitingForPeer: false,
localStream: null,
}));
console.log('[DesktopShare] 桌面共享已重置到初始状态');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '重置桌面共享失败';
console.error('[DesktopShare] 重置共享失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖
// 处理P2P连接断开但保持桌面共享状态用于接收方离开房间的情况
const handlePeerDisconnect = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] P2P连接断开但保持桌面共享状态以便新用户加入');
// 移除当前的发送器清理P2P连接相关资源
if (currentSenderRef.current) {
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 保持本地流和共享状态,只设置为等待新的对等方
setState(prev => ({
...prev,
isWaitingForPeer: true,
error: null,
}));
console.log('[DesktopShare] 已清理P2P连接资源等待新用户加入');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '处理P2P断开失败';
console.error('[DesktopShare] 处理P2P断开失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖
// 重新建立P2P连接并推流用于新用户加入房间的情况
const resumeSharing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 新用户加入重新建立P2P连接并推流');
// 检查是否还在共享状态且有本地流
if (!state.isSharing || !localStreamRef.current) {
console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流');
return;
}
// 检查P2P连接状态
if (!webRTC.getConnectState().isPeerConnected) {
console.log('[DesktopShare] P2P连接未建立等待连接完成');
return;
}
// 重新设置视频发送
await setupVideoSending(localStreamRef.current);
updateState({
isWaitingForPeer: false,
error: null,
});
console.log('[DesktopShare] ✅ P2P连接已恢复桌面共享流已重新建立');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败';
console.error('[DesktopShare] 恢复桌面共享失败:', error);
updateState({ error: errorMessage });
}
}, [webRTC, state.isSharing]); // 移除setupVideoSending和updateState依赖
// 加入桌面共享观看
const joinSharing = useCallback(async (code: string): Promise<void> => {
try {
setState(prev => ({ ...prev, error: null }));
console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code);
// 连接WebRTC
console.log('[DesktopShare] 🔗 正在连接WebRTC作为接收方...');
await webRTC.connect(code, 'receiver');
console.log('[DesktopShare] ✅ WebRTC连接建立完成');
// 等待连接完全建立
console.log('[DesktopShare] ⏳ 等待连接稳定...');
await new Promise(resolve => setTimeout(resolve, 1000));
// 检查连接状态
const pc = webRTC.getPeerConnection();
if (pc) {
console.log('[DesktopShare] 🔍 连接状态:', {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState
});
}
setState(prev => ({ ...prev, isViewing: true }));
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
// 设置一个超时检查,如果长时间没有收到流,输出警告
setTimeout(() => {
if (!state.remoteStream) {
console.warn('[DesktopShare] ⚠️ 长时间未收到远程流,可能存在连接问题');
// 可以在这里添加一些恢复逻辑,比如尝试重新连接
}
}, 10000); // 10秒后检查
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
console.error('[DesktopShare] ❌ 加入观看失败:', error);
setState(prev => ({ ...prev, error: errorMessage, isViewing: false }));
throw error;
}
}, [webRTC, state.remoteStream]); // 移除updateState依赖
// 停止观看桌面共享
const stopViewing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 停止观看桌面共享');
// 断开WebRTC连接
webRTC.disconnect();
setState(prev => ({
...prev,
isViewing: false,
remoteStream: null,
error: null,
}));
console.log('[DesktopShare] 已停止观看桌面共享');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止观看失败';
console.error('[DesktopShare] 停止观看失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖
// 设置远程视频元素引用
const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => {
remoteVideoRef.current = videoElement;
if (videoElement && state.remoteStream) {
videoElement.srcObject = state.remoteStream;
}
}, [state.remoteStream]);
// 监听P2P连接状态变化自动处理重新连接
const prevPeerConnectedForResumeRef = useRef<boolean>(false);
useEffect(() => {
const isPeerConnected = webRTC.getConnectState().isPeerConnected;
const wasPreviouslyDisconnected = !prevPeerConnectedForResumeRef.current;
// 更新ref
prevPeerConnectedForResumeRef.current = isPeerConnected;
// 当P2P连接从断开变为连接且正在等待对方时自动恢复推流
if (isPeerConnected &&
wasPreviouslyDisconnected &&
state.isWaitingForPeer &&
state.isSharing) {
console.log('[DesktopShare] 🔄 P2P连接已建立自动恢复桌面共享推流');
// 调用resumeSharing但不依赖它
const handleResume = async () => {
try {
console.log('[DesktopShare] 新用户加入重新建立P2P连接并推流');
// 检查是否还在共享状态且有本地流
if (!state.isSharing || !localStreamRef.current) {
console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流');
return;
}
// 重新设置视频发送
await setupVideoSending(localStreamRef.current);
setState(prev => ({
...prev,
isWaitingForPeer: false,
error: null,
}));
console.log('[DesktopShare] ✅ P2P连接已恢复桌面共享流已重新建立');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败';
console.error('[DesktopShare] 恢复桌面共享失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
};
handleResume();
}
}, [webRTC.getConnectState().isPeerConnected, state.isWaitingForPeer, state.isSharing]); // 移除resumeSharing依赖
// 清理资源
useEffect(() => {
return () => {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
return {
// 状态
isSharing: state.isSharing,
isViewing: state.isViewing,
connectionCode: state.connectionCode,
remoteStream: state.remoteStream,
localStream: state.localStream, // 使用状态中的流
error: state.error,
isWaitingForPeer: state.isWaitingForPeer,
isConnected: webRTC.getConnectState().isConnected,
isConnecting: webRTC.getConnectState().isConnecting,
isWebSocketConnected: webRTC.getConnectState().isWebSocketConnected,
isPeerConnected: webRTC.getConnectState().isPeerConnected,
// 新增表示是否可以开始共享WebSocket已连接且有房间代码
canStartSharing: webRTC.getConnectState().isWebSocketConnected && !!state.connectionCode,
// 方法
createRoom, // 创建房间
createRoomAndStartSharing, // 创建房间并立即开始桌面共享
startSharing, // 选择桌面并建立P2P连接
switchDesktop, // 新增:切换桌面
stopSharing,
resetSharing, // 重置到初始状态,保留房间连接
handlePeerDisconnect, // 处理P2P断开但保持桌面共享
resumeSharing, // 重新建立P2P连接并推流
joinSharing,
stopViewing,
setRemoteVideoRef,
// WebRTC连接状态
webRTCError: webRTC.getConnectState().error,
// 暴露WebRTC连接对象
webRTCConnection: webRTC,
};
}

View File

@@ -0,0 +1,317 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { IWebConnection } from '../connection/types';
import { useAudioVisualizer } from './useAudioVisualizer';
interface VoiceChatState {
isVoiceEnabled: boolean;
isMuted: boolean;
isRemoteVoiceActive: boolean;
localAudioStream: MediaStream | null;
remoteAudioStream: MediaStream | null;
error: string | null;
}
export function useVoiceChatBusiness(connection: IWebConnection) {
const [state, setState] = useState<VoiceChatState>({
isVoiceEnabled: false,
isMuted: false,
isRemoteVoiceActive: false,
localAudioStream: null,
remoteAudioStream: null,
error: null,
});
const localAudioStreamRef = useRef<MediaStream | null>(null);
const audioSenderRef = useRef<RTCRtpSender | null>(null);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
// 使用音频可视化
const localAudioVisualizer = useAudioVisualizer(state.localAudioStream);
const remoteAudioVisualizer = useAudioVisualizer(state.remoteAudioStream);
const updateState = useCallback((updates: Partial<VoiceChatState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 监听远程音频轨道
const handleRemoteAudioTrack = useCallback((event: RTCTrackEvent, currentTrackRef: { current: MediaStreamTrack | null }) => {
if (event.track.kind !== 'audio') return;
// 移除旧轨道的监听器
if (currentTrackRef.current) {
currentTrackRef.current.onended = null;
currentTrackRef.current.onmute = null;
currentTrackRef.current.onunmute = null;
}
currentTrackRef.current = event.track;
if (event.streams.length > 0) {
const remoteStream = event.streams[0];
event.track.enabled = true;
// 更新状态
setState(prev => ({
...prev,
remoteAudioStream: remoteStream,
isRemoteVoiceActive: true
}));
// 监听轨道结束事件
event.track.onended = () => {
setState(prev => ({ ...prev, isRemoteVoiceActive: false }));
};
// 监听轨道静音事件
event.track.onmute = () => {
// 远程音频轨道被静音
};
event.track.onunmute = () => {
// 远程音频轨道取消静音
};
// 在设置状态后,使用 setTimeout 确保 audio 元素更新
setTimeout(() => {
if (remoteAudioRef.current && remoteStream.active) {
remoteAudioRef.current.srcObject = remoteStream;
remoteAudioRef.current.play().catch(err => {
// 忽略 AbortError这是正常的竞态条件
if (err.name !== 'AbortError') {
console.error('[VoiceChat] 播放远程音频失败:', err);
}
});
}
}, 0);
}
}, []); // 空依赖数组,函数引用始终不变
useEffect(() => {
if (!connection) return;
const currentTrackRef = { current: null as MediaStreamTrack | null };
const trackHandler = (event: RTCTrackEvent) => {
if (event.track.kind === 'audio') {
handleRemoteAudioTrack(event, currentTrackRef);
}
};
const cleanup = connection.onTrack(trackHandler);
return () => {
if (currentTrackRef.current) {
currentTrackRef.current.onended = null;
currentTrackRef.current.onmute = null;
currentTrackRef.current.onunmute = null;
}
if (cleanup) {
cleanup();
}
};
}, [connection, handleRemoteAudioTrack]); // 只在 connection 或处理器变化时重新注册
// 获取本地音频流
const getLocalAudioStream = useCallback(async (): Promise<MediaStream> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
return stream;
} catch (error) {
console.error('[VoiceChat] 获取本地音频流失败:', error);
// 根据错误类型提供更详细的错误消息
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError') {
throw new Error('麦克风权限被拒绝,请在浏览器设置中允许使用麦克风');
} else if (error.name === 'NotFoundError') {
throw new Error('未检测到麦克风设备,请连接麦克风后重试');
} else if (error.name === 'NotReadableError') {
throw new Error('麦克风被其他应用占用,请关闭其他使用麦克风的程序');
} else if (error.name === 'OverconstrainedError') {
throw new Error('麦克风不支持所需的音频设置');
} else if (error.name === 'AbortError') {
throw new Error('麦克风访问被中断');
} else if (error.name === 'SecurityError') {
throw new Error('安全限制无法访问麦克风请使用HTTPS');
}
}
throw new Error('无法获取麦克风权限,请确保允许使用麦克风');
}
}, []);
// 启用语音通话
const enableVoice = useCallback(async () => {
if (state.isVoiceEnabled || !connection) {
return;
}
try {
updateState({ error: null });
// 检查P2P连接状态
const connectState = connection.getConnectState();
if (!connectState.isPeerConnected) {
throw new Error('P2P连接尚未建立无法启用语音');
}
// 获取本地音频流
const stream = await getLocalAudioStream();
localAudioStreamRef.current = stream;
console.log('[VoiceChat] ✅ 本地音频流获取成功:', {
streamId: stream.id,
audioTracks: stream.getAudioTracks().length,
trackEnabled: stream.getAudioTracks()[0]?.enabled,
trackReadyState: stream.getAudioTracks()[0]?.readyState
});
// 添加音频轨道到P2P连接
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack) {
const role = connection.currentRoom?.role;
console.log('[VoiceChat] 📤 添加音频轨道到P2P连接, 当前角色:', role);
const sender = connection.addTrack(audioTrack, stream);
audioSenderRef.current = sender;
if (sender) {
console.log('[VoiceChat] 📊 Sender 信息:', {
track: sender.track?.id,
trackEnabled: sender.track?.enabled,
trackReadyState: sender.track?.readyState
});
}
// 重要:添加音频轨道后,本地必须主动创建 offer
// 因为对方不知道我们添加了新轨道,必须由我们通知对方
console.log('[VoiceChat] 📡 [' + role + '] 创建 offer 进行重新协商(添加音频轨道)');
const negotiated = await connection.createOfferNow();
console.log('[VoiceChat] 📡 [' + role + '] 重新协商结果:', negotiated);
}
updateState({
isVoiceEnabled: true,
localAudioStream: stream,
isMuted: false,
});
} catch (error) {
console.error('[VoiceChat] 启用语音失败:', error);
const errorMsg = error instanceof Error ? error.message : '启用语音失败';
updateState({ error: errorMsg });
throw error;
}
}, [connection, getLocalAudioStream, state.isVoiceEnabled, updateState]);
// 禁用语音通话
const disableVoice = useCallback(async () => {
if (!state.isVoiceEnabled) return;
const role = connection.currentRoom?.role;
// 移除音频轨道
if (audioSenderRef.current) {
connection.removeTrack(audioSenderRef.current);
audioSenderRef.current = null;
// 重要:移除音频轨道后,本地必须主动创建 offer
// 因为对方不知道我们移除了轨道,必须由我们通知对方
console.log('[VoiceChat] 📡 [' + role + '] 移除音频轨道后重新协商');
try {
await connection.createOfferNow();
} catch (error) {
console.error('[VoiceChat] 重新协商失败:', error);
}
}
// 停止本地音频流
if (localAudioStreamRef.current) {
localAudioStreamRef.current.getTracks().forEach(track => {
track.stop();
});
localAudioStreamRef.current = null;
}
updateState({
isVoiceEnabled: false,
localAudioStream: null,
isMuted: false,
});
}, [connection, state.isVoiceEnabled, updateState]);
// 切换静音状态
const toggleMute = useCallback(() => {
if (!localAudioStreamRef.current) {
return;
}
const audioTracks = localAudioStreamRef.current.getAudioTracks();
if (audioTracks.length === 0) {
return;
}
const newMutedState = !state.isMuted;
audioTracks.forEach(track => {
track.enabled = !newMutedState;
});
updateState({ isMuted: newMutedState });
}, [state.isMuted, updateState]);
// 设置远程音频元素引用
const setRemoteAudioRef = useCallback((element: HTMLAudioElement | null) => {
remoteAudioRef.current = element;
if (element && state.remoteAudioStream && state.remoteAudioStream.active) {
element.srcObject = state.remoteAudioStream;
element.play().catch(err => {
// 忽略 AbortError这是正常的竞态条件
if (err.name !== 'AbortError') {
console.error('[VoiceChat] 播放远程音频失败:', err);
}
});
}
}, [state.remoteAudioStream]);
// 清理
useEffect(() => {
return () => {
if (localAudioStreamRef.current) {
localAudioStreamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
return {
// 状态
isVoiceEnabled: state.isVoiceEnabled,
isMuted: state.isMuted,
isRemoteVoiceActive: state.isRemoteVoiceActive,
error: state.error,
// 音频可视化数据
localVolume: localAudioVisualizer.volume,
localIsSpeaking: localAudioVisualizer.isSpeaking,
remoteVolume: remoteAudioVisualizer.volume,
remoteIsSpeaking: remoteAudioVisualizer.isSpeaking,
// 方法
enableVoice,
disableVoice,
toggleMute,
setRemoteAudioRef,
// 调试信息
_debug: {
hasRemoteStream: !!state.remoteAudioStream,
remoteStreamId: state.remoteAudioStream?.id,
remoteTrackCount: state.remoteAudioStream?.getTracks().length || 0,
}
};
}

View File

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

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef } 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: () => any;
}
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) {
console.log('跳过文件列表同步: 非发送模式或无房间码', { mode, pickupCode: !!pickupCode });
return;
}
// 获取当前通道状态
const channelState = getChannelState();
console.log(`文件列表同步检查 (${reason}):`, {
mode,
pickupCode: !!pickupCode,
isConnected,
isPeerConnected,
channelState: channelState.state || channelState,
fileInfosCount: fileInfos.length
});
// 清除之前的延时发送
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// 延时发送,避免频繁发送
syncTimeoutRef.current = setTimeout(() => {
// 检查数据通道状态 - 使用更宽松的条件
const currentState = getChannelState();
const isChannelOpen = typeof currentState === 'object' ?
currentState.state === 'open' || currentState.isDataChannelConnected :
currentState === 'open';
// 检查P2P连接状态
const isP2PConnected = isPeerConnected || (typeof currentState === 'object' && currentState.isPeerConnected);
console.log(`文件列表同步执行检查 (${reason}):`, {
isChannelOpen,
isP2PConnected,
fileInfosCount: fileInfos.length
});
// 如果数据通道已打开或P2P已连接就可以发送文件列表
if (isChannelOpen || isP2PConnected) {
console.log(`发送文件列表到接收方 (${reason}):`, fileInfos.map(f => f.name));
sendFileList(fileInfos);
} else {
console.log(`跳过文件列表发送: 数据通道未打开或P2P未连接 (${reason})`);
}
}, 150);
}, [mode, pickupCode, isConnected, isPeerConnected, getChannelState, sendFileList]);
// 清理防抖定时器
useEffect(() => {
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, []);
return {
syncFileListToReceiver
};
};

View File

@@ -0,0 +1,199 @@
import { useCallback, useEffect, useState } from 'react';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: 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, transferSpeed?: number) => {
setFileList(prev => prev.map(item =>
item.id === fileId
? {
...item,
status,
progress: progress ?? item.progress,
transferSpeed: transferSpeed ?? item.transferSpeed,
startTime: status === 'downloading' && !item.startTime ? Date.now() : item.startTime
}
: item
));
}, []);
// 更新文件进度
const updateFileProgress = useCallback((fileId: string, fileName: string, progress: number, transferSpeed?: number) => {
const newStatus = progress >= 100 ? 'completed' as const : 'downloading' as const;
setFileList(prev => prev.map(item => {
if (item.id === fileId || item.name === fileName) {
return {
...item,
progress,
status: newStatus,
transferSpeed: transferSpeed ?? item.transferSpeed,
startTime: newStatus === 'downloading' && !item.startTime ? Date.now() : item.startTime
};
}
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,
selectedFileNames: selectedFiles.map(f => f.name)
});
// 使用函数式更新获取当前fileList避免依赖fileList
setFileList(currentFileList => {
// 根据selectedFiles创建新的文件信息列表
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
// 尝试在当前fileList中找到现有的文件信息保持已有的状态
const existingFileInfo = currentFileList.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 !== currentFileList.length ||
newFileInfos.some(newFile =>
!currentFileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
);
if (fileListChanged) {
console.log('文件列表发生变化,更新:', {
before: currentFileList.map(f => f.name),
after: newFileInfos.map(f => f.name)
});
return newFileInfos;
}
// 如果没有变化返回当前的fileList
return currentFileList;
});
}, [selectedFiles, mode, pickupCode, generateFileId]); // 移除fileList依赖避免无限循环
// 清除发送方数据(当接收方离开房间时)
const clearSenderData = useCallback(() => {
console.log('[FileStateManager] 接收方离开房间,清除发送方数据');
// 只清除文件列表和传输状态,不清除选中的文件
// 这样用户可以重新连接后继续发送
setFileList(prev => prev.map(file => ({
...file,
status: 'ready' as const,
progress: 0,
transferSpeed: undefined,
startTime: undefined
})));
}, []);
return {
selectedFiles,
setSelectedFiles,
fileList,
setFileList,
downloadedFiles,
setDownloadedFiles,
handleFileSelect,
clearFiles,
resetFiles,
updateFileStatus,
updateFileProgress,
clearSenderData
};
};

View File

@@ -0,0 +1,755 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { type IWebConnection } from '../connection/types';
// 文件传输状态
interface FileTransferState {
isConnecting: boolean;
isConnected: boolean;
isWebSocketConnected: boolean;
connectionError: string | null;
isTransferring: boolean;
progress: number;
error: string | null;
receivedFiles: Array<{ id: string; file: File }>;
}
// 单个文件的接收进度
interface FileReceiveProgress {
fileId: string;
fileName: string;
totalChunks: number;
progress: number;
}
// 文件信息
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
// 文件元数据
interface FileMetadata {
id: string;
name: string;
size: number;
type: string;
}
// 文件块信息
interface FileChunk {
fileId: string;
chunkIndex: number;
totalChunks: number;
checksum?: string; // 数据校验和
}
// 块确认信息
interface ChunkAck {
fileId: string;
chunkIndex: number;
success: boolean;
checksum?: string;
}
// 传输状态
interface TransferStatus {
fileId: string;
fileName: string;
totalChunks: number;
sentChunks: Set<number>;
acknowledgedChunks: Set<number>;
failedChunks: Set<number>;
lastChunkTime: number;
retryCount: Map<number, number>;
averageSpeed: number; // KB/s
}
// 回调类型
type FileReceivedCallback = (fileData: { id: string; file: File }) => void;
type FileRequestedCallback = (fileId: string, fileName: string) => void;
type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; progress: number }) => void;
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
const CHANNEL_NAME = 'file-transfer';
const WEBRTC_CHUNK_SIZE = 256 * 1024; // 256KB for WebRTC
const WEBSOCKET_CHUNK_SIZE = 3 * 1024 * 1024; // 3MB for WebSocket
const MAX_RETRIES = 5; // 最大重试次数
const RETRY_DELAY = 1000; // 重试延迟(毫秒)
const ACK_TIMEOUT = 5000; // 确认超时(毫秒)
/**
* 根据连接类型获取块大小
*/
function getChunkSize(connectType: string): number {
return connectType === 'websocket' ? WEBSOCKET_CHUNK_SIZE : WEBRTC_CHUNK_SIZE;
}
/**
* 计算数据的CRC32校验和
*/
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');
}
/**
* 文件传输业务层
* 必须传入共享的 WebRTC 连接
*/
export function useFileTransferBusiness(connection: IWebConnection) {
const [state, setState] = useState<FileTransferState>({
isConnecting: false,
isConnected: false,
isWebSocketConnected: false,
connectionError: null,
isTransferring: false,
progress: 0,
error: null,
receivedFiles: [],
});
// 接收文件缓存
const receivingFiles = useRef<Map<string, {
metadata: FileMetadata;
chunks: ArrayBuffer[];
receivedChunks: number;
}>>(new Map());
// 当前期望的文件块
const expectedChunk = useRef<FileChunk | null>(null);
// 回调存储
const fileReceivedCallbacks = useRef<Set<FileReceivedCallback>>(new Set());
const fileRequestedCallbacks = useRef<Set<FileRequestedCallback>>(new Set());
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
// 传输状态管理
const transferStatus = useRef<Map<string, TransferStatus>>(new Map());
const pendingChunks = useRef<Map<string, NodeJS.Timeout>>(new Map());
const chunkAckCallbacks = useRef<Map<string, Set<(ack: ChunkAck) => void>>>(new Map());
// 接收文件进度跟踪
const receiveProgress = useRef<Map<string, FileReceiveProgress>>(new Map());
const activeReceiveFile = useRef<string | null>(null);
const updateState = useCallback((updates: Partial<FileTransferState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 消息处理器
const handleMessage = useCallback((message: any) => {
if (!message.type.startsWith('file-')) return;
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: [],
receivedChunks: 0,
});
// 初始化接收进度跟踪
const totalChunks = Math.ceil(metadata.size / getChunkSize(connection.connectType));
receiveProgress.current.set(metadata.id, {
fileId: metadata.id,
fileName: metadata.name,
totalChunks,
progress: 0
});
// 设置当前活跃的接收文件
activeReceiveFile.current = metadata.id;
updateState({ isTransferring: true, progress: 0 });
break;
case 'file-chunk-info':
expectedChunk.current = message.payload;
console.log('准备接收文件块:', message.payload);
break;
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
});
console.log('文件接收完成:', file.name);
setState(prev => ({
...prev,
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
isTransferring: false,
progress: 100
}));
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
receivingFiles.current.delete(fileId);
receiveProgress.current.delete(fileId);
// 清除活跃文件
if (activeReceiveFile.current === fileId) {
activeReceiveFile.current = null;
}
}
break;
case 'file-list':
console.log('收到文件列表:', message.payload);
fileListCallbacks.current.forEach(cb => cb(message.payload));
break;
case 'file-request':
const { fileId: requestedFileId, fileName } = message.payload;
console.log('收到文件请求:', fileName, requestedFileId);
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
break;
case 'file-chunk-ack':
const ack: ChunkAck = message.payload;
console.log('收到块确认:', ack);
// 清除超时定时器
const chunkKey = `${ack.fileId}-${ack.chunkIndex}`;
const timeout = pendingChunks.current.get(chunkKey);
if (timeout) {
clearTimeout(timeout);
pendingChunks.current.delete(chunkKey);
}
// 调用确认回调
const callbacks = chunkAckCallbacks.current.get(chunkKey);
if (callbacks) {
callbacks.forEach(cb => cb(ack));
chunkAckCallbacks.current.delete(chunkKey);
}
// 更新传输状态
const status = transferStatus.current.get(ack.fileId);
if (status) {
if (ack.success) {
status.acknowledgedChunks.add(ack.chunkIndex);
status.failedChunks.delete(ack.chunkIndex);
} else {
status.failedChunks.add(ack.chunkIndex);
}
}
break;
}
}, [updateState]);
// 处理文件块数据
const handleData = useCallback((data: ArrayBuffer) => {
if (!expectedChunk.current) {
console.warn('收到数据但没有对应的块信息');
return;
}
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',
payload: {
fileId,
chunkIndex,
success: false,
checksum: actualChecksum
}
}, CHANNEL_NAME);
expectedChunk.current = null;
return;
}
// 检查是否已经接收过这个块,避免重复计数
const alreadyReceived = fileInfo.chunks[chunkIndex] !== undefined;
// 数据有效,保存到缓存
fileInfo.chunks[chunkIndex] = data;
// 只有在首次接收时才增加计数
if (!alreadyReceived) {
fileInfo.receivedChunks++;
}
// 更新接收进度跟踪 - 使用 fileInfo 的计数,避免双重计数
const progressInfo = receiveProgress.current.get(fileId);
if (progressInfo) {
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,
fileName: progressInfo.fileName,
progress: progressInfo.progress
}));
console.log(`文件 ${progressInfo.fileName} 接收进度: ${progressInfo.progress.toFixed(1)}%`);
}
// 发送成功确认
connection.sendMessage({
type: 'file-chunk-ack',
payload: {
fileId,
chunkIndex,
success: true,
checksum: actualChecksum
}
}, CHANNEL_NAME);
expectedChunk.current = null;
}
}, [updateState, connection]);
const connectionRef = useRef(connection);
useEffect(() => {
connectionRef.current = connection;
}, [connection]);
useEffect(() => {
// 使用共享连接的注册方式
const unregisterMessage = connectionRef.current.registerMessageHandler(CHANNEL_NAME, handleMessage);
const unregisterData = connectionRef.current.registerDataHandler(CHANNEL_NAME, handleData);
return () => {
unregisterMessage();
unregisterData();
};
}, []); // 只依赖 connection 对象,不依赖处理函数
// 监听连接状态变化 (直接使用 connection 的状态)
useEffect(() => {
// 同步连接状态
updateState({
isConnecting: connection.getConnectState().isConnecting,
isConnected: connection.getConnectState().isConnected,
isWebSocketConnected: connection.getConnectState().isWebSocketConnected,
connectionError: connection.getConnectState().error
});
}, [connection.getConnectState, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return connection.connect(roomCode, role);
}, [connection]);
// 安全发送单个文件块
const sendChunkWithAck = useCallback(async (
fileId: string,
chunkIndex: number,
chunkData: ArrayBuffer,
checksum: string,
retryCount = 0
): Promise<boolean> => {
return new Promise((resolve) => {
// 主要检查数据通道状态,因为数据通道是文件传输的实际通道
const channelState = connection.getConnectState();
if (channelState.state === 'closed') {
console.warn(`数据通道已关闭,停止发送文件块 ${chunkIndex}`);
resolve(false);
return;
}
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
if (!channelState.isConnected && channelState.state === 'connecting') {
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
}
const chunkKey = `${fileId}-${chunkIndex}`;
// 设置确认回调
const ackCallback = (ack: ChunkAck) => {
if (ack.success) {
resolve(true);
} else {
console.warn(`文件块 ${chunkIndex} 确认失败,准备重试`);
resolve(false);
}
};
// 注册确认回调
if (!chunkAckCallbacks.current.has(chunkKey)) {
chunkAckCallbacks.current.set(chunkKey, new Set());
}
chunkAckCallbacks.current.get(chunkKey)!.add(ackCallback);
// 设置超时定时器
const timeout = setTimeout(() => {
console.warn(`文件块 ${chunkIndex} 确认超时`);
chunkAckCallbacks.current.get(chunkKey)?.delete(ackCallback);
resolve(false);
}, ACK_TIMEOUT);
pendingChunks.current.set(chunkKey, timeout);
// 发送块信息
connection.sendMessage({
type: 'file-chunk-info',
payload: {
fileId,
chunkIndex,
totalChunks: 0, // 这里不需要,因为已经在元数据中发送
checksum
}
}, CHANNEL_NAME);
// 发送块数据
connection.sendData(chunkData);
});
}, [connection]);
// 安全发送文件
const sendFileSecure = useCallback(async (file: File, fileId?: string) => {
if (connection.getConnectState().state !== 'open') {
updateState({ error: '连接未就绪' });
return;
}
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const chunkSize = getChunkSize(connection.connectType);
const totalChunks = Math.ceil(file.size / chunkSize);
console.log('开始安全发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks, '块大小:', chunkSize);
updateState({ isTransferring: true, progress: 0, error: null });
// 初始化传输状态
const status: TransferStatus = {
fileId: actualFileId,
fileName: file.name,
totalChunks,
sentChunks: new Set(),
acknowledgedChunks: new Set(),
failedChunks: new Set(),
lastChunkTime: Date.now(),
retryCount: new Map(),
averageSpeed: 0
};
transferStatus.current.set(actualFileId, status);
try {
// 1. 发送文件元数据
connection.sendMessage({
type: 'file-metadata',
payload: {
id: actualFileId,
name: file.name,
size: file.size,
type: file.type
}
}, CHANNEL_NAME);
// 2. 分块发送文件
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
let success = false;
let retryCount = 0;
while (!success && retryCount <= MAX_RETRIES) {
// 检查数据通道状态,这是文件传输的实际通道
const channelState = connection.getConnectState();
if (channelState.state === 'closed') {
console.warn(`数据通道已关闭,停止文件传输`);
throw new Error('数据通道已关闭');
}
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
if (!connection.getConnectState().isConnected && channelState.state === 'connecting') {
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
}
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const arrayBuffer = await chunk.arrayBuffer();
const checksum = calculateChecksum(arrayBuffer);
console.log(`发送文件块 ${chunkIndex}/${totalChunks}, 重试次数: ${retryCount}`);
// 发送块并等待确认
success = await sendChunkWithAck(actualFileId, chunkIndex, arrayBuffer, checksum, retryCount);
if (success) {
status.sentChunks.add(chunkIndex);
status.acknowledgedChunks.add(chunkIndex);
status.failedChunks.delete(chunkIndex);
// 计算传输速度
const now = Date.now();
const timeDiff = (now - status.lastChunkTime) / 1000; // 秒
if (timeDiff > 0) {
const speed = (arrayBuffer.byteLength / 1024) / timeDiff; // KB/s
status.averageSpeed = status.averageSpeed * 0.7 + speed * 0.3; // 平滑平均
}
status.lastChunkTime = now;
} 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}`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// 更新进度 - 基于已发送的块数,这样与接收方的进度更同步
const progress = ((chunkIndex + 1) / totalChunks) * 100;
updateState({ progress });
fileProgressCallbacks.current.forEach(cb => cb({
fileId: actualFileId,
fileName: file.name,
progress
}));
// 自适应流控:根据传输速度调整发送间隔
if (status.averageSpeed > 0) {
const currentChunkSize = Math.min(chunkSize, file.size - chunkIndex * chunkSize);
const expectedTime = (currentChunkSize / 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)));
}
}
}
// 3. 验证所有块都已确认
if (status.acknowledgedChunks.size !== totalChunks) {
throw new Error(`文件传输不完整:${status.acknowledgedChunks.size}/${totalChunks} 块已确认`);
}
// 4. 发送完成信号
connection.sendMessage({
type: 'file-complete',
payload: { fileId: actualFileId }
}, CHANNEL_NAME);
updateState({ isTransferring: false, progress: 100 });
console.log('文件安全发送完成:', file.name, `平均速度: ${status.averageSpeed.toFixed(2)} KB/s`);
transferStatus.current.delete(actualFileId);
} catch (error) {
console.error('安全发送文件失败:', error);
updateState({
error: error instanceof Error ? error.message : '发送失败',
isTransferring: false
});
transferStatus.current.delete(actualFileId);
}
}, [connection, updateState, sendChunkWithAck]);
// 保持原有的 sendFile 方法用于向后兼容
const sendFile = useCallback(async (file: File, fileId?: string) => {
// 默认使用新的安全发送方法
return sendFileSecure(file, fileId);
}, [sendFileSecure]);
// 发送文件列表
const sendFileList = useCallback((fileList: FileInfo[]) => {
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
const channelState = connection.getConnectState();
const peerConnected = channelState.isPeerConnected;
const dataChannelConnected = channelState.isDataChannelConnected;
const channelReadyState = channelState.state;
console.log('发送文件列表检查:', {
channelState,
peerConnected,
dataChannelConnected,
channelReadyState,
fileListLength: fileList.length
});
// 使用更宽松的条件检查连接状态
const isReadyToSend = channelReadyState === 'open' ||
dataChannelConnected ||
peerConnected ||
channelState.isConnected;
if (isReadyToSend) {
console.log('发送文件列表:', fileList.map(f => f.name));
const sendResult = connection.sendMessage({
type: 'file-list',
payload: fileList
}, CHANNEL_NAME);
if (!sendResult) {
console.warn('文件列表发送失败,可能是数据通道未准备好');
// 不立即重试,让上层逻辑处理重试
}
} else {
console.log('连接未就绪,等待连接后再发送文件列表:', {
channelReadyState,
dataChannelConnected,
peerConnected,
isConnected: channelState.isConnected
});
}
}, [connection]);
// 请求文件
const requestFile = useCallback((fileId: string, fileName: string) => {
const channelState = connection.getConnectState();
const isChannelOpen = channelState.state === 'open';
const isDataChannelConnected = channelState.isDataChannelConnected;
const isPeerConnected = channelState.isPeerConnected;
const isConnected = channelState.isConnected;
console.log('请求文件前检查连接状态:', {
fileName,
fileId,
isChannelOpen,
isDataChannelConnected,
isPeerConnected,
isConnected
});
// 使用更宽松的条件检查连接状态
const isReadyToRequest = isChannelOpen || isDataChannelConnected || isPeerConnected || isConnected;
if (!isReadyToRequest) {
console.error('数据通道未准备就绪,无法请求文件:', {
isChannelOpen,
isDataChannelConnected,
isPeerConnected,
isConnected
});
return;
}
console.log('发送文件请求:', fileName, fileId);
const sendResult = connection.sendMessage({
type: 'file-request',
payload: { fileId, fileName }
}, CHANNEL_NAME);
if (!sendResult) {
console.error('文件请求发送失败,可能是数据通道问题');
// 不立即重试,让上层逻辑处理重试
}
}, [connection]);
// 注册回调函数
const onFileReceived = useCallback((callback: FileReceivedCallback) => {
fileReceivedCallbacks.current.add(callback);
return () => { fileReceivedCallbacks.current.delete(callback); };
}, []);
const onFileRequested = useCallback((callback: FileRequestedCallback) => {
fileRequestedCallbacks.current.add(callback);
return () => { fileRequestedCallbacks.current.delete(callback); };
}, []);
const onFileProgress = useCallback((callback: FileProgressCallback) => {
fileProgressCallbacks.current.add(callback);
return () => { fileProgressCallbacks.current.delete(callback); };
}, []);
const onFileListReceived = useCallback((callback: FileListReceivedCallback) => {
fileListCallbacks.current.add(callback);
return () => { fileListCallbacks.current.delete(callback); };
}, []);
// 清除发送方数据
const clearSenderData = useCallback(() => {
console.log('[FileTransferBusiness] 清除发送方数据');
// 清除传输状态
transferStatus.current.clear();
// 清除待处理的块
pendingChunks.current.forEach(timeout => clearTimeout(timeout));
pendingChunks.current.clear();
// 清除块确认回调
chunkAckCallbacks.current.clear();
// 重置状态
updateState({
isTransferring: false,
progress: 0,
error: null
});
}, [updateState]);
// 设置断开连接回调
useEffect(() => {
connection.setOnDisconnectCallback(clearSenderData);
return () => {
// 清理回调
connection.setOnDisconnectCallback(() => { });
};
}, [connection, clearSenderData]);
return {
// 文件传输状态(包括连接状态)
...state,
// 操作方法
connect,
disconnect: connection.disconnect,
sendFile,
sendFileList,
requestFile,
clearSenderData,
// 回调注册
onFileReceived,
onFileRequested,
onFileProgress,
onFileListReceived,
};
}

View File

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

View File

@@ -0,0 +1,3 @@
export * from './useIceServersConfig';
export * from './useWebRTCConfigSync';
export * from './useWebRTCConfigSync';

View File

@@ -0,0 +1,251 @@
import { useState, useEffect, useCallback } from 'react';
export interface IceServerConfig {
id: string;
urls: string;
username?: string;
credential?: string;
type: 'stun' | 'turn';
enabled: boolean;
isDefault?: boolean; // 标记是否为默认服务器
}
const DEFAULT_ICE_SERVERS: IceServerConfig[] = [
{
id: 'easyvoip-stun',
urls: 'stun:stun.easyvoip.com:3478',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'miwifi-stun',
urls: 'stun:stun.miwifi.com:3478',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'google-stun-1',
urls: 'stun:stun.l.google.com:19302',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'google-stun-2',
urls: 'stun:stun1.l.google.com:19302',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'twilio-stun',
urls: 'stun:global.stun.twilio.com:3478',
type: 'stun',
enabled: true,
isDefault: true,
}
];
const STORAGE_KEY = 'webrtc-ice-servers-config-090901';
export function useIceServersConfig() {
const [iceServers, setIceServers] = useState<IceServerConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
// 加载配置
const loadConfig = useCallback(() => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
if (savedConfig) {
const parsed = JSON.parse(savedConfig);
// 确保所有服务器都有isDefault属性
const serversWithDefaults = parsed.map((server: any) => ({
...server,
isDefault: server.isDefault !== undefined ? server.isDefault :
DEFAULT_ICE_SERVERS.some(defaultServer => defaultServer.id === server.id)
}));
setIceServers(serversWithDefaults);
} else {
setIceServers(DEFAULT_ICE_SERVERS);
}
} catch (error) {
console.error('加载ICE服务器配置失败:', error);
setIceServers(DEFAULT_ICE_SERVERS);
} finally {
setIsLoading(false);
}
}, []);
// 保存配置
const saveConfig = useCallback((servers: IceServerConfig[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
setIceServers(servers);
} catch (error) {
console.error('保存ICE服务器配置失败:', error);
throw new Error('保存配置失败');
}
}, []);
// 添加服务器
const addIceServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
const newServer: IceServerConfig = {
...config,
id: `custom-${Date.now()}`,
isDefault: false, // 用户添加的服务器不标记为默认
};
const updatedServers = [...iceServers, newServer];
saveConfig(updatedServers);
}, [iceServers, saveConfig]);
// 更新服务器
const updateIceServer = useCallback((id: string, updates: Partial<IceServerConfig>) => {
const updatedServers = iceServers.map(server =>
server.id === id ? { ...server, ...updates } : server
);
saveConfig(updatedServers);
}, [iceServers, saveConfig]);
// 删除服务器
const removeIceServer = useCallback((id: string) => {
// 确保至少保留一个服务器
if (iceServers.length <= 1) {
throw new Error('至少需要保留一个ICE服务器');
}
const updatedServers = iceServers.filter(server => server.id !== id);
saveConfig(updatedServers);
}, [iceServers, saveConfig]);
// 恢复默认配置
const resetToDefault = useCallback(() => {
saveConfig(DEFAULT_ICE_SERVERS);
}, [saveConfig]);
// 获取WebRTC格式的配置
const getWebRTCConfig = useCallback((): RTCIceServer[] => {
return iceServers
.filter(server => server.enabled)
.map(server => {
const rtcServer: RTCIceServer = {
urls: server.urls,
};
if (server.username) {
rtcServer.username = server.username;
}
if (server.credential) {
rtcServer.credential = server.credential;
}
return rtcServer;
});
}, [iceServers]);
// 验证服务器配置
const validateServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
const errors: string[] = [];
if (!config.urls.trim()) {
errors.push('服务器地址不能为空');
} else {
// 基本URL格式验证
const urlPattern = /^(stun|turn|turns):.+/i;
if (!urlPattern.test(config.urls)) {
errors.push('服务器地址格式不正确(应以 stun: 或 turn: 开头)');
}
}
if (config.type === 'turn') {
if (!config.username?.trim()) {
errors.push('TURN服务器需要用户名');
}
if (!config.credential?.trim()) {
errors.push('TURN服务器需要密码');
}
}
return errors;
}, []);
// 初始化加载
useEffect(() => {
loadConfig();
}, [loadConfig]);
return {
iceServers,
isLoading,
addIceServer,
updateIceServer,
removeIceServer,
resetToDefault,
getWebRTCConfig,
validateServer,
saveConfig,
};
}
// 独立的函数用于在非React组件中获取ICE服务器配置
export function getIceServersConfig(): RTCIceServer[] {
if (typeof window === 'undefined') {
// 服务器端默认配置
return [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
}
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) {
// 返回默认配置的WebRTC格式
return DEFAULT_ICE_SERVERS
.filter(server => server.enabled)
.map(server => {
const rtcServer: RTCIceServer = {
urls: server.urls,
};
if (server.username) {
rtcServer.username = server.username;
}
if (server.credential) {
rtcServer.credential = server.credential;
}
return rtcServer;
});
}
const iceServers: IceServerConfig[] = JSON.parse(saved);
return iceServers
.filter(server => server.enabled)
.map(server => {
const rtcServer: RTCIceServer = {
urls: server.urls,
};
if (server.username) {
rtcServer.username = server.username;
}
if (server.credential) {
rtcServer.credential = server.credential;
}
return rtcServer;
});
} catch (error) {
console.error('获取ICE服务器配置失败:', error);
// 发生错误时返回默认配置
return [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
}
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
export function useWebRTCConfigSync() {
const { showToast } = useToast();
// 监听存储变化事件
const handleStorageChange = useCallback((event: StorageEvent) => {
if (event.key === 'webrtc-ice-servers-config') {
showToast(
'检测到WebRTC配置更改请重新建立连接以应用新配置',
'info'
);
}
}, [showToast]);
useEffect(() => {
// 监听localStorage变化
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [handleStorageChange]);
return {
// 可以在这里添加其他配置同步相关的方法
};
}

View File

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

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from './useSharedWebRTCManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useWebConnectStateManager } from '../connection/state/useWebConnectStateManager';
import { Role, type IWebConnection } from '../connection/types';
// 文本传输状态
interface TextTransferState {
@@ -21,7 +22,7 @@ const CHANNEL_NAME = 'text-transfer';
*
* WebRTC
*/
export function useTextTransferBusiness(connection: WebRTCConnection) {
export function useTextTransferBusiness(connection: IWebConnection) {
const [state, setState] = useState<TextTransferState>({
isConnecting: false,
isConnected: false,
@@ -31,6 +32,8 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
isTyping: false
});
const connectState = useWebConnectStateManager(); // 确保状态管理器被初始化
// 回调引用
const textSyncCallbackRef = useRef<TextSyncCallback | null>(null);
const typingCallbackRef = useRef<TypingStatusCallback | null>(null);
@@ -86,15 +89,15 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
useEffect(() => {
// 同步连接状态
updateState({
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error
isConnecting: connectState.getState().isConnecting,
isConnected: connectState.getState().isConnected,
isWebSocketConnected: connectState.getState().isWebSocketConnected,
connectionError: connectState.getState().error
});
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
}, [connectState.getState, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
const connect = useCallback((roomCode: string, role: Role) => {
return connection.connect(roomCode, role);
}, [connection]);
@@ -105,7 +108,7 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
// 发送实时文本同步 (替代原来的 sendMessage)
const sendTextSync = useCallback((text: string) => {
if (!connection || !connection.isPeerConnected) return;
if (!connectState.getState().isConnected || !connection.getConnectState().isPeerConnected) return;
const message = {
type: 'text-sync',
@@ -116,11 +119,11 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
if (success) {
console.log('发送实时文本同步:', text.length, '字符');
}
}, [connection]);
}, [connectState.getState]);
// 发送打字状态
const sendTypingStatus = useCallback((isTyping: boolean) => {
if (!connection || !connection.isPeerConnected) return;
if (!connection || !connection.getConnectState().isPeerConnected) return;
const message = {
type: 'text-typing',
@@ -155,10 +158,10 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
return {
// 状态 - 直接从 connection 获取
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error,
isConnecting: connection.getConnectState().isConnecting,
isConnected: connection.getConnectState().isConnected,
isWebSocketConnected: connection.getConnectState().isWebSocketConnected,
connectionError: connection.getConnectState().error,
currentText: state.currentText,
isTyping: state.isTyping,

View File

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

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,183 @@
import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useConnectManager } from '../connection';
import { useWebRTCStore } from '../connection/state/webConnectStore';
import { useConfirmDialog } from './useConfirmDialog';
import { FeatureType, useURLHandler } from './useURLHandler';
// Tab类型定义包括非WebRTC功能
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat' | 'settings';
// Tab显示名称
const TAB_NAMES: Record<TabType, string> = {
webrtc: '文件传输',
message: '文字传输',
desktop: '桌面共享',
wechat: '微信群',
settings: '设置'
};
// 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,
} = useWebRTCStore();
// 获取WebRTC连接管理器
const { disconnect: disconnectWebRTC } = useConnectManager();
// 创建一个通用的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);
// 对于任何非WebRTC功能的tabwechat、settings如果有活跃连接需要确认
if ((newTab === 'wechat' || newTab === 'settings') && hasActiveConnection()) {
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;
}
// 断开连接并清除状态
disconnectWebRTC();
console.log(`已清除WebRTC连接状态切换到${targetTabName}`);
setActiveTab(newTab);
// 清除URL参数
const newUrl = new URL(window.location.href);
newUrl.search = '';
window.history.pushState({}, '', newUrl.toString());
return true;
}
// 如果切换到非活跃连接的wechat或settings tab直接切换
if (newTab === 'wechat' || newTab === 'settings') {
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状态
disconnectWebRTC();
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, disconnectWebRTC]);
// 获取连接状态信息
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 { useToast } from '@/components/ui/toast-simple';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useWebRTCStore } from '../connection/state/webConnectStore';
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

@@ -1,407 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useSharedWebRTCManager } from './useSharedWebRTCManager';
interface DesktopShareState {
isSharing: boolean;
isViewing: boolean;
connectionCode: string;
remoteStream: MediaStream | null;
error: string | null;
isWaitingForPeer: boolean; // 新增:是否等待对方连接
}
export function useDesktopShareBusiness() {
const webRTC = useSharedWebRTCManager();
const [state, setState] = useState<DesktopShareState>({
isSharing: false,
isViewing: false,
connectionCode: '',
remoteStream: null,
error: null,
isWaitingForPeer: false,
});
const localStreamRef = useRef<MediaStream | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
const currentSenderRef = useRef<RTCRtpSender | null>(null);
const updateState = useCallback((updates: Partial<DesktopShareState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 生成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 {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor',
} as DisplayMediaStreamOptions['video'],
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
} as DisplayMediaStreamOptions['audio'],
});
console.log('[DesktopShare] 获取桌面流成功:', stream.getTracks().length, '个轨道');
return stream;
} catch (error) {
console.error('[DesktopShare] 获取桌面流失败:', error);
throw new Error('无法获取桌面共享权限,请确保允许屏幕共享');
}
}, []);
// 设置视频轨道发送
const setupVideoSending = useCallback(async (stream: MediaStream) => {
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
// 移除之前的轨道(如果存在)
if (currentSenderRef.current) {
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 添加新的视频轨道到PeerConnection
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
if (videoTrack) {
console.log('[DesktopShare] 📹 添加视频轨道:', videoTrack.id, videoTrack.readyState);
const videoSender = webRTC.addTrack(videoTrack, stream);
if (videoSender) {
currentSenderRef.current = videoSender;
console.log('[DesktopShare] ✅ 视频轨道添加成功');
} else {
console.warn('[DesktopShare] ⚠️ 视频轨道添加返回null');
}
} else {
console.error('[DesktopShare] ❌ 未找到视频轨道');
throw new Error('未找到视频轨道');
}
if (audioTrack) {
try {
console.log('[DesktopShare] 🎵 添加音频轨道:', audioTrack.id, audioTrack.readyState);
const audioSender = webRTC.addTrack(audioTrack, stream);
if (audioSender) {
console.log('[DesktopShare] ✅ 音频轨道添加成功');
} else {
console.warn('[DesktopShare] ⚠️ 音频轨道添加返回null');
}
} catch (error) {
console.warn('[DesktopShare] ⚠️ 音频轨道添加失败,继续视频共享:', error);
}
} else {
console.log('[DesktopShare] 未检测到音频轨道(这通常是正常的)');
}
// 轨道添加完成,现在需要重新协商以包含媒体轨道
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
// 检查P2P连接是否已建立
if (!webRTC.isPeerConnected) {
console.error('[DesktopShare] ❌ P2P连接尚未建立无法开始媒体传输');
throw new Error('P2P连接尚未建立');
}
// 创建新的offer包含媒体轨道
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
const success = await webRTC.createOfferNow();
if (success) {
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
} else {
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
throw new Error('媒体轨道重新协商失败');
}
// 监听流结束事件(用户停止共享)
const handleStreamEnded = () => {
console.log('[DesktopShare] 🛑 用户停止了屏幕共享');
stopSharing();
};
videoTrack?.addEventListener('ended', handleStreamEnded);
audioTrack?.addEventListener('ended', handleStreamEnded);
return () => {
videoTrack?.removeEventListener('ended', handleStreamEnded);
audioTrack?.removeEventListener('ended', handleStreamEnded);
};
}, [webRTC]);
// 处理远程流
const handleRemoteStream = useCallback((stream: MediaStream) => {
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
updateState({ remoteStream: stream });
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = stream;
}
}, [updateState]);
// 创建房间(只建立连接,等待对方加入)
const createRoom = useCallback(async (): Promise<string> => {
try {
updateState({ error: null, isWaitingForPeer: false });
// 生成房间代码
const roomCode = generateRoomCode();
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
// 建立WebRTC连接作为发送方
console.log('[DesktopShare] 📡 正在建立WebRTC连接...');
await webRTC.connect(roomCode, 'sender');
console.log('[DesktopShare] ✅ WebSocket连接已建立');
updateState({
connectionCode: roomCode,
isWaitingForPeer: true, // 标记为等待对方连接
});
console.log('[DesktopShare] 🎯 房间创建完成等待对方加入建立P2P连接');
return roomCode;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
console.error('[DesktopShare] ❌ 创建房间失败:', error);
updateState({ error: errorMessage, connectionCode: '', isWaitingForPeer: false });
throw error;
}
}, [webRTC, generateRoomCode, updateState]);
// 开始桌面共享(在接收方加入后)
const startSharing = useCallback(async (): Promise<void> => {
try {
// 检查WebSocket连接状态
if (!webRTC.isWebSocketConnected) {
throw new Error('WebSocket连接未建立请先创建房间');
}
updateState({ error: null });
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
// 获取桌面流
const stream = await getDesktopStream();
localStreamRef.current = stream;
console.log('[DesktopShare] ✅ 桌面流获取成功');
// 设置视频发送这会添加轨道并创建offer启动P2P连接
console.log('[DesktopShare] 📤 正在设置视频轨道推送并建立P2P连接...');
await setupVideoSending(stream);
console.log('[DesktopShare] ✅ 视频轨道推送设置完成');
updateState({
isSharing: true,
isWaitingForPeer: false,
});
console.log('[DesktopShare] 🎉 桌面共享已开始');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
console.error('[DesktopShare] ❌ 开始共享失败:', error);
updateState({ error: errorMessage, isSharing: false });
// 清理资源
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
localStreamRef.current = null;
}
throw error;
}
}, [webRTC, getDesktopStream, setupVideoSending, updateState]);
// 切换桌面共享(重新选择屏幕)
const switchDesktop = useCallback(async (): Promise<void> => {
try {
if (!webRTC.isPeerConnected) {
throw new Error('P2P连接未建立');
}
if (!state.isSharing) {
throw new Error('当前未在共享桌面');
}
updateState({ error: null });
console.log('[DesktopShare] 🔄 正在切换桌面共享...');
// 获取新的桌面流
const newStream = await getDesktopStream();
// 停止之前的流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = newStream;
console.log('[DesktopShare] ✅ 新桌面流获取成功');
// 设置新的视频发送
await setupVideoSending(newStream);
console.log('[DesktopShare] ✅ 桌面切换完成');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
console.error('[DesktopShare] ❌ 切换桌面失败:', error);
updateState({ error: errorMessage });
throw error;
}
}, [webRTC, state.isSharing, getDesktopStream, setupVideoSending, updateState]);
// 停止桌面共享
const stopSharing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 停止桌面共享');
// 停止本地流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => {
track.stop();
console.log('[DesktopShare] 停止轨道:', track.kind);
});
localStreamRef.current = null;
}
// 移除发送器
if (currentSenderRef.current) {
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 断开WebRTC连接
webRTC.disconnect();
updateState({
isSharing: false,
connectionCode: '',
error: null,
isWaitingForPeer: false,
});
console.log('[DesktopShare] 桌面共享已停止');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
console.error('[DesktopShare] 停止共享失败:', error);
updateState({ error: errorMessage });
}
}, [webRTC, updateState]);
// 加入桌面共享观看
const joinSharing = useCallback(async (code: string): Promise<void> => {
try {
updateState({ error: null });
console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code);
// 连接WebRTC
console.log('[DesktopShare] 🔗 正在连接WebRTC作为接收方...');
await webRTC.connect(code, 'receiver');
console.log('[DesktopShare] ✅ WebRTC连接建立完成');
// 等待连接完全建立
console.log('[DesktopShare] ⏳ 等待连接稳定...');
await new Promise(resolve => setTimeout(resolve, 1000));
// 设置远程流处理 - 在连接建立后设置
console.log('[DesktopShare] 📡 设置远程流处理器...');
webRTC.onTrack((event: RTCTrackEvent) => {
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
console.log('[DesktopShare] 远程流数量:', event.streams.length);
if (event.streams.length > 0) {
const remoteStream = event.streams[0];
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
handleRemoteStream(remoteStream);
} else {
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
}
});
updateState({ isViewing: true });
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
console.error('[DesktopShare] ❌ 加入观看失败:', error);
updateState({ error: errorMessage, isViewing: false });
throw error;
}
}, [webRTC, handleRemoteStream, updateState]);
// 停止观看桌面共享
const stopViewing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 停止观看桌面共享');
// 断开WebRTC连接
webRTC.disconnect();
updateState({
isViewing: false,
remoteStream: null,
error: null,
});
console.log('[DesktopShare] 已停止观看桌面共享');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止观看失败';
console.error('[DesktopShare] 停止观看失败:', error);
updateState({ error: errorMessage });
}
}, [webRTC, updateState]);
// 设置远程视频元素引用
const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => {
remoteVideoRef.current = videoElement;
if (videoElement && state.remoteStream) {
videoElement.srcObject = state.remoteStream;
}
}, [state.remoteStream]);
// 清理资源
useEffect(() => {
return () => {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
return {
// 状态
isSharing: state.isSharing,
isViewing: state.isViewing,
connectionCode: state.connectionCode,
remoteStream: state.remoteStream,
error: state.error,
isWaitingForPeer: state.isWaitingForPeer,
isConnected: webRTC.isConnected,
isConnecting: webRTC.isConnecting,
isWebSocketConnected: webRTC.isWebSocketConnected,
isPeerConnected: webRTC.isPeerConnected,
// 新增表示是否可以开始共享WebSocket已连接且有房间代码
canStartSharing: webRTC.isWebSocketConnected && !!state.connectionCode,
// 方法
createRoom, // 创建房间
startSharing, // 选择桌面并建立P2P连接
switchDesktop, // 新增:切换桌面
stopSharing,
joinSharing,
stopViewing,
setRemoteVideoRef,
// WebRTC连接状态
webRTCError: webRTC.error,
};
}

View File

@@ -1,356 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from './useSharedWebRTCManager';
// 文件传输状态
interface FileTransferState {
isConnecting: boolean;
isConnected: boolean;
isWebSocketConnected: boolean;
connectionError: string | null;
isTransferring: boolean;
progress: number;
error: string | null;
receivedFiles: Array<{ id: string; file: File }>;
}
// 文件信息
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
// 文件元数据
interface FileMetadata {
id: string;
name: string;
size: number;
type: string;
}
// 文件块信息
interface FileChunk {
fileId: string;
chunkIndex: number;
totalChunks: number;
}
// 回调类型
type FileReceivedCallback = (fileData: { id: string; file: File }) => void;
type FileRequestedCallback = (fileId: string, fileName: string) => void;
type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; progress: number }) => void;
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
const CHANNEL_NAME = 'file-transfer';
const CHUNK_SIZE = 256 * 1024; // 256KB
/**
* 文件传输业务层
* 必须传入共享的 WebRTC 连接
*/
export function useFileTransferBusiness(connection: WebRTCConnection) {
const [state, setState] = useState<FileTransferState>({
isConnecting: false,
isConnected: false,
isWebSocketConnected: false,
connectionError: null,
isTransferring: false,
progress: 0,
error: null,
receivedFiles: [],
});
// 接收文件缓存
const receivingFiles = useRef<Map<string, {
metadata: FileMetadata;
chunks: ArrayBuffer[];
receivedChunks: number;
}>>(new Map());
// 当前期望的文件块
const expectedChunk = useRef<FileChunk | null>(null);
// 回调存储
const fileReceivedCallbacks = useRef<Set<FileReceivedCallback>>(new Set());
const fileRequestedCallbacks = useRef<Set<FileRequestedCallback>>(new Set());
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
const updateState = useCallback((updates: Partial<FileTransferState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 消息处理器
const handleMessage = useCallback((message: any) => {
if (!message.type.startsWith('file-')) return;
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: [],
receivedChunks: 0,
});
updateState({ isTransferring: true, progress: 0 });
break;
case 'file-chunk-info':
expectedChunk.current = message.payload;
console.log('准备接收文件块:', message.payload);
break;
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
});
console.log('文件接收完成:', file.name);
setState(prev => ({
...prev,
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
isTransferring: false,
progress: 100
}));
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
receivingFiles.current.delete(fileId);
}
break;
case 'file-list':
console.log('收到文件列表:', message.payload);
fileListCallbacks.current.forEach(cb => cb(message.payload));
break;
case 'file-request':
const { fileId: requestedFileId, fileName } = message.payload;
console.log('收到文件请求:', fileName, requestedFileId);
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
break;
}
}, [updateState]);
// 处理文件块数据
const handleData = useCallback((data: ArrayBuffer) => {
if (!expectedChunk.current) {
console.warn('收到数据但没有对应的块信息');
return;
}
const { fileId, chunkIndex, totalChunks } = expectedChunk.current;
const fileInfo = receivingFiles.current.get(fileId);
if (fileInfo) {
fileInfo.chunks[chunkIndex] = data;
fileInfo.receivedChunks++;
const progress = (fileInfo.receivedChunks / totalChunks) * 100;
updateState({ progress });
fileProgressCallbacks.current.forEach(cb => cb({
fileId: fileId,
fileName: fileInfo.metadata.name,
progress
}));
console.log(`文件 ${fileInfo.metadata.name} 接收进度: ${progress.toFixed(1)}%`);
expectedChunk.current = null;
}
}, [updateState]);
// 设置处理器
useEffect(() => {
// 使用共享连接的注册方式
const unregisterMessage = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
const unregisterData = connection.registerDataHandler(CHANNEL_NAME, handleData);
return () => {
unregisterMessage();
unregisterData();
};
}, [handleMessage, handleData]);
// 监听连接状态变化 (直接使用 connection 的状态)
useEffect(() => {
// 同步连接状态
updateState({
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error
});
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return connection.connect(roomCode, role);
}, [connection]);
// 发送文件
const sendFile = useCallback(async (file: File, fileId?: string) => {
if (connection.getChannelState() !== 'open') {
updateState({ error: '连接未就绪' });
return;
}
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
console.log('开始发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
updateState({ isTransferring: true, progress: 0, error: null });
try {
// 1. 发送文件元数据
connection.sendMessage({
type: 'file-metadata',
payload: {
id: actualFileId,
name: file.name,
size: file.size,
type: file.type
}
}, CHANNEL_NAME);
// 2. 分块发送文件
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
// 先发送块信息
connection.sendMessage({
type: 'file-chunk-info',
payload: {
fileId: actualFileId,
chunkIndex,
totalChunks
}
}, CHANNEL_NAME);
// 再发送块数据
const arrayBuffer = await chunk.arrayBuffer();
connection.sendData(arrayBuffer);
const progress = ((chunkIndex + 1) / totalChunks) * 100;
updateState({ progress });
fileProgressCallbacks.current.forEach(cb => cb({
fileId: actualFileId,
fileName: file.name,
progress
}));
// 简单的流控:等待一小段时间让接收方处理
if (chunkIndex % 10 === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
// 3. 发送完成信号
connection.sendMessage({
type: 'file-complete',
payload: { fileId: actualFileId }
}, CHANNEL_NAME);
updateState({ isTransferring: false, progress: 100 });
console.log('文件发送完成:', file.name);
} catch (error) {
console.error('发送文件失败:', error);
updateState({
error: error instanceof Error ? error.message : '发送失败',
isTransferring: false
});
}
}, [connection, updateState]);
// 发送文件列表
const sendFileList = useCallback((fileList: FileInfo[]) => {
if (!connection.isPeerConnected) {
console.log('P2P连接未建立等待连接后再发送文件列表');
return;
}
if (connection.getChannelState() !== 'open') {
console.error('数据通道未准备就绪,无法发送文件列表');
return;
}
console.log('发送文件列表:', fileList);
connection.sendMessage({
type: 'file-list',
payload: fileList
}, CHANNEL_NAME);
}, [connection]);
// 请求文件
const requestFile = useCallback((fileId: string, fileName: string) => {
if (connection.getChannelState() !== 'open') {
console.error('数据通道未准备就绪,无法请求文件');
return;
}
console.log('请求文件:', fileName, fileId);
connection.sendMessage({
type: 'file-request',
payload: { fileId, fileName }
}, CHANNEL_NAME);
}, [connection]);
// 注册回调函数
const onFileReceived = useCallback((callback: FileReceivedCallback) => {
fileReceivedCallbacks.current.add(callback);
return () => { fileReceivedCallbacks.current.delete(callback); };
}, []);
const onFileRequested = useCallback((callback: FileRequestedCallback) => {
fileRequestedCallbacks.current.add(callback);
return () => { fileRequestedCallbacks.current.delete(callback); };
}, []);
const onFileProgress = useCallback((callback: FileProgressCallback) => {
fileProgressCallbacks.current.add(callback);
return () => { fileProgressCallbacks.current.delete(callback); };
}, []);
const onFileListReceived = useCallback((callback: FileListReceivedCallback) => {
fileListCallbacks.current.add(callback);
return () => { fileListCallbacks.current.delete(callback); };
}, []);
return {
// 文件传输状态(包括连接状态)
...state,
// 操作方法
connect,
disconnect: connection.disconnect,
sendFile,
sendFileList,
requestFile,
// 回调注册
onFileReceived,
onFileRequested,
onFileProgress,
onFileListReceived,
};
}

View File

@@ -1,665 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { getWsUrl } from '@/lib/config';
// 基础连接状态
interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
}
// 消息类型
interface WebRTCMessage {
type: string;
payload: any;
channel?: string;
}
// 消息和数据处理器类型
type MessageHandler = (message: WebRTCMessage) => void;
type DataHandler = (data: ArrayBuffer) => void;
// WebRTC 连接接口
export interface WebRTCConnection {
// 状态
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
// 操作方法
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
disconnect: () => void;
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
sendData: (data: ArrayBuffer) => boolean;
// 处理器注册
registerMessageHandler: (channel: string, handler: MessageHandler) => () => void;
registerDataHandler: (channel: string, handler: DataHandler) => () => void;
// 工具方法
getChannelState: () => RTCDataChannelState;
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') => boolean;
// 当前房间信息
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
// 媒体轨道方法
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
removeTrack: (sender: RTCRtpSender) => void;
onTrack: (callback: (event: RTCTrackEvent) => void) => void;
getPeerConnection: () => RTCPeerConnection | null;
createOfferNow: () => Promise<boolean>;
}
/**
* 共享 WebRTC 连接管理器
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
*/
export function useSharedWebRTCManager(): WebRTCConnection {
const [state, setState] = useState<WebRTCState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
});
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 当前连接的房间信息
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
// 多通道消息处理器
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// STUN 服务器配置 - 使用更稳定的服务器
const STUN_SERVERS = [
{ 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' },
];
const updateState = useCallback((updates: Partial<WebRTCState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 清理连接
const cleanup = useCallback(() => {
console.log('[SharedWebRTC] 清理连接');
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
currentRoom.current = null;
}, []);
// 创建 Offer
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
console.log('[SharedWebRTC] 🎬 开始创建offer当前轨道数量:', pc.getSenders().length);
const offer = await pc.createOffer({
offerToReceiveAudio: true, // 改为true以支持音频接收
offerToReceiveVideo: true, // 改为true以支持视频接收
});
console.log('[SharedWebRTC] 📝 Offer创建成功设置本地描述...');
await pc.setLocalDescription(offer);
const iceTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 📤 发送 offer (超时发送)');
}
}, 3000);
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
}
} else {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
}
}
};
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 创建 offer 失败:', error);
updateState({ error: '创建连接失败', isConnecting: false });
}
}, [updateState]);
// 处理数据通道消息
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as WebRTCMessage;
console.log('[SharedWebRTC] 收到消息:', message.type, message.channel || 'default');
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 兼容旧版本,广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} catch (error) {
console.error('[SharedWebRTC] 解析消息失败:', error);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('[SharedWebRTC] 收到数据:', event.data.byteLength, 'bytes');
// 数据优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 如果没有文件处理器,发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
}
}, []);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
// 如果正在连接中,避免重复连接
if (state.isConnecting) {
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
return;
}
// 清理之前的连接
cleanup();
currentRoom.current = { code: roomCode, role };
updateState({ isConnecting: true, error: null });
// 注意不在这里设置超时因为WebSocket连接很快
// WebRTC连接的建立是在后续添加轨道时进行的
try {
console.log('[SharedWebRTC] 🔧 创建PeerConnection...');
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: STUN_SERVERS,
iceCandidatePoolSize: 10,
});
pcRef.current = pc;
// 连接 WebSocket - 使用动态URL
const baseWsUrl = getWsUrl();
if (!baseWsUrl) {
throw new Error('WebSocket URL未配置');
}
// 构建完整的WebSocket URL
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
console.log('[SharedWebRTC] 🌐 连接WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// WebSocket 事件处理
ws.onopen = () => {
console.log('[SharedWebRTC] ✅ WebSocket 连接已建立,房间准备就绪');
updateState({
isWebSocketConnected: true,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log('[SharedWebRTC] 📨 收到信令消息:', message.type);
switch (message.type) {
case 'peer-joined':
// 对方加入房间的通知
console.log('[SharedWebRTC] 👥 对方已加入房间,角色:', message.payload?.role);
if (role === 'sender' && message.payload?.role === 'receiver') {
console.log('[SharedWebRTC] 🚀 接收方已连接发送方自动建立P2P连接');
updateState({ isPeerConnected: true }); // 标记对方已加入可以开始P2P
// 发送方自动创建offer建立基础P2P连接
try {
console.log('[SharedWebRTC] 📡 自动创建基础P2P连接offer');
await createOffer(pc, ws);
} catch (error) {
console.error('[SharedWebRTC] 自动创建基础P2P连接失败:', error);
}
} else if (role === 'receiver' && message.payload?.role === 'sender') {
console.log('[SharedWebRTC] 🚀 发送方已连接接收方准备接收P2P连接');
updateState({ isPeerConnected: true }); // 标记对方已加入
}
break;
case 'offer':
console.log('[SharedWebRTC] 📬 处理offer...');
if (pc.signalingState === 'stable') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[SharedWebRTC] ✅ 设置远程描述完成');
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log('[SharedWebRTC] ✅ 创建并设置answer完成');
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('[SharedWebRTC] 📤 发送 answer');
} else {
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是stable:', pc.signalingState);
}
break;
case 'answer':
console.log('[SharedWebRTC] 📬 处理answer...');
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[SharedWebRTC] ✅ answer 处理完成');
} else {
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是have-local-offer:', pc.signalingState);
}
break;
case 'ice-candidate':
if (message.payload && pc.remoteDescription) {
try {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
console.log('[SharedWebRTC] ✅ 添加 ICE 候选成功');
} catch (err) {
console.warn('[SharedWebRTC] ⚠️ 添加 ICE 候选失败:', err);
}
} else {
console.warn('[SharedWebRTC] ⚠️ ICE候选无效或远程描述未设置');
}
break;
case 'error':
console.error('[SharedWebRTC] ❌ 信令服务器错误:', message.error);
updateState({ error: message.error, isConnecting: false });
break;
default:
console.warn('[SharedWebRTC] ⚠️ 未知消息类型:', message.type);
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 处理信令消息失败:', error);
updateState({ error: '信令处理失败: ' + error, isConnecting: false });
}
};
ws.onerror = (error) => {
console.error('[SharedWebRTC] ❌ WebSocket 错误:', error);
updateState({ error: 'WebSocket连接失败请检查服务器是否运行在8080端口', isConnecting: false });
};
ws.onclose = (event) => {
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
updateState({ isWebSocketConnected: false });
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '未知原因'}`, isConnecting: false });
}
};
// PeerConnection 事件处理
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice-candidate',
payload: event.candidate
}));
console.log('[SharedWebRTC] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
} else if (!event.candidate) {
console.log('[SharedWebRTC] 🏁 ICE 收集完成');
}
};
pc.oniceconnectionstatechange = () => {
console.log('[SharedWebRTC] 🧊 ICE连接状态变化:', pc.iceConnectionState);
switch (pc.iceConnectionState) {
case 'checking':
console.log('[SharedWebRTC] 🔍 正在检查ICE连接...');
break;
case 'connected':
case 'completed':
console.log('[SharedWebRTC] ✅ ICE连接成功');
break;
case 'failed':
console.error('[SharedWebRTC] ❌ ICE连接失败');
updateState({ error: 'ICE连接失败可能是网络防火墙阻止了连接', isConnecting: false });
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 ICE连接断开');
break;
case 'closed':
console.log('[SharedWebRTC] 🚫 ICE连接已关闭');
break;
}
};
pc.onconnectionstatechange = () => {
console.log('[SharedWebRTC] 🔗 WebRTC连接状态变化:', pc.connectionState);
switch (pc.connectionState) {
case 'connecting':
console.log('[SharedWebRTC] 🔄 WebRTC正在连接中...');
updateState({ isPeerConnected: false });
break;
case 'connected':
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立可以进行媒体传输');
updateState({ isPeerConnected: true, error: null });
break;
case 'failed':
// 只有在数据通道也未打开的情况下才认为连接真正失败
const currentDc = dcRef.current;
if (!currentDc || currentDc.readyState !== 'open') {
console.error('[SharedWebRTC] ❌ WebRTC连接失败数据通道未建立');
updateState({ error: 'WebRTC连接失败请检查网络设置或重试', isPeerConnected: false });
} else {
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed但数据通道正常忽略此状态');
}
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 WebRTC连接已断开');
updateState({ isPeerConnected: false });
break;
case 'closed':
console.log('[SharedWebRTC] 🚫 WebRTC连接已关闭');
updateState({ isPeerConnected: false });
break;
}
};
// 数据通道处理
if (role === 'sender') {
const dataChannel = pc.createDataChannel('shared-channel', {
ordered: true,
maxRetransmits: 3
});
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false });
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
};
} else {
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false });
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
};
};
}
// 设置轨道接收处理(对于接收方)
pc.ontrack = (event) => {
console.log('[SharedWebRTC] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id);
console.log('[SharedWebRTC] 关联的流数量:', event.streams.length);
if (event.streams.length > 0) {
console.log('[SharedWebRTC] 🎬 轨道关联到流:', event.streams[0].id);
}
// 这里不处理,让具体的业务逻辑处理
// onTrack会被业务逻辑重新设置
};
} catch (error) {
console.error('[SharedWebRTC] 连接失败:', error);
updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false
});
}
}, [updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[SharedWebRTC] 断开连接');
cleanup();
setState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
});
}, [cleanup]);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[SharedWebRTC] 数据通道未准备就绪');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
dataChannel.send(JSON.stringify(messageWithChannel));
console.log('[SharedWebRTC] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[SharedWebRTC] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[SharedWebRTC] 数据通道未准备就绪');
return false;
}
try {
dataChannel.send(data);
console.log('[SharedWebRTC] 发送数据:', data.byteLength, 'bytes');
return true;
} catch (error) {
console.error('[SharedWebRTC] 发送数据失败:', error);
return false;
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[SharedWebRTC] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[SharedWebRTC] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[SharedWebRTC] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[SharedWebRTC] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
return dcRef.current?.readyState || 'closed';
}, []);
// 检查是否已连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return currentRoom.current?.code === roomCode &&
currentRoom.current?.role === role &&
state.isConnected;
}, [state.isConnected]);
// 添加媒体轨道
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
const pc = pcRef.current;
if (!pc) {
console.error('[SharedWebRTC] PeerConnection 不可用');
return null;
}
try {
return pc.addTrack(track, stream);
} catch (error) {
console.error('[SharedWebRTC] 添加轨道失败:', error);
return null;
}
}, []);
// 移除媒体轨道
const removeTrack = useCallback((sender: RTCRtpSender) => {
const pc = pcRef.current;
if (!pc) {
console.error('[SharedWebRTC] PeerConnection 不可用');
return;
}
try {
pc.removeTrack(sender);
} catch (error) {
console.error('[SharedWebRTC] 移除轨道失败:', error);
}
}, []);
// 设置轨道处理器
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
const pc = pcRef.current;
if (!pc) {
console.warn('[SharedWebRTC] PeerConnection 尚未准备就绪将在连接建立后设置onTrack');
// 延迟设置等待PeerConnection准备就绪
const checkAndSetTrackHandler = () => {
const currentPc = pcRef.current;
if (currentPc) {
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪设置onTrack处理器');
currentPc.ontrack = handler;
} else {
console.log('[SharedWebRTC] ⏳ 等待PeerConnection准备就绪...');
setTimeout(checkAndSetTrackHandler, 100);
}
};
checkAndSetTrackHandler();
return;
}
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
pc.ontrack = handler;
}, []);
// 获取PeerConnection实例
const getPeerConnection = useCallback(() => {
return pcRef.current;
}, []);
// 立即创建offer用于媒体轨道添加后的重新协商
const createOfferNow = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[SharedWebRTC] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
await createOffer(pc, ws);
return true;
} catch (error) {
console.error('[SharedWebRTC] 创建 offer 失败:', error);
return false;
}
}, [createOffer]);
return {
// 状态
isConnected: state.isConnected,
isConnecting: state.isConnecting,
isWebSocketConnected: state.isWebSocketConnected,
isPeerConnected: state.isPeerConnected,
error: state.error,
// 操作方法
connect,
disconnect,
sendMessage,
sendData,
// 处理器注册
registerMessageHandler,
registerDataHandler,
// 工具方法
getChannelState,
isConnectedToRoom,
// 媒体轨道方法
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
// 当前房间信息
currentRoom: currentRoom.current,
};
}

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', {});
}
/**
@@ -133,20 +106,6 @@ export class ClientAPI {
async getRoomInfo(code: string): Promise<ApiResponse> {
return this.get(`/api/room-info?code=${code}`);
}
/**
* 获取WebRTC房间状态
*/
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

@@ -26,19 +26,20 @@ const getCurrentBaseUrl = () => {
// 动态获取 WebSocket URL - 总是在客户端运行时计算
const getCurrentWsUrl = () => {
// return "ws://192.168.1.120:8080"
if (typeof window !== 'undefined') {
// 检查是否是 Next.js 开发服务器(端口 3000 或 3001
const isNextDevServer = window.location.hostname === 'localhost' &&
(window.location.port === '3000' || window.location.port === '3001');
const isNextDevServer = window.location.hostname === 'localhost' &&
(window.location.port === '3000' || window.location.port === '3001');
if (isNextDevServer) {
// 开发模式:通过 Next.js 开发服务器访问,连接到后端 WebSocket
return 'ws://localhost:8080/ws/p2p';
return 'ws://localhost:8080';
}
// 生产模式或通过 Go 服务器访问:使用当前域名和端口
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws/p2p`;
return `${protocol}//${window.location.host}`;
}
// 服务器端返回空字符串,强制在客户端计算
return '';
@@ -49,28 +50,28 @@ export const config = {
isDev: getEnv('NODE_ENV') === 'development',
isProd: getEnv('NODE_ENV') === 'production',
isStatic: typeof window !== 'undefined', // 客户端运行时认为是静态模式
// API配置
api: {
// 后端API地址 (服务器端使用)
backendUrl: getEnv('GO_BACKEND_URL', 'http://localhost:8080'),
// 前端API基础URL (客户端使用) - 开发模式下调用 Next.js API 路由
baseUrl: getEnv('NEXT_PUBLIC_API_BASE_URL', 'http://localhost:3000'),
// 直接后端URL (客户端在静态模式下使用) - 如果环境变量为空,则使用当前域名
directBackendUrl: getEnv('NEXT_PUBLIC_BACKEND_URL') || getCurrentBaseUrl(),
// WebSocket地址 - 在客户端运行时动态计算,不在构建时预设
wsUrl: '', // 将通过 getWsUrl() 函数动态获取
},
// 超时配置
timeout: {
api: 30000, // 30秒
ws: 60000, // 60秒
},
// 重试配置
retry: {
max: 3,
@@ -122,12 +123,12 @@ export function getWsUrl(): string {
if (envWsUrl) {
return envWsUrl;
}
// 如果是服务器端SSG构建时返回空字符串
if (typeof window === 'undefined') {
return '';
}
// 客户端运行时动态计算
return getCurrentWsUrl();
}

View File

@@ -0,0 +1,273 @@
/**
* 传输速度和时间计算工具
*/
export interface TransferSpeed {
bytesPerSecond: number;
displaySpeed: string;
unit: string;
}
export interface TransferProgress {
totalBytes: number;
transferredBytes: number;
percentage: number;
speed: TransferSpeed;
remainingTime: {
seconds: number;
display: string;
};
elapsedTime: {
seconds: number;
display: string;
};
}
/**
* 格式化传输速度显示
* @param bytesPerSecond 每秒传输的字节数
* @returns 格式化的速度显示
*/
export function formatTransferSpeed(bytesPerSecond: number): TransferSpeed {
if (bytesPerSecond < 1024) {
return {
bytesPerSecond,
displaySpeed: `${bytesPerSecond.toFixed(0)}`,
unit: 'B/s'
};
} else if (bytesPerSecond < 1024 * 1024) {
const kbps = bytesPerSecond / 1024;
return {
bytesPerSecond,
displaySpeed: `${kbps.toFixed(1)}`,
unit: 'KB/s'
};
} else {
const mbps = bytesPerSecond / (1024 * 1024);
return {
bytesPerSecond,
displaySpeed: `${mbps.toFixed(1)}`,
unit: 'MB/s'
};
}
}
/**
* 格式化时间显示
* @param seconds 秒数
* @returns 格式化的时间显示
*/
export function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) {
return '--:--';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
}
/**
* 传输进度跟踪器
*/
export class TransferProgressTracker {
private startTime: number;
private lastUpdateTime: number;
private lastSpeedUpdateTime: number;
private lastProgressUpdateTime: number;
private lastTransferredBytes: number;
private speedHistory: number[] = [];
private readonly maxHistorySize = 10; // 保持最近10个速度样本
private readonly speedUpdateInterval = 300; // 速度更新间隔0.3秒
private readonly progressUpdateInterval = 50; // 进度更新间隔0.3秒
private cachedProgress: TransferProgress | null = null;
private lastDisplayedSpeed: TransferSpeed;
private lastDisplayedPercentage: number = 0;
constructor(
private totalBytes: number,
private initialTransferredBytes: number = 0
) {
this.startTime = Date.now();
this.lastUpdateTime = this.startTime;
this.lastSpeedUpdateTime = this.startTime;
this.lastProgressUpdateTime = this.startTime;
this.lastTransferredBytes = initialTransferredBytes;
this.lastDisplayedSpeed = formatTransferSpeed(0);
}
/**
* 更新传输进度
* @param transferredBytes 已传输的字节数
* @returns 传输进度信息
*/
update(transferredBytes: number): TransferProgress {
const now = Date.now();
const elapsedTimeMs = now - this.startTime;
const timeSinceLastUpdate = now - this.lastUpdateTime;
const timeSinceLastSpeedUpdate = now - this.lastSpeedUpdateTime;
const timeSinceLastProgressUpdate = now - this.lastProgressUpdateTime;
// 计算即时速度(基于最近的更新)
let instantSpeed = 0;
if (timeSinceLastUpdate > 0) {
const bytesDiff = transferredBytes - this.lastTransferredBytes;
instantSpeed = (bytesDiff * 1000) / timeSinceLastUpdate; // bytes per second
}
// 只有当距离上次速度更新超过指定间隔时才更新速度显示
let shouldUpdateSpeed = timeSinceLastSpeedUpdate >= this.speedUpdateInterval;
// 只有当距离上次进度更新超过指定间隔时才更新进度显示
let shouldUpdateProgress = timeSinceLastProgressUpdate >= this.progressUpdateInterval;
// 如果是第一次更新或者传输完成,立即更新速度和进度
if (this.cachedProgress === null || transferredBytes >= this.totalBytes) {
shouldUpdateSpeed = true;
shouldUpdateProgress = true;
}
if (shouldUpdateSpeed) {
// 更新速度历史
if (instantSpeed > 0) {
this.speedHistory.push(instantSpeed);
if (this.speedHistory.length > this.maxHistorySize) {
this.speedHistory.shift();
}
}
// 计算平均速度
let averageSpeed = 0;
if (this.speedHistory.length > 0) {
averageSpeed = this.speedHistory.reduce((sum, speed) => sum + speed, 0) / this.speedHistory.length;
} else if (elapsedTimeMs > 0) {
// 如果没有即时速度历史,使用总体平均速度
averageSpeed = (transferredBytes * 1000) / elapsedTimeMs;
}
// 更新显示的速度
this.lastDisplayedSpeed = formatTransferSpeed(averageSpeed);
this.lastSpeedUpdateTime = now;
}
// 更新显示的进度百分比
if (shouldUpdateProgress) {
const currentPercentage = this.totalBytes > 0 ? (transferredBytes / this.totalBytes) * 100 : 0;
this.lastDisplayedPercentage = Math.min(currentPercentage, 100);
this.lastProgressUpdateTime = now;
}
// 计算剩余时间(使用当前显示的速度)
const remainingBytes = this.totalBytes - transferredBytes;
const remainingTimeSeconds = this.lastDisplayedSpeed.bytesPerSecond > 0
? remainingBytes / this.lastDisplayedSpeed.bytesPerSecond
: Infinity;
// 更新跟踪状态
this.lastUpdateTime = now;
this.lastTransferredBytes = transferredBytes;
// 创建进度对象(使用稳定的进度值)
const progress: TransferProgress = {
totalBytes: this.totalBytes,
transferredBytes,
percentage: this.lastDisplayedPercentage,
speed: this.lastDisplayedSpeed,
remainingTime: {
seconds: remainingTimeSeconds,
display: formatTime(remainingTimeSeconds)
},
elapsedTime: {
seconds: elapsedTimeMs / 1000,
display: formatTime(elapsedTimeMs / 1000)
}
};
// 缓存进度信息
this.cachedProgress = progress;
return progress;
}
/**
* 重置跟踪器
*/
reset(totalBytes?: number, initialTransferredBytes: number = 0) {
if (totalBytes !== undefined) {
this.totalBytes = totalBytes;
}
this.startTime = Date.now();
this.lastUpdateTime = this.startTime;
this.lastSpeedUpdateTime = this.startTime;
this.lastProgressUpdateTime = this.startTime;
this.lastTransferredBytes = initialTransferredBytes;
this.speedHistory = [];
this.cachedProgress = null;
this.lastDisplayedSpeed = formatTransferSpeed(0);
this.lastDisplayedPercentage = 0;
}
/**
* 获取总字节数
*/
getTotalBytes(): number {
return this.totalBytes;
}
/**
* 获取平均速度(整个传输过程)
*/
getOverallAverageSpeed(): number {
const elapsedTimeMs = Date.now() - this.startTime;
if (elapsedTimeMs > 0) {
return (this.lastTransferredBytes * 1000) / elapsedTimeMs;
}
return 0;
}
}
/**
* 创建传输进度跟踪器
* @param totalBytes 总字节数
* @param initialTransferredBytes 初始已传输字节数
* @returns 传输进度跟踪器实例
*/
export function createTransferTracker(totalBytes: number, initialTransferredBytes: number = 0): TransferProgressTracker {
return new TransferProgressTracker(totalBytes, initialTransferredBytes);
}
/**
* 简单的传输速度计算(无状态)
* @param transferredBytes 已传输字节数
* @param elapsedTimeMs 经过的时间(毫秒)
* @returns 格式化的速度
*/
export function calculateSpeed(transferredBytes: number, elapsedTimeMs: number): TransferSpeed {
if (elapsedTimeMs <= 0) {
return formatTransferSpeed(0);
}
const bytesPerSecond = (transferredBytes * 1000) / elapsedTimeMs;
return formatTransferSpeed(bytesPerSecond);
}
/**
* 计算剩余时间
* @param remainingBytes 剩余字节数
* @param bytesPerSecond 每秒传输字节数
* @returns 格式化的剩余时间
*/
export function calculateRemainingTime(remainingBytes: number, bytesPerSecond: number): string {
if (bytesPerSecond <= 0 || remainingBytes <= 0) {
return '--:--';
}
const remainingSeconds = remainingBytes / bytesPerSecond;
return formatTime(remainingSeconds);
}

View File

@@ -0,0 +1,162 @@
/**
* 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) {
console.warn(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',
},
];
}

View File

@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
borderWidth: {
'3': '3px',
'4': '4px',
},
boxShadow: {
'cartoon-sm': '2px 2px 0 #000',
'cartoon': '4px 4px 0 #000',
'cartoon-md': '6px 6px 0 #000',
'cartoon-lg': '8px 8px 0 #000',
'cartoon-xl': '10px 10px 0 #000',
},
animation: {
'bounce-in': 'bounce-in 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
'wiggle': 'wiggle 0.5s ease-in-out infinite',
'float-cartoon': 'float-cartoon 4s ease-in-out infinite',
'rainbow': 'rainbow 5s ease infinite',
'gradient-shift': 'gradientShift 15s ease infinite',
},
fontFamily: {
'cartoon': ['"Comic Sans MS"', '"Chalkboard SE"', '"Comic Neue"', 'cursive'],
},
},
},
plugins: [],
}

View File

@@ -3530,3 +3530,8 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zustand@^5.0.7:
version "5.0.7"
resolved "https://registry.npmmirror.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==

178
cmd/config.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"strconv"
"strings"
)
// Config 应用配置结构
type Config struct {
Port int
FrontendDir string
TurnConfig TurnConfig
}
// TurnConfig TURN服务器配置
type TurnConfig struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Realm string `json:"realm"`
}
// loadEnvFile 加载环境变量文件
func loadEnvFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// 跳过空行和注释行
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// 解析 KEY=VALUE 格式
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// 移除值两端的引号
if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
(strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
value = value[1 : len(value)-1]
}
// 只有当环境变量不存在时才设置
if os.Getenv(key) == "" {
os.Setenv(key, value)
}
}
}
return scanner.Err()
}
// showHelp 显示帮助信息
func showHelp() {
fmt.Println("文件传输服务器")
fmt.Println("用法:")
fmt.Println(" 配置文件:")
fmt.Println(" .chuan.env - 自动加载的配置文件")
fmt.Println(" 环境变量:")
fmt.Println(" PORT=8080 - 服务器监听端口")
fmt.Println(" FRONTEND_DIR=/path - 外部前端文件目录 (可选)")
fmt.Println(" TURN_ENABLED=true - 启用TURN服务器")
fmt.Println(" TURN_PORT=3478 - TURN服务器端口")
fmt.Println(" TURN_USERNAME=user - TURN服务器用户名")
fmt.Println(" TURN_PASSWORD=pass - TURN服务器密码")
fmt.Println(" TURN_REALM=localhost - TURN服务器域")
fmt.Println(" 命令行参数:")
flag.PrintDefaults()
fmt.Println("")
fmt.Println("配置优先级: 命令行参数 > 环境变量 > 配置文件 > 默认值")
fmt.Println("")
fmt.Println("示例:")
fmt.Println(" ./file-transfer-server")
fmt.Println(" ./file-transfer-server -port 3000")
fmt.Println(" PORT=8080 FRONTEND_DIR=./dist ./file-transfer-server")
fmt.Println(" TURN_ENABLED=true TURN_PORT=3478 ./file-transfer-server")
}
// loadConfig 加载应用配置
func loadConfig() *Config {
// 首先尝试加载 .chuan.env 文件
if err := loadEnvFile(".chuan.env"); err == nil {
log.Printf("📄 已加载配置文件: .chuan.env")
}
// 从环境变量获取配置,如果没有则使用默认值
defaultPort := 8080
if envPort := os.Getenv("PORT"); envPort != "" {
if port, err := strconv.Atoi(envPort); err == nil {
defaultPort = port
}
}
// TURN 配置默认值
turnEnabled := os.Getenv("TURN_ENABLED") == "true"
turnPort := 3478
if envTurnPort := os.Getenv("TURN_PORT"); envTurnPort != "" {
if port, err := strconv.Atoi(envTurnPort); err == nil {
turnPort = port
}
}
turnUsername := os.Getenv("TURN_USERNAME")
if turnUsername == "" {
turnUsername = "chuan"
}
turnPassword := os.Getenv("TURN_PASSWORD")
if turnPassword == "" {
turnPassword = "chuan123"
}
turnRealm := os.Getenv("TURN_REALM")
if turnRealm == "" {
turnRealm = "localhost"
}
// 定义命令行参数
var port = flag.Int("port", defaultPort, "服务器监听端口 (可通过 PORT 环境变量设置)")
var help = flag.Bool("help", false, "显示帮助信息")
flag.Parse()
// 显示帮助信息
if *help {
showHelp()
os.Exit(0)
}
config := &Config{
Port: *port,
FrontendDir: os.Getenv("FRONTEND_DIR"),
TurnConfig: TurnConfig{
Enabled: turnEnabled,
Port: turnPort,
Username: turnUsername,
Password: turnPassword,
Realm: turnRealm,
},
}
return config
}
// logConfig 记录配置信息
func logConfig(config *Config) {
// 记录前端配置信息
if config.FrontendDir != "" {
if info, err := os.Stat(config.FrontendDir); err == nil && info.IsDir() {
log.Printf("✅ 使用外部前端目录: %s", config.FrontendDir)
} else {
log.Printf("⚠️ 外部前端目录不可用: %s, 回退到内嵌文件", config.FrontendDir)
}
} else {
log.Printf("📦 使用内嵌前端文件")
}
// 记录 TURN 配置信息
if config.TurnConfig.Enabled {
log.Printf("🔄 TURN服务器已启用")
log.Printf(" 端口: %d", config.TurnConfig.Port)
log.Printf(" 用户名: %s", config.TurnConfig.Username)
log.Printf(" 域: %s", config.TurnConfig.Realm)
} else {
log.Printf("❌ TURN服务器已禁用")
}
}

View File

@@ -1,103 +1,25 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"chuan/internal/handlers"
"chuan/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func main() {
// 定义命令行参数
var port = flag.Int("port", 8080, "服务器监听端口")
var help = flag.Bool("help", false, "显示帮助信息")
flag.Parse()
// 显示帮助信息
if *help {
fmt.Println("文件传输服务器")
fmt.Println("用法:")
flag.PrintDefaults()
os.Exit(0)
// 检查是否需要显示帮助
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
showHelp()
return
}
// 初始化处理器
h := handlers.NewHandler()
// 加载配置
config := loadConfig()
// 创建路由
r := chi.NewRouter()
// 记录配置信息
logConfig(config)
// 中间件
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
// 设置路由
routerSetup := setupRouter(config)
// CORS 配置
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// 嵌入式前端文件服务
r.Handle("/*", web.CreateFrontendHandler())
// WebRTC信令WebSocket路由
r.Get("/ws/webrtc", h.HandleWebRTCWebSocket)
// WebRTC房间API
r.Post("/api/create-room", h.CreateRoomHandler)
r.Get("/api/room-info", h.WebRTCRoomStatusHandler)
r.Get("/api/webrtc-room-status", h.WebRTCRoomStatusHandler)
// 构建服务器地址
addr := fmt.Sprintf(":%d", *port)
// 启动服务器
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 优雅关闭
go func() {
log.Printf("服务器启动在端口 %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务器启动失败: %v", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭服务器...")
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("服务器强制关闭:", err)
}
log.Println("服务器已退出")
// 运行服务器(包含启动和优雅关闭)
RunServer(config, routerSetup)
}

76
cmd/router.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"net/http"
"chuan/internal/handlers"
"chuan/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
// RouterSetup 路由设置结果
type RouterSetup struct {
Handler *handlers.Handler
Router http.Handler
}
// setupRouter 设置路由和中间件
func setupRouter(config *Config) *RouterSetup {
// 初始化处理器
h := handlers.NewHandler()
router := chi.NewRouter()
// 设置中间件
setupMiddleware(router)
// 设置API路由
setupAPIRoutes(router, h, config)
// 设置前端路由
router.Handle("/*", web.CreateFrontendHandler())
return &RouterSetup{
Handler: h,
Router: router,
}
}
// setupMiddleware 设置中间件
func setupMiddleware(r *chi.Mux) {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
// CORS 配置
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
}
// setupAPIRoutes 设置API路由
func setupAPIRoutes(r *chi.Mux, h *handlers.Handler, config *Config) {
// WebRTC信令WebSocket路由
r.Get("/api/ws/webrtc", h.HandleWebRTCWebSocket)
// WebRTC房间API
r.Post("/api/create-room", h.CreateRoomHandler)
r.Get("/api/room-info", h.WebRTCRoomStatusHandler)
// TURN服务器API仅在启用时可用
if config.TurnConfig.Enabled {
r.Get("/api/turn/stats", h.TurnStatsHandler)
r.Get("/api/turn/config", h.TurnConfigHandler)
}
// 管理API
r.Get("/api/admin/status", h.AdminStatusHandler)
}

111
cmd/server.go Normal file
View File

@@ -0,0 +1,111 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"chuan/internal/services"
)
// Server 服务器结构
type Server struct {
httpServer *http.Server
config *Config
turnService *services.TurnService
}
// NewServer 创建新的服务器实例
func NewServer(config *Config, routerSetup *RouterSetup) *Server {
server := &Server{
httpServer: &http.Server{
Addr: fmt.Sprintf(":%d", config.Port),
Handler: routerSetup.Router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
config: config,
}
// 如果启用了TURN服务器创建TURN服务实例
if config.TurnConfig.Enabled {
turnConfig := services.TurnServiceConfig{
Port: config.TurnConfig.Port,
Username: config.TurnConfig.Username,
Password: config.TurnConfig.Password,
Realm: config.TurnConfig.Realm,
}
server.turnService = services.NewTurnService(turnConfig)
// 将TURN服务设置到处理器中
routerSetup.Handler.SetTurnService(server.turnService)
}
return server
}
// Start 启动服务器
func (s *Server) Start() error {
// 启动TURN服务器如果启用
if s.turnService != nil {
if err := s.turnService.Start(); err != nil {
return fmt.Errorf("启动TURN服务器失败: %v", err)
}
}
log.Printf("🚀 服务器启动在端口 :%d", s.config.Port)
return s.httpServer.ListenAndServe()
}
// Stop 停止服务器
func (s *Server) Stop(ctx context.Context) error {
log.Println("🛑 正在关闭服务器...")
// 停止TURN服务器如果启用
if s.turnService != nil {
if err := s.turnService.Stop(); err != nil {
log.Printf("⚠️ 停止TURN服务器失败: %v", err)
}
}
return s.httpServer.Shutdown(ctx)
}
// WaitForShutdown 等待关闭信号并优雅关闭
func (s *Server) WaitForShutdown() {
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Fatal("❌ 服务器强制关闭:", err)
}
log.Println("✅ 服务器已退出")
}
// RunServer 运行服务器(包含启动和优雅关闭)
func RunServer(config *Config, routerSetup *RouterSetup) {
server := NewServer(config, routerSetup)
// 启动服务器
go func() {
if err := server.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("❌ 服务器启动失败: %v", err)
}
}()
// 等待关闭信号
server.WaitForShutdown()
}

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
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-go:latest
ports:
- "8080:8080"
restart: unless-stopped

13
go.mod
View File

@@ -5,6 +5,17 @@ go 1.21
require (
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/pion/turn/v3 v3.0.3
)
require (
github.com/pion/dtls/v2 v2.2.7 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v2 v2.0.0 // indirect
github.com/pion/transport/v2 v2.2.1 // indirect
github.com/pion/transport/v3 v3.0.2 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/sys v0.18.0 // indirect
)

91
go.sum
View File

@@ -1,8 +1,95 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4=
github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0=
github.com/pion/turn/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE=
github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -10,6 +10,7 @@ import (
type Handler struct {
webrtcService *services.WebRTCService
turnService *services.TurnService
}
func NewHandler() *Handler {
@@ -18,12 +19,17 @@ func NewHandler() *Handler {
}
}
// SetTurnService 设置TURN服务实例
func (h *Handler) SetTurnService(turnService *services.TurnService) {
h.turnService = turnService
}
// HandleWebRTCWebSocket 处理WebRTC信令WebSocket连接
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 +42,7 @@ func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
return
}
// 创建新房间
// 创建新房间(忽略请求体中的无用参数)
code := h.webrtcService.CreateNewRoom()
log.Printf("创建房间成功: %s", code)
@@ -105,3 +111,101 @@ func (h *Handler) GetRoomStatusHandler(w http.ResponseWriter, r *http.Request) {
status := h.webrtcService.GetRoomStatus(code)
json.NewEncoder(w).Encode(status)
}
// TurnStatsHandler 获取TURN服务器统计信息API
func (h *Handler) TurnStatsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
if h.turnService == nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "TURN服务器未启用",
})
return
}
stats := h.turnService.GetStats()
response := map[string]interface{}{
"success": true,
"data": stats,
}
json.NewEncoder(w).Encode(response)
}
// TurnConfigHandler 获取TURN服务器配置信息API用于前端WebRTC配置
func (h *Handler) TurnConfigHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
if h.turnService == nil || !h.turnService.IsRunning() {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "TURN服务器未启用或未运行",
})
return
}
turnInfo := h.turnService.GetTurnServerInfo()
response := map[string]interface{}{
"success": true,
"data": turnInfo,
}
json.NewEncoder(w).Encode(response)
}
// AdminStatusHandler 获取服务器总体状态API
func (h *Handler) AdminStatusHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
// 获取WebRTC服务状态
// 这里简化实际可以从WebRTC服务获取更多信息
webrtcStatus := map[string]interface{}{
"isRunning": true, // WebRTC服务总是运行的
}
// 获取TURN服务状态
var turnStatus interface{}
if h.turnService != nil {
turnStatus = h.turnService.GetStats()
} else {
turnStatus = map[string]interface{}{
"isRunning": false,
"message": "TURN服务器未启用",
}
}
response := map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"webrtc": webrtcStatus,
"turn": turnStatus,
},
}
json.NewEncoder(w).Encode(response)
}

View File

@@ -1,56 +0,0 @@
package models
import (
"time"
"github.com/gorilla/websocket"
)
// WebRTCOffer WebRTC offer 结构
type WebRTCOffer struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
// WebRTCAnswer WebRTC answer 结构
type WebRTCAnswer struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
// WebRTCICECandidate ICE candidate 结构
type WebRTCICECandidate struct {
Candidate string `json:"candidate"`
SDPMLineIndex int `json:"sdpMLineIndex"`
SDPMid string `json:"sdpMid"`
}
// VideoMessage 视频消息结构
type VideoMessage struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// ClientInfo 客户端连接信息
type ClientInfo struct {
ID string `json:"id"` // 客户端唯一标识
Role string `json:"role"` // sender 或 receiver
Connection *websocket.Conn `json:"-"` // WebSocket连接不序列化
JoinedAt time.Time `json:"joined_at"` // 加入时间
UserAgent string `json:"user_agent"` // 用户代理
}
// RoomStatus 房间状态信息
type RoomStatus struct {
Code string `json:"code"`
SenderOnline bool `json:"sender_online"`
ReceiverOnline bool `json:"receiver_online"`
CreatedAt time.Time `json:"created_at"`
}
// ErrorResponse 错误响应结构
type ErrorResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
}

View File

@@ -0,0 +1,234 @@
package services
import (
"fmt"
"log"
"net"
"sync"
"github.com/pion/turn/v3"
)
// TurnService TURN服务器结构
type TurnService struct {
server *turn.Server
config TurnServiceConfig
stats *TurnStats
isRunning bool
mu sync.RWMutex
}
// TurnServiceConfig TURN服务器配置
type TurnServiceConfig struct {
Port int
Username string
Password string
Realm string
}
// TurnStats TURN服务器统计信息
type TurnStats struct {
ActiveAllocations int64
TotalAllocations int64
BytesTransferred int64
PacketsTransferred int64
Connections int64
mu sync.RWMutex
}
// NewTurnService 创建新的TURN服务实例
func NewTurnService(config TurnServiceConfig) *TurnService {
return &TurnService{
config: config,
stats: &TurnStats{},
}
}
// Start 启动TURN服务器
func (ts *TurnService) Start() error {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.isRunning {
return fmt.Errorf("TURN服务器已在运行")
}
// 监听UDP端口
udpListener, err := net.ListenPacket("udp4", fmt.Sprintf("0.0.0.0:%d", ts.config.Port))
if err != nil {
return fmt.Errorf("无法监听UDP端口: %v", err)
}
// 监听TCP端口
tcpListener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", ts.config.Port))
if err != nil {
udpListener.Close()
return fmt.Errorf("无法监听TCP端口: %v", err)
}
// 创建TURN服务器配置
turnConfig := turn.ServerConfig{
Realm: ts.config.Realm,
AuthHandler: ts.authHandler,
PacketConnConfigs: []turn.PacketConnConfig{
{
PacketConn: udpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
RelayAddress: net.ParseIP("127.0.0.1"), // 在生产环境中应该使用公网IP
Address: "0.0.0.0",
},
},
},
ListenerConfigs: []turn.ListenerConfig{
{
Listener: tcpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
RelayAddress: net.ParseIP("127.0.0.1"), // 在生产环境中应该使用公网IP
Address: "0.0.0.0",
},
},
},
}
// 创建TURN服务器
server, err := turn.NewServer(turnConfig)
if err != nil {
udpListener.Close()
tcpListener.Close()
return fmt.Errorf("创建TURN服务器失败: %v", err)
}
ts.server = server
ts.isRunning = true
log.Printf("🔄 TURN服务器启动成功监听端口: %d", ts.config.Port)
log.Printf(" 用户名: %s, 域: %s", ts.config.Username, ts.config.Realm)
return nil
}
// Stop 停止TURN服务器
func (ts *TurnService) Stop() error {
ts.mu.Lock()
defer ts.mu.Unlock()
if !ts.isRunning {
return fmt.Errorf("TURN服务器未运行")
}
if ts.server != nil {
if err := ts.server.Close(); err != nil {
return fmt.Errorf("关闭TURN服务器失败: %v", err)
}
}
ts.isRunning = false
log.Printf("🛑 TURN服务器已停止")
return nil
}
// IsRunning 检查TURN服务器是否正在运行
func (ts *TurnService) IsRunning() bool {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.isRunning
}
// authHandler 认证处理器
func (ts *TurnService) authHandler(username string, realm string, srcAddr net.Addr) ([]byte, bool) {
// 记录连接统计
ts.stats.mu.Lock()
ts.stats.Connections++
ts.stats.mu.Unlock()
log.Printf("🔐 TURN认证请求: 用户=%s, 域=%s, 地址=%s", username, realm, srcAddr.String())
// 简单的用户名密码验证
if username == ts.config.Username && realm == ts.config.Realm {
// 记录分配统计
ts.stats.mu.Lock()
ts.stats.ActiveAllocations++
ts.stats.TotalAllocations++
ts.stats.mu.Unlock()
log.Printf("📊 TURN认证成功: 活跃分配=%d, 总分配=%d", ts.stats.ActiveAllocations, ts.stats.TotalAllocations)
// 返回密码的key
return turn.GenerateAuthKey(username, ts.config.Realm, ts.config.Password), true
}
log.Printf("❌ TURN认证失败: 用户=%s", username)
return nil, false
}
// GetStats 获取统计信息
func (ts *TurnService) GetStats() TurnStatsResponse {
ts.stats.mu.RLock()
defer ts.stats.mu.RUnlock()
return TurnStatsResponse{
IsRunning: ts.IsRunning(),
ActiveAllocations: ts.stats.ActiveAllocations,
TotalAllocations: ts.stats.TotalAllocations,
BytesTransferred: ts.stats.BytesTransferred,
PacketsTransferred: ts.stats.PacketsTransferred,
Connections: ts.stats.Connections,
Port: ts.config.Port,
Username: ts.config.Username,
Realm: ts.config.Realm,
}
}
// GetTurnServerInfo 获取TURN服务器信息用于客户端
func (ts *TurnService) GetTurnServerInfo() TurnServerInfo {
if !ts.IsRunning() {
return TurnServerInfo{}
}
return TurnServerInfo{
URLs: []string{fmt.Sprintf("turn:localhost:%d", ts.config.Port)},
Username: ts.config.Username,
Credential: ts.config.Password,
}
}
// UpdateStats 更新传输统计 (可以从外部调用)
func (ts *TurnService) UpdateStats(bytes, packets int64) {
ts.stats.mu.Lock()
defer ts.stats.mu.Unlock()
ts.stats.BytesTransferred += bytes
ts.stats.PacketsTransferred += packets
}
// DecrementActiveAllocations 减少活跃分配数(当连接关闭时调用)
func (ts *TurnService) DecrementActiveAllocations() {
ts.stats.mu.Lock()
defer ts.stats.mu.Unlock()
if ts.stats.ActiveAllocations > 0 {
ts.stats.ActiveAllocations--
log.Printf("📊 TURN分配释放: 活跃分配=%d", ts.stats.ActiveAllocations)
}
}
// TurnStatsResponse TURN统计响应结构
type TurnStatsResponse struct {
IsRunning bool `json:"isRunning"`
ActiveAllocations int64 `json:"activeAllocations"`
TotalAllocations int64 `json:"totalAllocations"`
BytesTransferred int64 `json:"bytesTransferred"`
PacketsTransferred int64 `json:"packetsTransferred"`
Connections int64 `json:"connections"`
Port int `json:"port"`
Username string `json:"username"`
Realm string `json:"realm"`
}
// TurnServerInfo TURN服务器信息结构 (用于WebRTC配置)
type TurnServerInfo struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}

View File

@@ -1,6 +1,7 @@
package services
import (
"encoding/json"
"fmt"
"log"
"math/rand"
@@ -23,7 +24,6 @@ type WebRTCRoom struct {
Receiver *WebRTCClient
CreatedAt time.Time
ExpiresAt time.Time // 添加过期时间
LastOffer *WebRTCMessage // 保存最后的offer消息
}
type WebRTCClient struct {
@@ -76,6 +76,48 @@ 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
}
// 检查房间是否已满(两个连接都已存在)
ws.roomsMux.RLock()
isRoomFull := room.Sender != nil && room.Receiver != nil
ws.roomsMux.RUnlock()
if isRoomFull {
log.Printf("房间已满,拒绝连接: %s", code)
conn.WriteJSON(map[string]interface{}{
"type": "error",
"message": "当前房间人数已满,正在传输中无法加入",
})
return
}
@@ -105,18 +147,36 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
// 处理消息
for {
var msg WebRTCMessage
err := conn.ReadJSON(&msg)
// 首先读取原始消息类型和数据
messageType, data, err := conn.ReadMessage()
if err != nil {
log.Printf("读取WebRTC WebSocket消息失败: %v", err)
break
}
msg.From = clientID
log.Printf("收到WebRTC信令: 类型=%s, 来自=%s, 房间=%s", msg.Type, clientID, code)
if messageType == websocket.TextMessage {
// 文本消息尝试解析为JSON
var msg WebRTCMessage
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("解析WebRTC JSON消息失败: %v", err)
continue
}
// 转发信令消息给对方
ws.forwardMessage(code, clientID, &msg)
msg.From = clientID
log.Printf("收到WebRTC信令: 类型=%s, 来自=%s, 房间=%s", msg.Type, clientID, code)
// 转发信令消息给对方
ws.forwardMessage(code, clientID, &msg)
} else if messageType == websocket.BinaryMessage {
// 二进制消息,直接转发
log.Printf("收到WebRTC二进制数据: 大小=%d bytes, 来自=%s, 房间=%s", len(data), clientID, code)
// 转发二进制数据给对方
ws.forwardBinaryMessage(code, clientID, data)
} else {
log.Printf("收到未知消息类型: %d", messageType)
}
}
}
@@ -127,13 +187,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" {
@@ -164,15 +219,6 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
}
room.Sender.Connection.WriteJSON(peerJoinedMsg)
}
// 如果接收方连接且有保存的offer立即发送给接收方
if room.LastOffer != nil {
log.Printf("向新连接的接收方发送保存的offer")
err := client.Connection.WriteJSON(room.LastOffer)
if err != nil {
log.Printf("发送保存的offer失败: %v", err)
}
}
}
}
@@ -210,12 +256,6 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
return
}
// 如果是offer消息保存起来
if msg.Type == "offer" {
room.LastOffer = msg
log.Printf("保存offer消息等待接收方连接")
}
var targetClient *WebRTCClient
if room.Sender != nil && room.Sender.ID == fromClientID {
// 消息来自sender转发给receiver
@@ -238,6 +278,37 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
}
}
// 转发二进制消息
func (ws *WebRTCService) forwardBinaryMessage(roomCode string, fromClientID string, data []byte) {
ws.roomsMux.Lock()
defer ws.roomsMux.Unlock()
room := ws.rooms[roomCode]
if room == nil {
return
}
var targetClient *WebRTCClient
if room.Sender != nil && room.Sender.ID == fromClientID {
// 消息来自sender转发给receiver
targetClient = room.Receiver
} else if room.Receiver != nil && room.Receiver.ID == fromClientID {
// 消息来自receiver转发给sender
targetClient = room.Sender
}
if targetClient != nil && targetClient.Connection != nil {
err := targetClient.Connection.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
log.Printf("转发WebRTC二进制数据失败: %v", err)
} else {
log.Printf("转发WebRTC二进制数据: 大小=%d bytes, 从=%s到=%s", len(data), fromClientID, targetClient.ID)
}
} else {
log.Printf("目标客户端不在线,无法转发二进制数据")
}
}
// CreateRoom 创建或获取房间
func (ws *WebRTCService) CreateRoom(code string) {
ws.roomsMux.Lock()
@@ -253,19 +324,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 定期清理过期房间
@@ -331,6 +422,7 @@ func (ws *WebRTCService) notifyRoomDisconnection(roomCode string, disconnectedCl
}
}
}
func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
ws.roomsMux.RLock()
defer ws.roomsMux.RUnlock()
@@ -344,11 +436,15 @@ func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
}
}
// 检查房间是否已满(两个连接都已存在)
isRoomFull := room.Sender != nil && room.Receiver != nil
return map[string]interface{}{
"success": true,
"exists": true,
"sender_online": room.Sender != nil,
"receiver_online": room.Receiver != nil,
"is_room_full": isRoomFull,
"created_at": room.CreatedAt,
}
}

View File

@@ -5,7 +5,9 @@ import (
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
@@ -25,6 +27,15 @@ func hasFrontendFiles() bool {
// CreateFrontendHandler 创建前端文件处理器
func CreateFrontendHandler() http.Handler {
// 检查是否配置了外部前端目录
if frontendDir := os.Getenv("FRONTEND_DIR"); frontendDir != "" {
if info, err := os.Stat(frontendDir); err == nil && info.IsDir() {
// 使用外部前端目录
return &externalSpaHandler{baseDir: frontendDir}
}
}
// 使用内嵌的前端文件
if !hasFrontendFiles() {
return &placeholderHandler{}
}
@@ -59,6 +70,7 @@ func (h *placeholderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
pre { margin: 0; overflow-x: auto; }
.api-list { margin: 20px 0; }
.api-item { margin: 10px 0; padding: 10px; background: #e3f2fd; border-radius: 4px; }
.env-config { background: #e8f5e8; padding: 15px; border-radius: 4px; margin: 20px 0; }
</style>
</head>
<body>
@@ -69,11 +81,21 @@ func (h *placeholderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
⚠️ 前端界面未构建,当前显示的是后端 API 服务。
</div>
<h2>📋 可用的 API 接口</h2>
<h2><EFBFBD> 环境变量配置</h2>
<div class="env-config">
<strong>FRONTEND_DIR</strong> - 指定外部前端文件目录<br>
<strong>PORT</strong> - 自定义服务端口 (默认: 8080)<br><br>
<strong>示例:</strong><br>
<pre>export FRONTEND_DIR=/path/to/frontend
export PORT=3000
./file-transfer-server</pre>
</div>
<h2><3E>📋 可用的 API 接口</h2>
<div class="api-list">
<div class="api-item"><strong>POST</strong> /api/create-text-room - 创建文本传输房间</div>
<div class="api-item"><strong>GET</strong> /api/get-text-content/* - 获取文本内容</div>
<div class="api-item"><strong>WebSocket</strong> /ws/webrtc - WebRTC 信令连接</div>
<div class="api-item"><strong>POST</strong> /api/create-room - 创建WebRTC房间</div>
<div class="api-item"><strong>GET</strong> /api/room-info - 获取房间信息</div>
<div class="api-item"><strong>WebSocket</strong> /api/ws/webrtc - WebRTC 信令连接</div>
</div>
<h2>🛠️ 构建前端</h2>
@@ -82,14 +104,18 @@ func (h *placeholderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cd chuan-next
# 安装依赖
yarn install
npm install
# 构建静态文件
yarn build:ssg
npm run build
# 重新构建 Go 项目以嵌入前端文件
# 方法1: 重新构建 Go 项目以嵌入前端文件
cd ..
go build -o file-transfer-server ./cmd</pre>
go build -o file-transfer-server ./cmd
# 方法2: 使用外部前端目录
export FRONTEND_DIR=./chuan-next/out
./file-transfer-server</pre>
</div>
<p><strong>提示:</strong> 构建完成后刷新页面即可看到完整的前端界面。</p>
@@ -99,6 +125,61 @@ go build -o file-transfer-server ./cmd</pre>
`))
}
// externalSpaHandler 外部文件目录处理器
type externalSpaHandler struct {
baseDir string
}
func (h *externalSpaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 清理路径
upath := strings.TrimPrefix(r.URL.Path, "/")
if upath == "" {
upath = "index.html"
}
// 构建完整文件路径
fullPath := filepath.Join(h.baseDir, upath)
// 安全检查:确保文件在基础目录内
absBasePath, err := filepath.Abs(h.baseDir)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
absFullPath, err := filepath.Abs(fullPath)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !strings.HasPrefix(absFullPath, absBasePath) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// 检查文件是否存在
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// 文件不存在,对于 SPA 应用返回 index.html
h.serveIndexHTML(w, r)
return
}
// 服务文件
http.ServeFile(w, r, fullPath)
}
// serveIndexHTML 服务外部目录的 index.html 文件
func (h *externalSpaHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) {
indexPath := filepath.Join(h.baseDir, "index.html")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, indexPath)
}
// spaHandler SPA 应用处理器
type spaHandler struct {
fs fs.FS

View File

@@ -0,0 +1,3 @@
# Static Files Directory
This directory contains static files that will be embedded into the Go binary.

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More