mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-13 00:24:44 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
055deea67a | ||
|
|
0fd8899fc6 | ||
|
|
4bf0ce447d | ||
|
|
bc01224c11 | ||
|
|
63e6e956e4 | ||
|
|
301434fd4c | ||
|
|
fbb5135eed | ||
|
|
0caeaf62c4 | ||
|
|
6b69d35a20 | ||
|
|
75825e1104 | ||
|
|
720f808ed6 | ||
|
|
2abf7bdf42 |
91
.dockerignore
Normal file
91
.dockerignore
Normal file
@@ -0,0 +1,91 @@
|
||||
# ==============================================
|
||||
# Docker 忽略文件
|
||||
# 优化构建上下文,减少构建时间
|
||||
# ==============================================
|
||||
|
||||
# Git 相关
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 文档
|
||||
README.md
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# 开发配置
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
.envrc
|
||||
|
||||
# IDE 和编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
.yarn-integrity
|
||||
|
||||
# Next.js
|
||||
chuan-next/.next/
|
||||
chuan-next/out/
|
||||
chuan-next/.env*
|
||||
chuan-next/build.log
|
||||
|
||||
# Go 相关
|
||||
# *.sum # 注释掉这行,因为需要 go.sum 文件
|
||||
vendor/
|
||||
|
||||
# 构建输出
|
||||
dist/
|
||||
build/
|
||||
*.exe
|
||||
*.exe~
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
.tmp
|
||||
|
||||
# OS 生成文件
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker 相关
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
.circleci/
|
||||
|
||||
# 测试
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.coverage
|
||||
|
||||
# 其他构建工具
|
||||
.sass-cache/
|
||||
.cache/
|
||||
129
.github/workflows/docker-publish.yml
vendored
Normal file
129
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: 🐳 Build and Push Docker Image
|
||||
|
||||
on:
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.0.5)'
|
||||
required: true
|
||||
default: 'v1.0.5'
|
||||
type: string
|
||||
push_to_hub:
|
||||
description: '推送到 Docker Hub'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
build_multiarch:
|
||||
description: '构建多架构镜像 (amd64 + arm64)'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# 推送标签时触发
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
# PR 时构建测试(不推送)
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: matrixseven/file-transfer-go
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 🏗️ Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🏷️ Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: 🔑 Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: 🐳 Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ (github.event_name == 'workflow_dispatch' && inputs.build_multiarch == true) || github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ github.event_name != 'pull_request' && (inputs.push_to_hub != false) }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: 📊 Image digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: 🎉 Build Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Registry**: ${{ env.REGISTRY }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tags**: ${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Platforms**: ${{ (github.event_name == 'workflow_dispatch' && inputs.build_multiarch == true) || github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🚀 Usage" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```bash' >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker run -d -p 8080:8080 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version || 'latest' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
security-scan:
|
||||
name: 🔍 Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔍 Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: 📤 Upload Trivy scan results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
65
Dockerfile
Normal file
65
Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# ==============================================
|
||||
# 最简 Dockerfile - 专为 Docker 环境优化
|
||||
# ==============================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# 国内镜像源优化
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 安装必要工具
|
||||
RUN apk add --no-cache bash git curl wget make ca-certificates tzdata
|
||||
|
||||
# 安装 Go
|
||||
ENV GO_VERSION=1.21.5
|
||||
RUN wget https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-amd64.tar.gz && \
|
||||
tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz && \
|
||||
rm go${GO_VERSION}.linux-amd64.tar.gz
|
||||
|
||||
# Go 环境
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Go 依赖
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# 前端依赖和构建
|
||||
COPY chuan-next/package.json ./chuan-next/
|
||||
RUN cd chuan-next && npm install
|
||||
|
||||
COPY chuan-next/ ./chuan-next/
|
||||
# 临时移除 API 目录进行 SSG 构建(模仿 build-fullstack.sh)
|
||||
RUN cd chuan-next && \
|
||||
if [ -d "src/app/api" ]; then mv src/app/api /tmp/api-backup; fi && \
|
||||
NEXT_EXPORT=true npm run build && \
|
||||
if [ -d "/tmp/api-backup" ]; then mv /tmp/api-backup src/app/api; fi
|
||||
|
||||
# Go 源码和构建
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
|
||||
# 嵌入前端文件
|
||||
RUN mkdir -p internal/web/frontend && \
|
||||
cp -r chuan-next/out/* internal/web/frontend/
|
||||
|
||||
# 构建 Go 应用
|
||||
RUN CGO_ENABLED=0 go build -ldflags='-w -s' -o server ./cmd
|
||||
|
||||
# ==============================================
|
||||
|
||||
FROM alpine:3.18
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||
apk add --no-cache ca-certificates tzdata && \
|
||||
adduser -D -s /bin/sh appuser
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=appuser:appuser /app/server ./
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
141
README.md
141
README.md
@@ -1,29 +1,99 @@
|
||||
# 文件快传 - P2P文件传输工具
|
||||
|
||||
|
||||
### 在线体验 https://transfer.52python.cn
|
||||
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
|
||||
|
||||
## [在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
|
||||
|
||||

|
||||
|
||||
> 安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用
|
||||
|
||||
|
||||
## ✨ 核心功能
|
||||
## ✨ 核心功能[端到端数据传输完全基于WebRTC的P2P直连]
|
||||
<div align="center">
|
||||
|
||||
- 📁 **文件传输** - 支持多文件同时传输,基于WebRTC的P2P直连
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
- 📁 **文件传输** - 支持多文件同时传输
|
||||
- 📝 **文字传输** - 快速分享文本内容
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享
|
||||
- 🔗 **连接状态同步** - 实时连接状态UI同步
|
||||
- 🔒 **端到端加密** - 数据传输安全,服务器不存储文件
|
||||
- 📱 **响应式设计** - 完美适配手机、平板、电脑
|
||||
- 🖥️ **多平台支持** - 支持linux/macos/win 单文件部署
|
||||
|
||||
## 🔄 最近更新日志
|
||||
|
||||
### 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:latest
|
||||
```
|
||||
|
||||
### 方式二:本地构建部署
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
@@ -33,13 +103,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. 点击共享桌面 → 生成取件码 → 对方输入码观看
|
||||
|
||||
## 📊 项目架构
|
||||
|
||||
@@ -65,4 +176,10 @@ MIT License
|
||||
|
||||
---
|
||||
|
||||
⭐ 觉得有用请给个星标!
|
||||
<div align="center">
|
||||
|
||||
⭐ 如果觉得这个项目对你有帮助,请给个星标!
|
||||
|
||||
[]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,46 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Upload, MessageSquare, Monitor, TestTube } from 'lucide-react';
|
||||
import { Upload, MessageSquare, Monitor, Users } from 'lucide-react';
|
||||
import Hero from '@/components/Hero';
|
||||
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
|
||||
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
|
||||
import DesktopShare from '@/components/DesktopShare';
|
||||
import WeChatGroup from '@/components/WeChatGroup';
|
||||
import { WebRTCUnsupportedModal } from '@/components/WebRTCUnsupportedModal';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useWebRTCSupport } from '@/hooks/connection';
|
||||
import { useTabNavigation, TabType } from '@/hooks/ui';
|
||||
|
||||
export default function HomePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('webrtc');
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
// 使用tab导航hook
|
||||
const {
|
||||
activeTab,
|
||||
handleTabChange,
|
||||
getConnectionInfo,
|
||||
hasInitialized,
|
||||
confirmDialogState,
|
||||
closeConfirmDialog
|
||||
} = useTabNavigation();
|
||||
|
||||
// 根据URL参数设置初始标签(仅首次加载时)
|
||||
useEffect(() => {
|
||||
if (!hasInitialized) {
|
||||
const urlType = searchParams.get('type');
|
||||
|
||||
console.log('=== HomePage URL处理 ===');
|
||||
console.log('URL type参数:', urlType);
|
||||
console.log('所有搜索参数:', Object.fromEntries(searchParams.entries()));
|
||||
|
||||
// 将旧的text类型重定向到message
|
||||
if (urlType === 'text') {
|
||||
console.log('检测到text类型,重定向到message标签页');
|
||||
setActiveTab('message');
|
||||
} else if (urlType === 'webrtc') {
|
||||
// webrtc类型对应文件传输标签页
|
||||
console.log('检测到webrtc类型,切换到webrtc标签页(文件传输)');
|
||||
setActiveTab('webrtc');
|
||||
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
|
||||
console.log('切换到对应标签页:', urlType);
|
||||
setActiveTab(urlType);
|
||||
} else {
|
||||
console.log('没有有效的type参数,使用默认标签页:webrtc(文件传输)');
|
||||
}
|
||||
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [searchParams, hasInitialized]);
|
||||
// WebRTC 支持检测
|
||||
const {
|
||||
webrtcSupport,
|
||||
isSupported,
|
||||
isChecked,
|
||||
showUnsupportedModal,
|
||||
closeUnsupportedModal,
|
||||
showUnsupportedModalManually,
|
||||
} = useWebRTCSupport();
|
||||
|
||||
// 桌面共享功能的占位符函数(保持向后兼容)
|
||||
const handleStartSharing = async () => {
|
||||
console.log('开始桌面共享');
|
||||
};
|
||||
|
||||
const handleStopSharing = async () => {
|
||||
console.log('停止桌面共享');
|
||||
};
|
||||
|
||||
const handleJoinSharing = async (code: string) => {
|
||||
console.log('加入桌面共享:', code);
|
||||
};
|
||||
|
||||
// 处理Tabs组件的字符串参数
|
||||
const handleTabChangeWrapper = (value: string) => {
|
||||
// 类型转换并调用实际的处理函数
|
||||
handleTabChange(value as TabType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
@@ -50,56 +61,137 @@ export default function HomePage() {
|
||||
<Hero />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
{/* Tabs Navigation - 横向布局 */}
|
||||
<div className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsTrigger
|
||||
value="webrtc"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文件传输</span>
|
||||
<span className="sm:hidden">文件</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="message"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文本消息</span>
|
||||
<span className="sm:hidden">消息</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="desktop"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">共享桌面</span>
|
||||
<span className="sm:hidden">桌面</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* WebRTC 支持检测加载状态 */}
|
||||
{!isChecked && (
|
||||
<div className="max-w-4xl mx-auto text-center py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-48 mx-auto mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600">正在检测浏览器支持...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCFileTransfer />
|
||||
</TabsContent>
|
||||
{/* 主要内容 - 只有在检测完成后才显示 */}
|
||||
{isChecked && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* WebRTC 不支持时的警告横幅 */}
|
||||
{!isSupported && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-red-700 font-medium">
|
||||
当前浏览器不支持 WebRTC,功能可能无法正常使用
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={showUnsupportedModalManually}
|
||||
className="text-red-600 hover:text-red-800 text-sm underline"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="message" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCTextImageTransfer />
|
||||
</TabsContent>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChangeWrapper} className="w-full">
|
||||
{/* Tabs Navigation - 横向布局 */}
|
||||
<div className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-4 max-w-2xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsTrigger
|
||||
value="webrtc"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
disabled={!isSupported}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文件传输</span>
|
||||
<span className="sm:hidden">文件</span>
|
||||
{!isSupported && <span className="text-xs opacity-60">*</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="message"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
|
||||
disabled={!isSupported}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文本消息</span>
|
||||
<span className="sm:hidden">消息</span>
|
||||
{!isSupported && <span className="text-xs opacity-60">*</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="desktop"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600"
|
||||
disabled={!isSupported}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">共享桌面</span>
|
||||
<span className="sm:hidden">桌面</span>
|
||||
{!isSupported && <span className="text-xs opacity-60">*</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="wechat"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-green-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-green-600"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">微信群</span>
|
||||
<span className="sm:hidden">微信</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* WebRTC 不支持时的提示 */}
|
||||
{!isSupported && (
|
||||
<p className="text-center text-xs text-gray-500 mt-2">
|
||||
* 需要 WebRTC 支持才能使用
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
<DesktopShare />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCFileTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="message" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCTextImageTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
<DesktopShare />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wechat" className="mt-0 animate-fade-in-up">
|
||||
<WeChatGroup />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WebRTC 不支持提示模态框 */}
|
||||
{webrtcSupport && (
|
||||
<WebRTCUnsupportedModal
|
||||
isOpen={showUnsupportedModal}
|
||||
onClose={closeUnsupportedModal}
|
||||
webrtcSupport={webrtcSupport}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 自定义确认对话框 */}
|
||||
{confirmDialogState && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialogState.isOpen}
|
||||
onClose={closeConfirmDialog}
|
||||
onConfirm={confirmDialogState.onConfirm}
|
||||
title={confirmDialogState.title}
|
||||
message={confirmDialogState.message}
|
||||
confirmText={confirmDialogState.confirmText}
|
||||
cancelText={confirmDialogState.cancelText}
|
||||
type={confirmDialogState.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
263
chuan-next/src/components/ConnectionStatus.tsx
Normal file
263
chuan-next/src/components/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWebRTCStore } from '@/hooks/index';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
// 房间信息 - 只需要这个基本信息
|
||||
currentRoom?: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
// 样式类名
|
||||
className?: string;
|
||||
// 紧凑模式
|
||||
compact?: boolean;
|
||||
// 内联模式 - 只返回状态文本,不包含UI结构
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// 连接状态枚举
|
||||
const getConnectionStatus = (connection: any, currentRoom: any) => {
|
||||
const isWebSocketConnected = connection?.isWebSocketConnected || false;
|
||||
const isPeerConnected = connection?.isPeerConnected || false;
|
||||
const isConnecting = connection?.isConnecting || false;
|
||||
const error = connection?.error || null;
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
message: '连接失败',
|
||||
detail: error,
|
||||
};
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return {
|
||||
type: 'connecting' as const,
|
||||
message: '正在连接',
|
||||
detail: '建立房间连接中...',
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentRoom) {
|
||||
return {
|
||||
type: 'disconnected' as const,
|
||||
message: '未连接',
|
||||
detail: '尚未创建房间',
|
||||
};
|
||||
}
|
||||
|
||||
// 如果有房间信息但WebSocket未连接,且不是正在连接状态
|
||||
// 可能是状态更新的时序问题,显示连接中状态
|
||||
if (!isWebSocketConnected && !isConnecting) {
|
||||
return {
|
||||
type: 'connecting' as const,
|
||||
message: '连接中',
|
||||
detail: '正在建立WebSocket连接...',
|
||||
};
|
||||
}
|
||||
|
||||
if (isWebSocketConnected && !isPeerConnected) {
|
||||
return {
|
||||
type: 'room-ready' as const,
|
||||
message: '房间已创建',
|
||||
detail: '等待对方加入并建立P2P连接...',
|
||||
};
|
||||
}
|
||||
|
||||
if (isWebSocketConnected && isPeerConnected) {
|
||||
return {
|
||||
type: 'connected' as const,
|
||||
message: 'P2P连接成功',
|
||||
detail: '可以开始传输',
|
||||
};
|
||||
}
|
||||
|
||||
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 '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 '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: any) => {
|
||||
const isWebSocketConnected = connection?.isWebSocketConnected || false;
|
||||
const isPeerConnected = connection?.isPeerConnected || false;
|
||||
const isConnecting = connection?.isConnecting || false;
|
||||
const error = connection?.error || null;
|
||||
|
||||
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连接成功`;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// 如果是内联模式,只返回状态文字
|
||||
if (inline) {
|
||||
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
|
||||
}
|
||||
|
||||
const status = getConnectionStatus(connection, currentRoom);
|
||||
|
||||
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>
|
||||
|
||||
{/* 错误信息
|
||||
{connection.error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 rounded p-2">
|
||||
{connection.error}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简化版本的 Hook,用于快速集成 - 现在已经不需要了,但保留兼容性
|
||||
export function useConnectionStatus(webrtcConnection?: any) {
|
||||
// 这个hook现在不再需要,因为ConnectionStatus组件直接使用底层连接
|
||||
// 但为了向后兼容,保留这个接口
|
||||
return useMemo(() => ({
|
||||
isWebSocketConnected: webrtcConnection?.isWebSocketConnected || false,
|
||||
isPeerConnected: webrtcConnection?.isPeerConnected || false,
|
||||
isConnecting: webrtcConnection?.isConnecting || false,
|
||||
currentRoom: webrtcConnection?.currentRoom || null,
|
||||
error: webrtcConnection?.error || null,
|
||||
}), [
|
||||
webrtcConnection?.isWebSocketConnected,
|
||||
webrtcConnection?.isPeerConnected,
|
||||
webrtcConnection?.isConnecting,
|
||||
webrtcConnection?.currentRoom,
|
||||
webrtcConnection?.error,
|
||||
]);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useURLHandler } from '@/hooks/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { 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 } from 'lucide-react';
|
||||
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
|
||||
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
|
||||
import { useWebRTCStore } from '@/hooks/index';
|
||||
|
||||
|
||||
interface DesktopShareProps {
|
||||
// 保留向后兼容性的props
|
||||
// 保留向后兼容性的props(已废弃,但保留接口)
|
||||
onStartSharing?: () => Promise<string>;
|
||||
onStopSharing?: () => Promise<void>;
|
||||
onJoinSharing?: (code: string) => Promise<void>;
|
||||
@@ -22,175 +21,34 @@ export default function DesktopShare({
|
||||
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();
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
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');
|
||||
}
|
||||
// 使用统一的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');
|
||||
}
|
||||
}, [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 handleConnectionChange = useCallback((connection: any) => {
|
||||
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
|
||||
console.log('桌面共享连接状态变化:', connection);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
@@ -216,423 +74,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X } from 'lucide-react';
|
||||
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DesktopViewerProps {
|
||||
@@ -22,6 +22,9 @@ export default function DesktopViewer({
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
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;
|
||||
@@ -39,9 +42,69 @@ export default function DesktopViewer({
|
||||
|
||||
videoRef.current.srcObject = stream;
|
||||
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);
|
||||
|
||||
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]);
|
||||
|
||||
@@ -176,6 +239,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 () => {
|
||||
@@ -223,6 +301,19 @@ export default function DesktopViewer({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 需要用户交互的播放覆盖层 - 只在自动播放尝试失败后显示 */}
|
||||
{hasAttemptedAutoplayRef.current && needsUserInteraction && !isPlaying && (
|
||||
<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">
|
||||
@@ -244,8 +335,8 @@ export default function DesktopViewer({
|
||||
{/* 左侧信息 */}
|
||||
<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>{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : '连接中'}</span>
|
||||
</div>
|
||||
{videoStats.resolution !== '0x0' && (
|
||||
<>
|
||||
|
||||
123
chuan-next/src/components/RoomInfoDisplay.tsx
Normal file
123
chuan-next/src/components/RoomInfoDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
chuan-next/src/components/WeChatGroup.tsx
Normal file
68
chuan-next/src/components/WeChatGroup.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
export default function WeChatGroup() {
|
||||
return (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-6 sm:p-8 animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
{/* 标题 */}
|
||||
<div className="mb-6">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-2">加入微信交流群</h2>
|
||||
<p className="text-slate-600 text-lg">
|
||||
佬们有意见/建议/bug反馈或者奇思妙想想来交流,可以扫码加入
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 二维码区域 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
|
||||
{/* 微信群二维码 - 请将此区域替换为实际的二维码图片 */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src="https://cdn-img.luxika.cc//i/2025/08/25/68abd75c363a6.png"
|
||||
alt="微信群二维码"
|
||||
className="w-64 h-64 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 说明文字 */}
|
||||
<div className="bg-green-50 rounded-xl p-6 border border-green-200">
|
||||
<div className="text-sm text-green-700 space-y-2">
|
||||
<p className="text-base font-semibold text-green-800 mb-3">🎉 欢迎加入我们的交流群!</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-left">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>💬</span>
|
||||
<span>分享使用心得和建议</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>🐛</span>
|
||||
<span>反馈问题和bug</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>💡</span>
|
||||
<span>提出新功能想法</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>🤝</span>
|
||||
<span>与其他用户交流技术</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 额外信息 */}
|
||||
<div className="mt-4 text-xs text-slate-500">
|
||||
<p>群内禁止广告和无关内容,专注技术交流</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
chuan-next/src/components/WebRTCConnectionStatus.tsx
Normal file
185
chuan-next/src/components/WebRTCConnectionStatus.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, Wifi, WifiOff, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { WebRTCConnection } from '@/hooks/connection/useSharedWebRTCManager';
|
||||
|
||||
interface Props {
|
||||
webrtc: WebRTCConnection;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC连接状态显示组件
|
||||
* 显示详细的连接状态、错误信息和重试按钮
|
||||
*/
|
||||
export function WebRTCConnectionStatus({ webrtc, className = '' }: Props) {
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isWebSocketConnected,
|
||||
isPeerConnected,
|
||||
error,
|
||||
canRetry,
|
||||
retry
|
||||
} = webrtc;
|
||||
|
||||
// 状态图标
|
||||
const getStatusIcon = () => {
|
||||
if (isConnecting) {
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// 区分信息提示和错误
|
||||
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
|
||||
return <WifiOff className="h-4 w-4 text-yellow-500" />;
|
||||
}
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
|
||||
if (isPeerConnected) {
|
||||
return <Wifi className="h-4 w-4 text-green-500" />;
|
||||
}
|
||||
|
||||
if (isWebSocketConnected) {
|
||||
return <Wifi className="h-4 w-4 text-yellow-500" />;
|
||||
}
|
||||
|
||||
return <WifiOff className="h-4 w-4 text-gray-400" />;
|
||||
};
|
||||
|
||||
// 状态文本
|
||||
const getStatusText = () => {
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return '正在连接...';
|
||||
}
|
||||
|
||||
if (isPeerConnected) {
|
||||
return 'P2P连接已建立';
|
||||
}
|
||||
|
||||
if (isWebSocketConnected) {
|
||||
return '信令服务器已连接';
|
||||
}
|
||||
|
||||
return '未连接';
|
||||
};
|
||||
|
||||
// 状态颜色
|
||||
const getStatusColor = () => {
|
||||
if (error) {
|
||||
// 区分信息提示和错误
|
||||
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
|
||||
return 'text-yellow-600';
|
||||
}
|
||||
return 'text-red-600';
|
||||
}
|
||||
if (isConnecting) return 'text-blue-600';
|
||||
if (isPeerConnected) return 'text-green-600';
|
||||
if (isWebSocketConnected) return 'text-yellow-600';
|
||||
return 'text-gray-600';
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
try {
|
||||
await retry();
|
||||
} catch (error) {
|
||||
console.error('重试连接失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between p-3 bg-white border rounded-lg ${className}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<span className={`text-sm font-medium ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 连接详细状态指示器 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* WebSocket状态 */}
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-400' : 'bg-gray-300'
|
||||
}`}
|
||||
title={isWebSocketConnected ? 'WebSocket已连接' : 'WebSocket未连接'}
|
||||
/>
|
||||
|
||||
{/* P2P状态 */}
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isPeerConnected ? 'bg-green-400' : 'bg-gray-300'
|
||||
}`}
|
||||
title={isPeerConnected ? 'P2P连接已建立' : 'P2P连接未建立'}
|
||||
/>
|
||||
|
||||
{/* 重试按钮 */}
|
||||
{canRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isConnecting}
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="重试连接"
|
||||
>
|
||||
<RotateCcw className={`h-3 w-3 ${isConnecting ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的连接状态指示器(用于空间受限的地方)
|
||||
*/
|
||||
export function WebRTCStatusIndicator({ webrtc, className = '' }: Props) {
|
||||
const { isPeerConnected, isConnecting, error } = webrtc;
|
||||
|
||||
if (error) {
|
||||
// 区分信息提示和错误
|
||||
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<div className="w-2 h-2 bg-yellow-400 rounded-full" />
|
||||
<span className="text-xs text-yellow-600">对方已离开</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<div className="w-2 h-2 bg-red-400 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-red-600">连接错误</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-blue-600">连接中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPeerConnected) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full" />
|
||||
<span className="text-xs text-green-600">已连接</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<div className="w-2 h-2 bg-gray-300 rounded-full" />
|
||||
<span className="text-xs text-gray-600">未连接</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"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 React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSharedWebRTCManager, useConnectionState, useRoomConnection } from '@/hooks/connection';
|
||||
import { useFileTransferBusiness, useFileListSync, useFileStateManager } from '@/hooks/file-transfer';
|
||||
import { useURLHandler } from '@/hooks/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { Upload, Download } from 'lucide-react';
|
||||
@@ -20,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 stableConnection = useMemo(() => connection, [connection.isConnected, connection.isConnecting, connection.isWebSocketConnected, connection.error]);
|
||||
|
||||
// 使用共享连接创建业务层
|
||||
const {
|
||||
@@ -60,243 +52,72 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
onFileListReceived,
|
||||
onFileRequested,
|
||||
onFileProgress
|
||||
} = useFileTransferBusiness(connection);
|
||||
} = useFileTransferBusiness(stableConnection);
|
||||
|
||||
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
|
||||
// 使用自定义 hooks
|
||||
const { syncFileListToReceiver } = useFileListSync({
|
||||
sendFileList,
|
||||
mode,
|
||||
pickupCode,
|
||||
isConnected,
|
||||
isPeerConnected: connection.isPeerConnected,
|
||||
getChannelState: connection.getChannelState
|
||||
});
|
||||
|
||||
const {
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
fileList,
|
||||
setFileList,
|
||||
downloadedFiles,
|
||||
setDownloadedFiles,
|
||||
handleFileSelect,
|
||||
clearFiles,
|
||||
resetFiles,
|
||||
updateFileStatus,
|
||||
updateFileProgress
|
||||
} = useFileStateManager({
|
||||
mode,
|
||||
pickupCode,
|
||||
syncFileListToReceiver,
|
||||
isPeerConnected: connection.isPeerConnected
|
||||
});
|
||||
|
||||
const { joinRoom: originalJoinRoom, isJoiningRoom } = useRoomConnection({
|
||||
connect,
|
||||
isConnecting,
|
||||
isConnected
|
||||
});
|
||||
|
||||
// 包装joinRoom函数以便设置pickupCode
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
// 检查取件码格式
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
setPickupCode(code);
|
||||
await originalJoinRoom(code);
|
||||
}, [originalJoinRoom]);
|
||||
|
||||
// 防止重复调用 - 检查是否已经在连接或已连接
|
||||
if (isConnecting || isConnected || isJoiningRoom) {
|
||||
console.log('已在连接中或已连接,跳过重复的房间状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
// 先检查房间状态
|
||||
console.log('检查房间状态...');
|
||||
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线 (使用新的字段名)
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
|
||||
connect(trimmedCode, 'receiver');
|
||||
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
let errorMessage = '检查房间状态失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false); // 重置加入房间状态
|
||||
}
|
||||
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
|
||||
const { updateMode } = useURLHandler({
|
||||
featureType: 'webrtc',
|
||||
onModeChange: setMode,
|
||||
onAutoJoinRoom: joinRoom
|
||||
});
|
||||
|
||||
// 从URL参数中获取初始模式(仅在首次加载时处理)
|
||||
useEffect(() => {
|
||||
// 使用 ref 确保只处理一次,避免严格模式的重复调用
|
||||
if (urlProcessedRef.current) {
|
||||
console.log('URL已处理过,跳过重复处理');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// 只在首次加载且URL中有webrtc类型时处理
|
||||
if (!hasProcessedInitialUrl && type === 'webrtc' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log('=== 处理初始URL参数 ===');
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
// 立即标记为已处理,防止重复
|
||||
urlProcessedRef.current = true;
|
||||
|
||||
setMode(urlMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
|
||||
if (code && urlMode === 'receive') {
|
||||
console.log('URL中有取件码,自动加入房间');
|
||||
// 防止重复调用 - 检查连接状态和加入房间状态
|
||||
if (!isConnecting && !isConnected && !isJoiningRoom) {
|
||||
// 直接调用异步函数,不依赖 joinRoom
|
||||
const autoJoinRoom = async () => {
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
console.log('检查房间状态...');
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
connect(trimmedCode, 'receiver');
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
let errorMessage = '检查房间状态失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoJoinRoom();
|
||||
} else {
|
||||
console.log('已在连接中或加入房间中,跳过重复处理');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl, isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
console.log('=== 手动切换模式 ===');
|
||||
console.log('新模式:', newMode);
|
||||
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'webrtc');
|
||||
params.set('mode', newMode);
|
||||
|
||||
// 如果切换到发送模式,移除code参数
|
||||
if (newMode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
useConnectionState({
|
||||
isWebSocketConnected,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error: error || '',
|
||||
pickupCode,
|
||||
fileListLength: fileList.length,
|
||||
currentTransferFile,
|
||||
setCurrentTransferFile,
|
||||
updateFileListStatus: setFileList
|
||||
});
|
||||
|
||||
// 生成文件ID
|
||||
const generateFileId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
// 文件选择处理
|
||||
const handleFileSelect = (files: File[]) => {
|
||||
console.log('=== 文件选择 ===');
|
||||
console.log('新文件:', files.map(f => f.name));
|
||||
|
||||
// 更新选中的文件
|
||||
setSelectedFiles(prev => [...prev, ...files]);
|
||||
|
||||
// 创建对应的文件信息
|
||||
const newFileInfos: FileInfo[] = files.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
setFileList(prev => {
|
||||
const updatedList = [...prev, ...newFileInfos];
|
||||
console.log('更新后的文件列表:', updatedList);
|
||||
|
||||
// 如果P2P连接已建立,立即同步文件列表
|
||||
if (isConnected && connection.isPeerConnected && pickupCode) {
|
||||
console.log('立即同步文件列表到对端');
|
||||
setTimeout(() => sendFileList(updatedList), 100);
|
||||
}
|
||||
|
||||
return updatedList;
|
||||
});
|
||||
};
|
||||
|
||||
// 创建房间 (发送模式)
|
||||
const generateCode = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
@@ -308,21 +129,14 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
console.log('=== 创建房间 ===');
|
||||
console.log('选中文件数:', selectedFiles.length);
|
||||
|
||||
// 创建后端房间
|
||||
// 创建后端房间 - 简化版本,不发送无用的文件信息
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'file',
|
||||
files: selectedFiles.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}))
|
||||
}),
|
||||
// 不再发送文件列表,因为后端不使用这些信息
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -332,12 +146,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 +183,17 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
// 清空状态
|
||||
setPickupCode('');
|
||||
setFileList([]);
|
||||
setDownloadedFiles(new Map());
|
||||
resetFiles();
|
||||
|
||||
// 如果是接收模式,更新URL移除code参数
|
||||
if (mode === 'receive') {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('code');
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
// 如果是接收模式,需要手动更新URL
|
||||
// URL处理逻辑已经移到 hook 中
|
||||
};
|
||||
|
||||
// 处理文件列表更新
|
||||
// 处理文件列表更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileListReceived((fileInfos: FileInfo[]) => {
|
||||
console.log('=== 收到文件列表更新 ===');
|
||||
console.log('文件列表:', fileInfos);
|
||||
console.log('当前模式:', mode);
|
||||
|
||||
if (mode === 'receive') {
|
||||
setFileList(fileInfos);
|
||||
@@ -395,6 +203,99 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
return cleanup;
|
||||
}, [onFileListReceived, mode]);
|
||||
|
||||
// 处理文件接收
|
||||
useEffect(() => {
|
||||
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
|
||||
console.log('=== 接收到文件 ===');
|
||||
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
|
||||
|
||||
// 更新下载的文件
|
||||
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
|
||||
|
||||
// 更新文件状态
|
||||
updateFileStatus(fileData.id, 'completed', 100);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileReceived, updateFileStatus]);
|
||||
|
||||
// 监听文件级别的进度更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileProgress((progressInfo) => {
|
||||
// 检查连接状态,如果连接断开则忽略进度更新
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 文件进度更新 ===');
|
||||
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
|
||||
|
||||
// 更新当前传输文件信息
|
||||
setCurrentTransferFile({
|
||||
fileId: progressInfo.fileId,
|
||||
fileName: progressInfo.fileName,
|
||||
progress: progressInfo.progress
|
||||
});
|
||||
|
||||
// 更新文件进度
|
||||
updateFileProgress(progressInfo.fileId, progressInfo.fileName, progressInfo.progress);
|
||||
|
||||
// 当传输完成时清理
|
||||
if (progressInfo.progress >= 100 && mode === 'send') {
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileProgress, mode, isConnected, error, updateFileProgress]);
|
||||
|
||||
// 处理文件请求(发送方监听)
|
||||
useEffect(() => {
|
||||
const cleanup = onFileRequested((fileId: string, fileName: string) => {
|
||||
console.log('=== 收到文件请求 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
|
||||
|
||||
if (mode === 'send') {
|
||||
// 检查连接状态
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,无法发送文件');
|
||||
showToast('连接已断开,无法发送文件', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 在发送方的selectedFiles中查找对应文件
|
||||
const file = selectedFiles.find(f => f.name === fileName);
|
||||
|
||||
if (!file) {
|
||||
console.error('找不到匹配的文件:', fileName);
|
||||
showToast(`无法找到文件: ${fileName}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
|
||||
|
||||
// 更新发送方文件状态为downloading
|
||||
updateFileStatus(fileId, 'downloading', 0);
|
||||
|
||||
// 发送文件
|
||||
try {
|
||||
sendFile(file, fileId);
|
||||
} catch (sendError) {
|
||||
console.error('发送文件失败:', sendError);
|
||||
showToast(`发送文件失败: ${fileName}`, "error");
|
||||
|
||||
// 重置文件状态
|
||||
updateFileStatus(fileId, 'ready', 0);
|
||||
}
|
||||
} else {
|
||||
console.warn('接收模式下收到文件请求,忽略');
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error, showToast, updateFileStatus]);
|
||||
|
||||
// 处理连接错误
|
||||
const [lastError, setLastError] = useState<string>('');
|
||||
useEffect(() => {
|
||||
@@ -461,52 +362,6 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
return cleanup;
|
||||
}, [onFileReceived]);
|
||||
|
||||
// 监听文件级别的进度更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileProgress((progressInfo) => {
|
||||
// 检查连接状态,如果连接断开则忽略进度更新
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 文件进度更新 ===');
|
||||
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
|
||||
|
||||
// 更新当前传输文件信息
|
||||
setCurrentTransferFile({
|
||||
fileId: progressInfo.fileId,
|
||||
fileName: progressInfo.fileName,
|
||||
progress: progressInfo.progress
|
||||
});
|
||||
|
||||
// 更新文件列表中对应文件的进度
|
||||
setFileList(prev => prev.map(item => {
|
||||
if (item.id === progressInfo.fileId || item.name === progressInfo.fileName) {
|
||||
const newProgress = progressInfo.progress;
|
||||
const newStatus = newProgress >= 100 ? 'completed' as const : 'downloading' as const;
|
||||
|
||||
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${newProgress}`);
|
||||
return { ...item, progress: newProgress, status: newStatus };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
|
||||
// 当传输完成时显示提示
|
||||
if (progressInfo.progress >= 100 && mode === 'send') {
|
||||
// 移除不必要的Toast - 传输完成状态在UI中已经显示
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileProgress, mode, isConnected, error]);
|
||||
|
||||
// 实时更新传输进度(旧逻辑 - 删除)
|
||||
// useEffect(() => {
|
||||
// ...已删除的旧代码...
|
||||
// }, [...]);
|
||||
|
||||
// 处理文件请求(发送方监听)
|
||||
useEffect(() => {
|
||||
const cleanup = onFileRequested((fileId: string, fileName: string) => {
|
||||
@@ -566,29 +421,46 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error]);
|
||||
|
||||
// 监听WebSocket连接状态变化
|
||||
// 监听连接状态变化和清理传输状态
|
||||
useEffect(() => {
|
||||
console.log('=== WebSocket状态变化 ===');
|
||||
console.log('=== 连接状态变化 ===');
|
||||
console.log('WebSocket连接状态:', isWebSocketConnected);
|
||||
console.log('WebRTC连接状态:', isConnected);
|
||||
console.log('连接中状态:', isConnecting);
|
||||
|
||||
// 只有在之前已经建立过连接,现在断开的情况下才显示断开提示
|
||||
// 避免在初始连接时误报断开
|
||||
if (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) {
|
||||
// 增加额外检查:只有在之前曾经连接成功过的情况下才显示断开提示
|
||||
// 通过检查是否有文件列表来判断是否曾经连接过
|
||||
if (fileList.length > 0 || currentTransferFile) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
const shouldCleanup = (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) ||
|
||||
((!isConnected && !isConnecting) || error);
|
||||
|
||||
if (shouldCleanup) {
|
||||
const hasCurrentTransfer = !!currentTransferFile;
|
||||
const hasFileList = fileList.length > 0;
|
||||
|
||||
// 只有在之前有连接活动时才显示断开提示和清理状态
|
||||
if (hasFileList || hasCurrentTransfer) {
|
||||
if (!isWebSocketConnected && pickupCode) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
}
|
||||
|
||||
// 清理传输状态
|
||||
console.log('WebSocket断开,清理传输状态');
|
||||
setCurrentTransferFile(null);
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
console.log('连接断开,清理传输状态');
|
||||
|
||||
if (currentTransferFile) {
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
setFileList(prev => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,34 +469,9 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
console.log('WebSocket已连接,正在建立P2P连接...');
|
||||
}
|
||||
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, showToast, fileList.length, currentTransferFile]);
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileList.length]);
|
||||
|
||||
// 监听连接状态变化,清理传输状态
|
||||
useEffect(() => {
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
if ((!isConnected && !isConnecting) || error) {
|
||||
if (currentTransferFile) {
|
||||
console.log('连接断开,清理当前传输文件状态:', currentTransferFile.fileName);
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
setFileList(prev => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [isConnected, isConnecting, error, currentTransferFile]);
|
||||
|
||||
// 监听连接状态变化并提供用户反馈
|
||||
// 监听连接状态变化并提供日志
|
||||
useEffect(() => {
|
||||
console.log('=== WebRTC连接状态变化 ===');
|
||||
console.log('连接状态:', {
|
||||
@@ -636,65 +483,66 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length
|
||||
});
|
||||
|
||||
// 连接成功时的提示
|
||||
if (isConnected && !isConnecting) {
|
||||
if (mode === 'send') {
|
||||
// 移除不必要的Toast - 连接状态在UI中已经显示
|
||||
} else {
|
||||
// 移除不必要的Toast - 连接状态在UI中已经显示
|
||||
}
|
||||
}
|
||||
|
||||
// 连接中的状态
|
||||
if (isConnecting && pickupCode) {
|
||||
console.log('正在建立WebRTC连接...');
|
||||
}
|
||||
|
||||
// 只有在P2P连接建立且没有错误时才发送文件列表
|
||||
if (isConnected && connection.isPeerConnected && !error && pickupCode && mode === 'send' && selectedFiles.length > 0) {
|
||||
// 确保有文件列表
|
||||
if (fileList.length === 0) {
|
||||
console.log('创建文件列表并发送...');
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
setFileList(newFileInfos);
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
|
||||
sendFileList(newFileInfos);
|
||||
}
|
||||
}, 500);
|
||||
} else if (fileList.length > 0) {
|
||||
console.log('发送现有文件列表...');
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
|
||||
sendFileList(fileList);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, error]);
|
||||
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, fileList.length]);
|
||||
|
||||
// 监听P2P连接建立,自动发送文件列表
|
||||
// 监听P2P连接建立时的状态变化
|
||||
useEffect(() => {
|
||||
if (connection.isPeerConnected && mode === 'send' && fileList.length > 0) {
|
||||
console.log('P2P连接已建立,发送文件列表...');
|
||||
// 稍微延迟一下,确保数据通道完全准备好
|
||||
setTimeout(() => {
|
||||
if (connection.isPeerConnected && connection.getChannelState() === 'open') {
|
||||
sendFileList(fileList);
|
||||
}
|
||||
}, 200);
|
||||
console.log('P2P连接已建立,数据通道首次打开,初始化文件列表');
|
||||
// 数据通道第一次打开时进行初始化
|
||||
syncFileListToReceiver(fileList, '数据通道初始化');
|
||||
}
|
||||
}, [connection.isPeerConnected, mode, fileList.length, sendFileList]);
|
||||
}, [connection.isPeerConnected, mode, syncFileListToReceiver]);
|
||||
|
||||
// 监听fileList大小变化并同步
|
||||
useEffect(() => {
|
||||
if (connection.isPeerConnected && mode === 'send' && pickupCode) {
|
||||
console.log('fileList大小变化,同步到接收方:', fileList.length);
|
||||
syncFileListToReceiver(fileList, 'fileList大小变化');
|
||||
}
|
||||
}, [fileList.length, connection.isPeerConnected, mode, pickupCode, syncFileListToReceiver]);
|
||||
|
||||
// 监听selectedFiles变化,同步更新fileList并发送给接收方
|
||||
useEffect(() => {
|
||||
// 只有在发送模式下且已有房间时才处理文件列表同步
|
||||
if (mode !== 'send' || !pickupCode) return;
|
||||
|
||||
console.log('=== selectedFiles变化,同步文件列表 ===', {
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length,
|
||||
selectedFileNames: selectedFiles.map(f => f.name)
|
||||
});
|
||||
|
||||
// 根据selectedFiles创建新的文件信息列表
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
|
||||
// 尝试找到现有的文件信息,保持已有的状态
|
||||
const existingFileInfo = fileList.find(info => info.name === file.name && info.size === file.size);
|
||||
return existingFileInfo || {
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready' as const,
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
// 检查文件列表是否真正发生变化
|
||||
const fileListChanged =
|
||||
newFileInfos.length !== fileList.length ||
|
||||
newFileInfos.some(newFile =>
|
||||
!fileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
|
||||
);
|
||||
|
||||
if (fileListChanged) {
|
||||
console.log('文件列表发生变化,更新:', {
|
||||
before: fileList.map(f => f.name),
|
||||
after: newFileInfos.map(f => f.name)
|
||||
});
|
||||
|
||||
setFileList(newFileInfos);
|
||||
}
|
||||
}, [selectedFiles, mode, pickupCode]);
|
||||
|
||||
// 请求下载文件(接收方调用)
|
||||
const requestFile = (fileId: string) => {
|
||||
@@ -778,17 +626,6 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 清空文件
|
||||
const clearFiles = () => {
|
||||
console.log('=== 清空文件 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
// 只有在P2P连接建立且数据通道准备好时才发送清空消息
|
||||
if (isConnected && connection.isPeerConnected && connection.getChannelState() === 'open' && pickupCode) {
|
||||
sendFileList([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文件到本地
|
||||
const downloadFile = (fileId: string) => {
|
||||
const file = downloadedFiles.get(fileId);
|
||||
@@ -846,6 +683,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,12 +699,12 @@ 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}
|
||||
@@ -876,6 +715,7 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
downloadedFiles={downloadedFiles}
|
||||
error={error}
|
||||
onReset={resetConnection}
|
||||
pickupCode={pickupCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 React, { useState, useCallback } from 'react';
|
||||
import { useURLHandler } from '@/hooks/ui';
|
||||
import { useWebRTCStore } from '@/hooks/ui/webRTCStore';
|
||||
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
|
||||
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MessageSquare, Send, Download, X } from 'lucide-react';
|
||||
|
||||
export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// 状态管理
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
if (!hasProcessedInitialUrl && type === 'message' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log('=== 处理初始URL参数 ===');
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
setMode(urlMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = (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"
|
||||
>
|
||||
|
||||
186
chuan-next/src/components/WebRTCUnsupportedModal.tsx
Normal file
186
chuan-next/src/components/WebRTCUnsupportedModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Download, X, Chrome, Monitor } from 'lucide-react';
|
||||
import { WebRTCSupport, getBrowserInfo, getRecommendedBrowsers } from '@/lib/webrtc-support';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
webrtcSupport: WebRTCSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 不支持提示模态框
|
||||
*/
|
||||
export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props) {
|
||||
const browserInfo = getBrowserInfo();
|
||||
const recommendedBrowsers = getRecommendedBrowsers();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBrowserDownload = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
浏览器不支持 WebRTC
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 当前浏览器信息 */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-red-800 mb-2">当前浏览器状态</h3>
|
||||
<div className="space-y-2 text-sm text-red-700">
|
||||
<div>
|
||||
<strong>浏览器:</strong> {browserInfo.name} {browserInfo.version}
|
||||
</div>
|
||||
<div>
|
||||
<strong>WebRTC 支持:</strong>
|
||||
<span className="ml-1 px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
||||
不支持
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缺失的功能 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-gray-900">缺失的功能:</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{webrtcSupport.missing.map((feature, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div className="w-2 h-2 bg-red-400 rounded-full"></div>
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能说明 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-blue-800 mb-2">为什么需要 WebRTC?</h3>
|
||||
<div className="space-y-2 text-sm text-blue-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Monitor className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong>屏幕共享:</strong> 实时共享您的桌面屏幕
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Download className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong>文件传输:</strong> 点对点直接传输文件,快速且安全
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Chrome className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong>文本传输:</strong> 实时文本和图像传输
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 浏览器推荐 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-gray-900">推荐使用以下浏览器:</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedBrowsers.map((browser, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors cursor-pointer"
|
||||
onClick={() => handleBrowserDownload(browser.downloadUrl)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{browser.name}</h4>
|
||||
<p className="text-sm text-gray-600">版本 {browser.minVersion}</p>
|
||||
</div>
|
||||
<Download className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 浏览器特定建议 */}
|
||||
{browserInfo.recommendations && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-yellow-800 mb-2">建议</h3>
|
||||
<ul className="space-y-1 text-sm text-yellow-700">
|
||||
{browserInfo.recommendations.map((recommendation, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-yellow-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
{recommendation}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 技术详情(可折叠) */}
|
||||
<details className="border border-gray-200 rounded-lg">
|
||||
<summary className="p-3 cursor-pointer font-medium text-gray-900 hover:bg-gray-50">
|
||||
技术详情
|
||||
</summary>
|
||||
<div className="p-3 border-t border-gray-200 space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<strong>RTCPeerConnection:</strong>
|
||||
<span className={`ml-2 px-2 py-1 rounded text-xs ${
|
||||
webrtcSupport.details.rtcPeerConnection
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{webrtcSupport.details.rtcPeerConnection ? '支持' : '不支持'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>DataChannel:</strong>
|
||||
<span className={`ml-2 px-2 py-1 rounded text-xs ${
|
||||
webrtcSupport.details.dataChannel
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{webrtcSupport.details.dataChannel ? '支持' : '不支持'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBrowserDownload('https://www.google.com/chrome/')}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
下载 Chrome 浏览器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
106
chuan-next/src/components/ui/confirm-dialog.tsx
Normal file
106
chuan-next/src/components/ui/confirm-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
357
chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx
Normal file
357
chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Monitor, Square } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
|
||||
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();
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
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.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]);
|
||||
|
||||
// 如果有初始代码且还未加入观看,自动尝试加入
|
||||
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.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="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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx
Normal file
290
chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Share, Monitor, Play, Square, Repeat } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
|
||||
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
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();
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange && desktopShare.webRTCConnection) {
|
||||
onConnectionChange(desktopShare.webRTCConnection);
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showToast('房间代码已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败,请手动复制', 'error');
|
||||
}
|
||||
}, [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');
|
||||
} 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');
|
||||
} 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]);
|
||||
|
||||
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={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 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-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>
|
||||
)}
|
||||
|
||||
{/* 房间信息显示 */}
|
||||
<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');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
@@ -41,6 +42,7 @@ interface WebRTCFileReceiveProps {
|
||||
downloadedFiles?: Map<string, File>;
|
||||
error?: string | null;
|
||||
onReset?: () => void;
|
||||
pickupCode?: string;
|
||||
}
|
||||
|
||||
export function WebRTCFileReceive({
|
||||
@@ -52,12 +54,16 @@ export function WebRTCFileReceive({
|
||||
isWebSocketConnected = false,
|
||||
downloadedFiles,
|
||||
error = null,
|
||||
onReset
|
||||
onReset,
|
||||
pickupCode: propPickupCode
|
||||
}: WebRTCFileReceiveProps) {
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 使用传入的取件码或本地状态的取件码
|
||||
const displayPickupCode = propPickupCode || pickupCode;
|
||||
|
||||
// 验证取件码是否存在
|
||||
const validatePickupCode = async (code: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -109,7 +115,7 @@ export function WebRTCFileReceive({
|
||||
}, [pickupCode, onJoinRoom]);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
|
||||
const value = e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, '');
|
||||
if (value.length <= 6) {
|
||||
setPickupCode(value);
|
||||
}
|
||||
@@ -133,63 +139,31 @@ export function WebRTCFileReceive({
|
||||
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>
|
||||
<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">
|
||||
@@ -226,67 +200,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>
|
||||
@@ -371,8 +300,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 +311,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">
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import React, { useState, useRef, useCallback } 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 RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
@@ -45,8 +46,6 @@ interface WebRTCFileUploadProps {
|
||||
onClearFiles?: () => void;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
isConnected?: boolean;
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
|
||||
export function WebRTCFileUpload({
|
||||
@@ -62,9 +61,7 @@ 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);
|
||||
@@ -115,9 +112,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 +124,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 +174,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">
|
||||
@@ -397,80 +333,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>
|
||||
);
|
||||
|
||||
@@ -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 { useSharedWebRTCManager } from '@/hooks/connection';
|
||||
import { useTextTransferBusiness } from '@/hooks/text-transfer';
|
||||
import { useFileTransferBusiness } from '@/hooks/file-transfer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { MessageSquare, Image, Download } from 'lucide-react';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
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,13 @@ 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 = useSharedWebRTCManager();
|
||||
|
||||
|
||||
// 使用共享连接创建业务层
|
||||
const textTransfer = useTextTransferBusiness(connection);
|
||||
const fileTransfer = useFileTransferBusiness(connection);
|
||||
@@ -42,116 +46,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, connection.isConnected, connection.isConnecting, connection.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 +111,66 @@ 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) {
|
||||
throw new Error(roomData.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 +179,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 +196,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 +209,7 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, ''))}
|
||||
placeholder="请输入取件码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
|
||||
maxLength={6}
|
||||
@@ -266,94 +250,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="等待对方发送文字内容... 💡 实时同步显示,对方的编辑会立即显示在这里"
|
||||
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">
|
||||
{connection.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>
|
||||
|
||||
@@ -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 { useSharedWebRTCManager } from '@/hooks/connection';
|
||||
import { useTextTransferBusiness } from '@/hooks/text-transfer';
|
||||
import { useFileTransferBusiness } from '@/hooks/file-transfer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
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();
|
||||
|
||||
// 状态管理
|
||||
@@ -48,6 +50,13 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
// 是否正在连接
|
||||
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange) {
|
||||
onConnectionChange(connection);
|
||||
}
|
||||
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
|
||||
|
||||
// 是否有任何错误
|
||||
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
|
||||
|
||||
@@ -106,21 +115,14 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
console.log('=== 开始创建房间 ===');
|
||||
const currentText = textInput.trim();
|
||||
|
||||
// 创建后端房间 - 简化版本,不发送无用的文本信息
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
initialText: currentText || '',
|
||||
hasImages: false,
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
settings: {
|
||||
enableRealTimeText: true,
|
||||
enableImageTransfer: true
|
||||
}
|
||||
}),
|
||||
// 不再发送文本内容,因为后端不使用这些信息
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -286,36 +288,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 +324,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">
|
||||
@@ -471,74 +431,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>
|
||||
)}
|
||||
|
||||
|
||||
6
chuan-next/src/hooks/connection/index.ts
Normal file
6
chuan-next/src/hooks/connection/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// 连接相关的hooks
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
export { useRoomConnection } from './useRoomConnection';
|
||||
export { useSharedWebRTCManager } from './useSharedWebRTCManager';
|
||||
export { useWebRTCManager } from './useWebRTCManager';
|
||||
export { useWebRTCSupport } from './useWebRTCSupport';
|
||||
137
chuan-next/src/hooks/connection/useConnectionState.ts
Normal file
137
chuan-next/src/hooks/connection/useConnectionState.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
interface UseConnectionStateProps {
|
||||
isWebSocketConnected: boolean;
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
error: string;
|
||||
pickupCode: string;
|
||||
fileListLength: number;
|
||||
currentTransferFile: any;
|
||||
setCurrentTransferFile: (file: any) => void;
|
||||
updateFileListStatus: (callback: (prev: any[]) => any[]) => void;
|
||||
}
|
||||
|
||||
export const useConnectionState = ({
|
||||
isWebSocketConnected,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
pickupCode,
|
||||
fileListLength,
|
||||
currentTransferFile,
|
||||
setCurrentTransferFile,
|
||||
updateFileListStatus
|
||||
}: UseConnectionStateProps) => {
|
||||
const { showToast } = useToast();
|
||||
const [lastError, setLastError] = useState<string>('');
|
||||
|
||||
// 处理连接错误
|
||||
useEffect(() => {
|
||||
if (error && error !== lastError) {
|
||||
console.log('=== 连接错误处理 ===');
|
||||
console.log('错误信息:', error);
|
||||
|
||||
// 根据错误类型显示不同的提示
|
||||
let errorMessage = error;
|
||||
|
||||
if (error.includes('WebSocket')) {
|
||||
errorMessage = '服务器连接失败,请检查网络连接或稍后重试';
|
||||
} else if (error.includes('数据通道')) {
|
||||
errorMessage = '数据通道连接失败,请重新尝试连接';
|
||||
} else if (error.includes('连接超时')) {
|
||||
errorMessage = '连接超时,请检查网络状况或重新尝试';
|
||||
} else if (error.includes('连接失败')) {
|
||||
errorMessage = 'WebRTC连接失败,可能是网络环境限制,请尝试刷新页面';
|
||||
} else if (error.includes('信令错误')) {
|
||||
errorMessage = '信令服务器错误,请稍后重试';
|
||||
} else if (error.includes('创建连接失败')) {
|
||||
errorMessage = '无法建立P2P连接,请检查网络设置';
|
||||
}
|
||||
|
||||
// 显示错误提示
|
||||
showToast(errorMessage, "error");
|
||||
setLastError(error);
|
||||
|
||||
// 如果是严重连接错误,清理传输状态
|
||||
if (error.includes('连接失败') || error.includes('数据通道连接失败') || error.includes('WebSocket')) {
|
||||
console.log('严重连接错误,清理传输状态');
|
||||
setCurrentTransferFile(null);
|
||||
|
||||
// 重置所有正在传输的文件状态
|
||||
updateFileListStatus((prev: any[]) => prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
}
|
||||
}, [error, lastError, showToast, setCurrentTransferFile, updateFileListStatus]);
|
||||
|
||||
// 监听连接状态变化和清理传输状态
|
||||
useEffect(() => {
|
||||
console.log('=== 连接状态变化 ===');
|
||||
console.log('WebSocket连接状态:', isWebSocketConnected);
|
||||
console.log('WebRTC连接状态:', isConnected);
|
||||
console.log('连接中状态:', isConnecting);
|
||||
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
const shouldCleanup = (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) ||
|
||||
((!isConnected && !isConnecting) || error);
|
||||
|
||||
if (shouldCleanup) {
|
||||
const hasCurrentTransfer = !!currentTransferFile;
|
||||
const hasFileList = fileListLength > 0;
|
||||
|
||||
// 只有在之前有连接活动时才显示断开提示和清理状态
|
||||
if (hasFileList || hasCurrentTransfer) {
|
||||
if (!isWebSocketConnected && pickupCode) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
}
|
||||
|
||||
console.log('连接断开,清理传输状态');
|
||||
|
||||
if (currentTransferFile) {
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
updateFileListStatus((prev: any[]) => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket连接成功时的提示
|
||||
if (isWebSocketConnected && isConnecting && !isConnected) {
|
||||
console.log('WebSocket已连接,正在建立P2P连接...');
|
||||
}
|
||||
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileListLength, setCurrentTransferFile, updateFileListStatus]);
|
||||
|
||||
// 监听连接状态变化并提供日志
|
||||
useEffect(() => {
|
||||
console.log('=== WebRTC连接状态变化 ===');
|
||||
console.log('连接状态:', {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isWebSocketConnected,
|
||||
pickupCode,
|
||||
fileListLength
|
||||
});
|
||||
}, [isConnected, isConnecting, isWebSocketConnected, pickupCode, fileListLength]);
|
||||
|
||||
return {
|
||||
lastError
|
||||
};
|
||||
};
|
||||
103
chuan-next/src/hooks/connection/useRoomConnection.ts
Normal file
103
chuan-next/src/hooks/connection/useRoomConnection.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
interface UseRoomConnectionProps {
|
||||
connect: (code: string, role: 'sender' | 'receiver') => void;
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoomConnectionProps) => {
|
||||
const { showToast } = useToast();
|
||||
const [isJoiningRoom, setIsJoiningRoom] = useState(false);
|
||||
|
||||
const validateRoomCode = (code: string): string | null => {
|
||||
const trimmedCode = code.trim();
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
return '请输入正确的6位取件码';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const checkRoomStatus = async (code: string) => {
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (!result.sender_online) {
|
||||
throw new Error('发送方不在线,请确认取件码是否正确或联系发送方');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleNetworkError = (error: Error): string => {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
return '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
return '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
return '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
return '服务器错误,请稍后重试';
|
||||
} else {
|
||||
return error.message;
|
||||
}
|
||||
};
|
||||
|
||||
// 加入房间 (接收模式)
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
// 验证输入
|
||||
const validationError = validateRoomCode(code);
|
||||
if (validationError) {
|
||||
showToast(validationError, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复调用
|
||||
if (isConnecting || isConnected || isJoiningRoom) {
|
||||
console.log('已在连接中或已连接,跳过重复的房间状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
console.log('检查房间状态...');
|
||||
await checkRoomStatus(code.trim());
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
connect(code.trim(), 'receiver');
|
||||
showToast(`正在连接到房间: ${code.trim()}`, "success");
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
const errorMessage = error instanceof Error ? handleNetworkError(error) : '检查房间状态失败';
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false);
|
||||
}
|
||||
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]);
|
||||
|
||||
return {
|
||||
joinRoom,
|
||||
isJoiningRoom
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
import { useWebRTCStore } from '../ui/webRTCStore';
|
||||
|
||||
// 基础连接状态
|
||||
interface WebRTCState {
|
||||
@@ -8,6 +9,7 @@ interface WebRTCState {
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean; // 新增:P2P连接状态
|
||||
error: string | null;
|
||||
canRetry: boolean; // 新增:是否可以重试
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
@@ -29,10 +31,12 @@ export interface WebRTCConnection {
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean; // 新增:P2P连接状态
|
||||
error: string | null;
|
||||
canRetry: boolean; // 新增:是否可以重试
|
||||
|
||||
// 操作方法
|
||||
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
|
||||
disconnect: () => void;
|
||||
retry: () => Promise<void>; // 新增:重试连接方法
|
||||
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
|
||||
sendData: (data: ArrayBuffer) => boolean;
|
||||
|
||||
@@ -60,13 +64,8 @@ export interface WebRTCConnection {
|
||||
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
|
||||
*/
|
||||
export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
const [state, setState] = useState<WebRTCState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
});
|
||||
// 使用全局状态 store
|
||||
const webrtcStore = useWebRTCStore();
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
@@ -75,6 +74,9 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
// 当前连接的房间信息
|
||||
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
|
||||
|
||||
// 用于跟踪是否是用户主动断开连接
|
||||
const isUserDisconnecting = useRef<boolean>(false);
|
||||
|
||||
// 多通道消息处理器
|
||||
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
|
||||
@@ -89,8 +91,8 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
];
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
webrtcStore.updateState(updates);
|
||||
}, [webrtcStore]);
|
||||
|
||||
// 清理连接
|
||||
const cleanup = useCallback(() => {
|
||||
@@ -116,6 +118,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
}
|
||||
|
||||
currentRoom.current = null;
|
||||
isUserDisconnecting.current = false; // 重置主动断开标志
|
||||
}, []);
|
||||
|
||||
// 创建 Offer
|
||||
@@ -157,7 +160,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 创建 offer 失败:', error);
|
||||
updateState({ error: '创建连接失败', isConnecting: false });
|
||||
updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
@@ -203,7 +206,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
|
||||
|
||||
// 如果正在连接中,避免重复连接
|
||||
if (state.isConnecting) {
|
||||
if (webrtcStore.isConnecting) {
|
||||
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
@@ -211,7 +214,11 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
// 清理之前的连接
|
||||
cleanup();
|
||||
currentRoom.current = { code: roomCode, role };
|
||||
webrtcStore.setCurrentRoom({ code: roomCode, role });
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
// 重置主动断开标志
|
||||
isUserDisconnecting.current = false;
|
||||
|
||||
// 注意:不在这里设置超时,因为WebSocket连接很快,
|
||||
// WebRTC连接的建立是在后续添加轨道时进行的
|
||||
@@ -292,11 +299,25 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
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);
|
||||
try {
|
||||
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);
|
||||
// 如果状态不对,尝试重新创建 offer
|
||||
if (pc.connectionState === 'connected' || pc.connectionState === 'connecting') {
|
||||
console.log('[SharedWebRTC] 🔄 连接状态正常但信令状态异常,尝试重新创建offer');
|
||||
// 这里不直接处理,让连接自然建立
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 处理answer失败:', error);
|
||||
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
|
||||
console.warn('[SharedWebRTC] ⚠️ Answer处理失败,可能是连接状态变化导致的');
|
||||
// 清理连接状态,让客户端重新连接
|
||||
updateState({ error: 'WebRTC连接状态异常,请重新连接', isPeerConnected: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -315,7 +336,27 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
case 'error':
|
||||
console.error('[SharedWebRTC] ❌ 信令服务器错误:', message.error);
|
||||
updateState({ error: message.error, isConnecting: false });
|
||||
updateState({ error: message.error, isConnecting: false, canRetry: true });
|
||||
break;
|
||||
|
||||
case 'disconnection':
|
||||
console.log('[SharedWebRTC] 🔌 对方主动断开连接');
|
||||
// 对方断开连接的处理
|
||||
updateState({
|
||||
isPeerConnected: false,
|
||||
isConnected: false, // 添加这个状态
|
||||
error: '对方已离开房间',
|
||||
canRetry: true
|
||||
});
|
||||
// 清理P2P连接但保持WebSocket连接,允许重新连接
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -323,20 +364,29 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 处理信令消息失败:', error);
|
||||
updateState({ error: '信令处理失败: ' + error, isConnecting: false });
|
||||
updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] ❌ WebSocket 错误:', error);
|
||||
updateState({ error: 'WebSocket连接失败,请检查服务器是否运行在8080端口', isConnecting: false });
|
||||
updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
updateState({ isWebSocketConnected: false });
|
||||
|
||||
// 检查是否是用户主动断开
|
||||
if (isUserDisconnecting.current) {
|
||||
console.log('[SharedWebRTC] ✅ 用户主动断开,正常关闭');
|
||||
// 用户主动断开时不显示错误消息
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有在非正常关闭且不是用户主动断开时才显示错误
|
||||
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
|
||||
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '未知原因'}`, isConnecting: false });
|
||||
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,7 +415,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
break;
|
||||
case 'failed':
|
||||
console.error('[SharedWebRTC] ❌ ICE连接失败');
|
||||
updateState({ error: 'ICE连接失败,可能是网络防火墙阻止了连接', isConnecting: false });
|
||||
updateState({ error: 'ICE连接失败,可能是网络防火墙阻止了连接', isConnecting: false, canRetry: true });
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[SharedWebRTC] 🔌 ICE连接断开');
|
||||
@@ -385,14 +435,14 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立,可以进行媒体传输');
|
||||
updateState({ isPeerConnected: true, error: null });
|
||||
updateState({ isPeerConnected: true, error: null, canRetry: false });
|
||||
break;
|
||||
case 'failed':
|
||||
// 只有在数据通道也未打开的情况下才认为连接真正失败
|
||||
const currentDc = dcRef.current;
|
||||
if (!currentDc || currentDc.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] ❌ WebRTC连接失败,数据通道未建立');
|
||||
updateState({ error: 'WebRTC连接失败,请检查网络设置或重试', isPeerConnected: false });
|
||||
updateState({ error: 'WebRTC连接失败,请检查网络设置或重试', isPeerConnected: false, canRetry: true });
|
||||
} else {
|
||||
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed,但数据通道正常,忽略此状态');
|
||||
}
|
||||
@@ -418,14 +468,59 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false });
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] 数据通道错误:', error);
|
||||
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
const pc = pcRef.current;
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[SharedWebRTC] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
} else {
|
||||
pc.ondatachannel = (event) => {
|
||||
@@ -434,14 +529,59 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false });
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] 数据通道错误:', error);
|
||||
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
|
||||
console.error('[SharedWebRTC] 数据通道错误 (接收方):', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
const pc = pcRef.current;
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[SharedWebRTC] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -463,23 +603,57 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
console.error('[SharedWebRTC] 连接失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false
|
||||
isConnecting: false,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}, [updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
|
||||
}, [updateState, cleanup, createOffer, handleDataChannelMessage, webrtcStore.isConnecting, webrtcStore.isConnected]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('[SharedWebRTC] 断开连接');
|
||||
console.log('[SharedWebRTC] 主动断开连接');
|
||||
|
||||
// 设置主动断开标志
|
||||
isUserDisconnecting.current = true;
|
||||
|
||||
// 在断开之前通知对方
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'disconnection',
|
||||
payload: { reason: '用户主动断开' }
|
||||
}));
|
||||
console.log('[SharedWebRTC] 📤 已通知对方断开连接');
|
||||
} catch (error) {
|
||||
console.warn('[SharedWebRTC] 发送断开通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理连接
|
||||
cleanup();
|
||||
setState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
});
|
||||
}, [cleanup]);
|
||||
|
||||
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
|
||||
webrtcStore.resetToInitial();
|
||||
}, [cleanup, webrtcStore]);
|
||||
|
||||
// 重试连接
|
||||
const retry = useCallback(async () => {
|
||||
const room = currentRoom.current;
|
||||
if (!room) {
|
||||
console.warn('[SharedWebRTC] 没有当前房间信息,无法重试');
|
||||
updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SharedWebRTC] 🔄 重试连接到房间:', room.code, room.role);
|
||||
|
||||
// 清理当前连接
|
||||
cleanup();
|
||||
|
||||
// 重新连接
|
||||
await connect(room.code, room.role);
|
||||
}, [cleanup, connect, updateState]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
@@ -549,8 +723,8 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return currentRoom.current?.code === roomCode &&
|
||||
currentRoom.current?.role === role &&
|
||||
state.isConnected;
|
||||
}, [state.isConnected]);
|
||||
webrtcStore.isConnected;
|
||||
}, [webrtcStore.isConnected]);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
@@ -588,15 +762,32 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.warn('[SharedWebRTC] PeerConnection 尚未准备就绪,将在连接建立后设置onTrack');
|
||||
// 检查WebSocket连接状态,只有连接后才尝试设置
|
||||
if (!webrtcStore.isWebSocketConnected) {
|
||||
console.log('[SharedWebRTC] WebSocket未连接,等待连接建立...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟设置,等待PeerConnection准备就绪
|
||||
let retryCount = 0;
|
||||
const maxRetries = 30; // 最多重试30次,即3秒
|
||||
|
||||
const checkAndSetTrackHandler = () => {
|
||||
const currentPc = pcRef.current;
|
||||
if (currentPc) {
|
||||
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪,设置onTrack处理器');
|
||||
currentPc.ontrack = handler;
|
||||
} else {
|
||||
console.log('[SharedWebRTC] ⏳ 等待PeerConnection准备就绪...');
|
||||
setTimeout(checkAndSetTrackHandler, 100);
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
// 只在偶数次重试时输出日志,减少日志数量
|
||||
if (retryCount % 2 === 0) {
|
||||
console.log(`[SharedWebRTC] ⏳ 等待PeerConnection准备就绪... (尝试: ${retryCount}/${maxRetries})`);
|
||||
}
|
||||
setTimeout(checkAndSetTrackHandler, 100);
|
||||
} else {
|
||||
console.error('[SharedWebRTC] ❌ PeerConnection 长时间未准备就绪,停止重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAndSetTrackHandler();
|
||||
@@ -605,7 +796,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
|
||||
pc.ontrack = handler;
|
||||
}, []);
|
||||
}, [webrtcStore.isWebSocketConnected]);
|
||||
|
||||
// 获取PeerConnection实例
|
||||
const getPeerConnection = useCallback(() => {
|
||||
@@ -632,15 +823,17 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
isConnected: webrtcStore.isConnected,
|
||||
isConnecting: webrtcStore.isConnecting,
|
||||
isWebSocketConnected: webrtcStore.isWebSocketConnected,
|
||||
isPeerConnected: webrtcStore.isPeerConnected,
|
||||
error: webrtcStore.error,
|
||||
canRetry: webrtcStore.canRetry,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect,
|
||||
retry,
|
||||
sendMessage,
|
||||
sendData,
|
||||
|
||||
195
chuan-next/src/hooks/connection/useWebRTCManager.ts
Normal file
195
chuan-next/src/hooks/connection/useWebRTCManager.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { WebRTCManager } from '../webrtc/WebRTCManager';
|
||||
import { WebRTCConnectionState, WebRTCMessage, MessageHandler, DataHandler } from '../webrtc/types';
|
||||
import { WebRTCConnection } from './useSharedWebRTCManager';
|
||||
|
||||
interface WebRTCManagerConfig {
|
||||
dataChannelName?: string;
|
||||
enableLogging?: boolean;
|
||||
iceServers?: RTCIceServer[];
|
||||
iceCandidatePoolSize?: number;
|
||||
chunkSize?: number;
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
ackTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新的 WebRTC 管理器 Hook
|
||||
* 替代原有的 useSharedWebRTCManager,提供更好的架构和错误处理
|
||||
*/
|
||||
export function useWebRTCManager(config: WebRTCManagerConfig = {}): WebRTCConnection {
|
||||
const managerRef = useRef<WebRTCManager | null>(null);
|
||||
const [state, setState] = useState<WebRTCConnectionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false,
|
||||
currentRoom: null,
|
||||
});
|
||||
|
||||
// 初始化管理器
|
||||
useEffect(() => {
|
||||
if (!managerRef.current) {
|
||||
managerRef.current = new WebRTCManager(config);
|
||||
|
||||
// 监听状态变化
|
||||
managerRef.current.on('state-change', (event: any) => {
|
||||
setState(prev => ({ ...prev, ...event.state }));
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.disconnect();
|
||||
managerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// 连接
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
if (!managerRef.current) {
|
||||
throw new Error('WebRTC 管理器未初始化');
|
||||
}
|
||||
return managerRef.current.connect(roomCode, role);
|
||||
}, []);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
if (!managerRef.current) return;
|
||||
managerRef.current.disconnect();
|
||||
}, []);
|
||||
|
||||
// 重试连接
|
||||
const retry = useCallback(async () => {
|
||||
if (!managerRef.current) {
|
||||
throw new Error('WebRTC 管理器未初始化');
|
||||
}
|
||||
return managerRef.current.retry();
|
||||
}, []);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.sendMessage(message, channel);
|
||||
}, []);
|
||||
|
||||
// 发送数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.sendData(data);
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
|
||||
if (!managerRef.current) return () => {};
|
||||
return managerRef.current.registerMessageHandler(channel, handler);
|
||||
}, []);
|
||||
|
||||
// 注册数据处理器
|
||||
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
|
||||
if (!managerRef.current) return () => {};
|
||||
return managerRef.current.registerDataHandler(channel, handler);
|
||||
}, []);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
if (!managerRef.current) return null;
|
||||
return managerRef.current.addTrack(track, stream);
|
||||
}, []);
|
||||
|
||||
// 移除媒体轨道
|
||||
const removeTrack = useCallback((sender: RTCRtpSender) => {
|
||||
if (!managerRef.current) return;
|
||||
managerRef.current.removeTrack(sender);
|
||||
}, []);
|
||||
|
||||
// 设置轨道处理器
|
||||
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
|
||||
if (!managerRef.current) return;
|
||||
managerRef.current.onTrack(handler);
|
||||
}, []);
|
||||
|
||||
// 获取 PeerConnection
|
||||
const getPeerConnection = useCallback(() => {
|
||||
if (!managerRef.current) return null;
|
||||
return managerRef.current.getPeerConnection();
|
||||
}, []);
|
||||
|
||||
// 立即创建 offer
|
||||
const createOfferNow = useCallback(async () => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.createOfferNow();
|
||||
}, []);
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
if (!managerRef.current) return 'closed';
|
||||
return managerRef.current.getChannelState();
|
||||
}, []);
|
||||
|
||||
// 检查是否已连接到指定房间
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.isConnectedToRoom(roomCode, role);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
canRetry: state.canRetry,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect,
|
||||
retry,
|
||||
sendMessage,
|
||||
sendData,
|
||||
|
||||
// 处理器注册
|
||||
registerMessageHandler,
|
||||
registerDataHandler,
|
||||
|
||||
// 工具方法
|
||||
getChannelState,
|
||||
isConnectedToRoom,
|
||||
|
||||
// 媒体轨道方法
|
||||
addTrack,
|
||||
removeTrack,
|
||||
onTrack,
|
||||
getPeerConnection,
|
||||
createOfferNow,
|
||||
|
||||
// 当前房间信息
|
||||
currentRoom: state.currentRoom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移辅助 Hook - 提供向后兼容性
|
||||
* 可以逐步将现有代码迁移到新的架构
|
||||
*/
|
||||
export function useWebRTCMigration() {
|
||||
const newManager = useWebRTCManager();
|
||||
// 注意:这里需要先创建一个包装器来兼容旧的接口
|
||||
// 暂时注释掉,避免循环依赖
|
||||
// const oldManager = useSharedWebRTCManager();
|
||||
|
||||
return {
|
||||
newManager,
|
||||
// oldManager, // 暂时禁用
|
||||
// 可以添加迁移工具函数
|
||||
migrateState: () => {
|
||||
// 将旧状态迁移到新状态
|
||||
console.log('状态迁移功能待实现');
|
||||
},
|
||||
};
|
||||
}
|
||||
40
chuan-next/src/hooks/connection/useWebRTCSupport.ts
Normal file
40
chuan-next/src/hooks/connection/useWebRTCSupport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
chuan-next/src/hooks/desktop-share/index.ts
Normal file
2
chuan-next/src/hooks/desktop-share/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// 桌面共享相关的hooks
|
||||
export { useDesktopShareBusiness } from './useDesktopShareBusiness';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useSharedWebRTCManager } from './useSharedWebRTCManager';
|
||||
import { useSharedWebRTCManager } from '../connection/useSharedWebRTCManager';
|
||||
|
||||
interface DesktopShareState {
|
||||
isSharing: boolean;
|
||||
@@ -29,15 +29,36 @@ export function useDesktopShareBusiness() {
|
||||
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));
|
||||
// 处理远程流
|
||||
const handleRemoteStream = useCallback((stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
|
||||
updateState({ remoteStream: stream });
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
}, [updateState]);
|
||||
|
||||
// 设置远程轨道处理器(始终监听)
|
||||
useEffect(() => {
|
||||
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);
|
||||
remoteStream.getTracks().forEach(track => {
|
||||
console.log('[DesktopShare] 远程轨道:', track.kind, track.id, track.enabled, track.readyState);
|
||||
});
|
||||
handleRemoteStream(remoteStream);
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
|
||||
}
|
||||
});
|
||||
}, [webRTC, handleRemoteStream]);
|
||||
|
||||
// 获取桌面共享流
|
||||
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
|
||||
@@ -141,24 +162,32 @@ export function useDesktopShareBusiness() {
|
||||
};
|
||||
}, [webRTC]);
|
||||
|
||||
// 处理远程流
|
||||
const handleRemoteStream = useCallback((stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
|
||||
updateState({ remoteStream: stream });
|
||||
// 创建房间 - 统一使用后端生成房间码
|
||||
const createRoomFromBackend = useCallback(async (): Promise<string> => {
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '创建房间失败');
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
return data.code;
|
||||
}, []);
|
||||
|
||||
// 创建房间(只建立连接,等待对方加入)
|
||||
const createRoom = useCallback(async (): Promise<string> => {
|
||||
try {
|
||||
updateState({ error: null, isWaitingForPeer: false });
|
||||
|
||||
// 生成房间代码
|
||||
const roomCode = generateRoomCode();
|
||||
// 从后端获取房间代码
|
||||
const roomCode = await createRoomFromBackend();
|
||||
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
|
||||
|
||||
// 建立WebRTC连接(作为发送方)
|
||||
@@ -179,7 +208,7 @@ export function useDesktopShareBusiness() {
|
||||
updateState({ error: errorMessage, connectionCode: '', isWaitingForPeer: false });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, generateRoomCode, updateState]);
|
||||
}, [webRTC, createRoomFromBackend, updateState]);
|
||||
|
||||
// 开始桌面共享(在接收方加入后)
|
||||
const startSharing = useCallback(async (): Promise<void> => {
|
||||
@@ -313,21 +342,6 @@ export function useDesktopShareBusiness() {
|
||||
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) {
|
||||
@@ -336,7 +350,7 @@ export function useDesktopShareBusiness() {
|
||||
updateState({ error: errorMessage, isViewing: false });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, handleRemoteStream, updateState]);
|
||||
}, [webRTC, updateState]);
|
||||
|
||||
// 停止观看桌面共享
|
||||
const stopViewing = useCallback(async (): Promise<void> => {
|
||||
@@ -403,5 +417,8 @@ export function useDesktopShareBusiness() {
|
||||
|
||||
// WebRTC连接状态
|
||||
webRTCError: webRTC.error,
|
||||
|
||||
// 暴露WebRTC连接对象
|
||||
webRTCConnection: webRTC,
|
||||
};
|
||||
}
|
||||
4
chuan-next/src/hooks/file-transfer/index.ts
Normal file
4
chuan-next/src/hooks/file-transfer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 文件传输相关的hooks
|
||||
export { useFileTransferBusiness } from './useFileTransferBusiness';
|
||||
export { useFileStateManager } from './useFileStateManager';
|
||||
export { useFileListSync } from './useFileListSync';
|
||||
65
chuan-next/src/hooks/file-transfer/useFileListSync.ts
Normal file
65
chuan-next/src/hooks/file-transfer/useFileListSync.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface UseFileListSyncProps {
|
||||
sendFileList: (fileInfos: FileInfo[]) => void;
|
||||
mode: 'send' | 'receive';
|
||||
pickupCode: string;
|
||||
isConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
getChannelState: () => string;
|
||||
}
|
||||
|
||||
export const useFileListSync = ({
|
||||
sendFileList,
|
||||
mode,
|
||||
pickupCode,
|
||||
isConnected,
|
||||
isPeerConnected,
|
||||
getChannelState
|
||||
}: UseFileListSyncProps) => {
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 统一的文件列表同步函数,带防抖功能
|
||||
const syncFileListToReceiver = useCallback((fileInfos: FileInfo[], reason: string) => {
|
||||
// 只有在发送模式、连接已建立且有房间时才发送文件列表
|
||||
if (mode !== 'send' || !pickupCode || !isConnected || !isPeerConnected) {
|
||||
console.log('跳过文件列表同步:', { mode, pickupCode: !!pickupCode, isConnected, isPeerConnected });
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的延时发送
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 延时发送,避免频繁发送
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
if (isPeerConnected && getChannelState() === 'open') {
|
||||
console.log(`发送文件列表到接收方 (${reason}):`, fileInfos.map(f => f.name));
|
||||
sendFileList(fileInfos);
|
||||
}
|
||||
}, 150);
|
||||
}, [mode, pickupCode, isConnected, isPeerConnected, getChannelState, sendFileList]);
|
||||
|
||||
// 清理防抖定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
syncFileListToReceiver
|
||||
};
|
||||
};
|
||||
166
chuan-next/src/hooks/file-transfer/useFileStateManager.ts
Normal file
166
chuan-next/src/hooks/file-transfer/useFileStateManager.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface UseFileStateManagerProps {
|
||||
mode: 'send' | 'receive';
|
||||
pickupCode: string;
|
||||
syncFileListToReceiver: (fileInfos: FileInfo[], reason: string) => void;
|
||||
isPeerConnected: boolean;
|
||||
}
|
||||
|
||||
export const useFileStateManager = ({
|
||||
mode,
|
||||
pickupCode,
|
||||
syncFileListToReceiver,
|
||||
isPeerConnected
|
||||
}: UseFileStateManagerProps) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [fileList, setFileList] = useState<FileInfo[]>([]);
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
|
||||
|
||||
// 生成文件ID
|
||||
const generateFileId = useCallback(() => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}, []);
|
||||
|
||||
// 文件选择处理
|
||||
const handleFileSelect = useCallback((files: File[]) => {
|
||||
console.log('=== 文件选择 ===');
|
||||
console.log('新文件:', files.map(f => f.name));
|
||||
|
||||
// 更新选中的文件
|
||||
setSelectedFiles(prev => [...prev, ...files]);
|
||||
|
||||
// 创建对应的文件信息
|
||||
const newFileInfos: FileInfo[] = files.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
setFileList(prev => {
|
||||
const updatedList = [...prev, ...newFileInfos];
|
||||
console.log('更新后的文件列表:', updatedList);
|
||||
return updatedList;
|
||||
});
|
||||
}, [generateFileId]);
|
||||
|
||||
// 清空文件
|
||||
const clearFiles = useCallback(() => {
|
||||
console.log('=== 清空文件 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
}, []);
|
||||
|
||||
// 重置状态
|
||||
const resetFiles = useCallback(() => {
|
||||
console.log('=== 重置文件状态 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
setDownloadedFiles(new Map());
|
||||
}, []);
|
||||
|
||||
// 更新文件状态
|
||||
const updateFileStatus = useCallback((fileId: string, status: FileInfo['status'], progress?: number) => {
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId
|
||||
? { ...item, status, progress: progress ?? item.progress }
|
||||
: item
|
||||
));
|
||||
}, []);
|
||||
|
||||
// 更新文件进度
|
||||
const updateFileProgress = useCallback((fileId: string, fileName: string, progress: number) => {
|
||||
const newStatus = progress >= 100 ? 'completed' as const : 'downloading' as const;
|
||||
setFileList(prev => prev.map(item => {
|
||||
if (item.id === fileId || item.name === fileName) {
|
||||
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${progress}`);
|
||||
return { ...item, progress, status: newStatus };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 数据通道第一次打开时初始化
|
||||
useEffect(() => {
|
||||
if (isPeerConnected && mode === 'send' && fileList.length > 0) {
|
||||
console.log('P2P连接已建立,数据通道首次打开,初始化文件列表');
|
||||
syncFileListToReceiver(fileList, '数据通道初始化');
|
||||
}
|
||||
}, [isPeerConnected, mode, syncFileListToReceiver]);
|
||||
|
||||
// 监听fileList大小变化并同步
|
||||
useEffect(() => {
|
||||
if (isPeerConnected && mode === 'send' && pickupCode) {
|
||||
console.log('fileList大小变化,同步到接收方:', fileList.length);
|
||||
syncFileListToReceiver(fileList, 'fileList大小变化');
|
||||
}
|
||||
}, [fileList.length, isPeerConnected, mode, pickupCode, syncFileListToReceiver]);
|
||||
|
||||
// 监听selectedFiles变化,同步更新fileList
|
||||
useEffect(() => {
|
||||
// 只有在发送模式下且已有房间时才处理文件列表同步
|
||||
if (mode !== 'send' || !pickupCode) return;
|
||||
|
||||
console.log('=== selectedFiles变化,同步文件列表 ===', {
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length,
|
||||
selectedFileNames: selectedFiles.map(f => f.name)
|
||||
});
|
||||
|
||||
// 根据selectedFiles创建新的文件信息列表
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
|
||||
// 尝试找到现有的文件信息,保持已有的状态
|
||||
const existingFileInfo = fileList.find(info => info.name === file.name && info.size === file.size);
|
||||
return existingFileInfo || {
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready' as const,
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
// 检查文件列表是否真正发生变化
|
||||
const fileListChanged =
|
||||
newFileInfos.length !== fileList.length ||
|
||||
newFileInfos.some(newFile =>
|
||||
!fileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
|
||||
);
|
||||
|
||||
if (fileListChanged) {
|
||||
console.log('文件列表发生变化,更新:', {
|
||||
before: fileList.map(f => f.name),
|
||||
after: newFileInfos.map(f => f.name)
|
||||
});
|
||||
|
||||
setFileList(newFileInfos);
|
||||
}
|
||||
}, [selectedFiles, mode, pickupCode, fileList, generateFileId]);
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
fileList,
|
||||
setFileList,
|
||||
downloadedFiles,
|
||||
setDownloadedFiles,
|
||||
handleFileSelect,
|
||||
clearFiles,
|
||||
resetFiles,
|
||||
updateFileStatus,
|
||||
updateFileProgress
|
||||
};
|
||||
};
|
||||
676
chuan-next/src/hooks/file-transfer/useFileTransferBusiness.ts
Normal file
676
chuan-next/src/hooks/file-transfer/useFileTransferBusiness.ts
Normal file
@@ -0,0 +1,676 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { WebRTCConnection } from '../connection/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 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 CHUNK_SIZE = 256 * 1024; // 256KB
|
||||
const MAX_RETRIES = 5; // 最大重试次数
|
||||
const RETRY_DELAY = 1000; // 重试延迟(毫秒)
|
||||
const ACK_TIMEOUT = 5000; // 确认超时(毫秒)
|
||||
|
||||
/**
|
||||
* 计算数据的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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成简单的校验和(备用方案)
|
||||
*/
|
||||
function simpleChecksum(data: ArrayBuffer): string {
|
||||
const buffer = new Uint8Array(data);
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(buffer.length, 1000); i++) {
|
||||
sum += buffer[i];
|
||||
}
|
||||
|
||||
return sum.toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件传输业务层
|
||||
* 必须传入共享的 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 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 / CHUNK_SIZE);
|
||||
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.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 sendChunkWithAck = useCallback(async (
|
||||
fileId: string,
|
||||
chunkIndex: number,
|
||||
chunkData: ArrayBuffer,
|
||||
checksum: string,
|
||||
retryCount = 0
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
// 主要检查数据通道状态,因为数据通道是文件传输的实际通道
|
||||
const channelState = connection.getChannelState();
|
||||
if (channelState === 'closed') {
|
||||
console.warn(`数据通道已关闭,停止发送文件块 ${chunkIndex}`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
|
||||
if (!connection.isConnected && channelState === 'connecting') {
|
||||
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
|
||||
}
|
||||
|
||||
const chunkKey = `${fileId}-${chunkIndex}`;
|
||||
|
||||
// 设置确认回调
|
||||
const ackCallback = (ack: ChunkAck) => {
|
||||
if (ack.success) {
|
||||
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.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 });
|
||||
|
||||
// 初始化传输状态
|
||||
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.getChannelState();
|
||||
if (channelState === 'closed') {
|
||||
console.warn(`数据通道已关闭,停止文件传输`);
|
||||
throw new Error('数据通道已关闭');
|
||||
}
|
||||
|
||||
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
|
||||
if (!connection.isConnected && channelState === 'connecting') {
|
||||
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
|
||||
}
|
||||
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
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 chunkSize = Math.min(CHUNK_SIZE, file.size - chunkIndex * CHUNK_SIZE);
|
||||
const expectedTime = (chunkSize / 1024) / status.averageSpeed;
|
||||
const actualTime = Date.now() - status.lastChunkTime;
|
||||
const delay = Math.max(0, expectedTime - actualTime);
|
||||
|
||||
if (delay > 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(delay, 100)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.getChannelState();
|
||||
const peerConnected = connection.isPeerConnected;
|
||||
|
||||
console.log('发送文件列表检查:', {
|
||||
channelState,
|
||||
peerConnected,
|
||||
fileListLength: fileList.length
|
||||
});
|
||||
|
||||
// 如果数据通道已打开或者 P2P 已连接,就可以发送文件列表
|
||||
if (channelState === 'open' || peerConnected) {
|
||||
console.log('发送文件列表:', fileList);
|
||||
|
||||
connection.sendMessage({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
}, CHANNEL_NAME);
|
||||
} else {
|
||||
console.log('P2P连接未建立,等待连接后再发送文件列表');
|
||||
}
|
||||
}, [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,
|
||||
};
|
||||
}
|
||||
19
chuan-next/src/hooks/index.ts
Normal file
19
chuan-next/src/hooks/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// 按功能分类的hooks导出
|
||||
|
||||
// 连接相关
|
||||
export * from './connection';
|
||||
|
||||
// 文件传输相关
|
||||
export * from './file-transfer';
|
||||
|
||||
// 桌面共享相关
|
||||
export * from './desktop-share';
|
||||
|
||||
// 文本传输相关
|
||||
export * from './text-transfer';
|
||||
|
||||
// UI状态管理相关
|
||||
export * from './ui';
|
||||
|
||||
// 核心WebRTC功能
|
||||
export * from './webrtc';
|
||||
2
chuan-next/src/hooks/text-transfer/index.ts
Normal file
2
chuan-next/src/hooks/text-transfer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// 文本传输相关的hooks
|
||||
export { useTextTransferBusiness } from './useTextTransferBusiness';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { WebRTCConnection } from './useSharedWebRTCManager';
|
||||
import type { WebRTCConnection } from '../connection/useSharedWebRTCManager';
|
||||
|
||||
// 文本传输状态
|
||||
interface TextTransferState {
|
||||
5
chuan-next/src/hooks/ui/index.ts
Normal file
5
chuan-next/src/hooks/ui/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// UI状态管理相关的hooks
|
||||
export { useURLHandler } from './useURLHandler';
|
||||
export { useWebRTCStore } from './webRTCStore';
|
||||
export { useTabNavigation } from './useTabNavigation';
|
||||
export type { TabType } from './useTabNavigation';
|
||||
52
chuan-next/src/hooks/ui/useConfirmDialog.ts
Normal file
52
chuan-next/src/hooks/ui/useConfirmDialog.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
171
chuan-next/src/hooks/ui/useTabNavigation.ts
Normal file
171
chuan-next/src/hooks/ui/useTabNavigation.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useURLHandler, FeatureType } from './useURLHandler';
|
||||
import { useWebRTCStore } from './webRTCStore';
|
||||
import { useConfirmDialog } from './useConfirmDialog';
|
||||
|
||||
// Tab类型定义(包括非WebRTC功能)
|
||||
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat';
|
||||
|
||||
// Tab显示名称
|
||||
const TAB_NAMES: Record<TabType, string> = {
|
||||
webrtc: '文件传输',
|
||||
message: '文字传输',
|
||||
desktop: '桌面共享',
|
||||
wechat: '微信群'
|
||||
};
|
||||
|
||||
// WebRTC功能的映射
|
||||
const WEBRTC_FEATURES: Record<string, FeatureType> = {
|
||||
webrtc: 'webrtc',
|
||||
message: 'message',
|
||||
desktop: 'desktop'
|
||||
};
|
||||
|
||||
export const useTabNavigation = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('webrtc');
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
|
||||
|
||||
// 获取WebRTC全局状态
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom,
|
||||
reset: resetWebRTCState
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 创建一个通用的URL处理器(用于断开连接)
|
||||
const { hasActiveConnection } = useURLHandler({
|
||||
featureType: 'webrtc', // 默认值,实际使用时会被覆盖
|
||||
onModeChange: () => {},
|
||||
});
|
||||
|
||||
// 根据URL参数设置初始标签(仅首次加载时)
|
||||
useEffect(() => {
|
||||
if (!hasInitialized) {
|
||||
const urlType = searchParams.get('type');
|
||||
|
||||
console.log('=== HomePage URL处理 ===');
|
||||
console.log('URL type参数:', urlType);
|
||||
console.log('所有搜索参数:', Object.fromEntries(searchParams.entries()));
|
||||
|
||||
// 将旧的text类型重定向到message
|
||||
if (urlType === 'text') {
|
||||
console.log('检测到text类型,重定向到message标签页');
|
||||
setActiveTab('message');
|
||||
} else if (urlType === 'webrtc') {
|
||||
console.log('检测到webrtc类型,切换到webrtc标签页(文件传输)');
|
||||
setActiveTab('webrtc');
|
||||
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
|
||||
console.log('切换到对应标签页:', urlType);
|
||||
setActiveTab(urlType as TabType);
|
||||
} else {
|
||||
console.log('没有有效的type参数,使用默认标签页:webrtc(文件传输)');
|
||||
// 保持默认的webrtc标签
|
||||
}
|
||||
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [searchParams, hasInitialized]);
|
||||
|
||||
// 处理tab切换
|
||||
const handleTabChange = useCallback(async (newTab: TabType) => {
|
||||
console.log('=== Tab切换 ===');
|
||||
console.log('当前tab:', activeTab, '目标tab:', newTab);
|
||||
|
||||
// 如果切换到wechat tab(非WebRTC功能),可以直接切换
|
||||
if (newTab === 'wechat') {
|
||||
// 如果有活跃连接,需要确认
|
||||
if (hasActiveConnection()) {
|
||||
const currentTabName = TAB_NAMES[activeTab];
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到微信群功能需要关闭当前的${currentTabName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 断开连接并清除状态
|
||||
resetWebRTCState();
|
||||
console.log('已清除WebRTC连接状态,切换到微信群');
|
||||
}
|
||||
|
||||
setActiveTab(newTab);
|
||||
// 清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果有活跃连接且切换到不同的WebRTC功能,需要确认
|
||||
if (hasActiveConnection() && newTab !== activeTab && WEBRTC_FEATURES[newTab]) {
|
||||
const currentTabName = TAB_NAMES[activeTab];
|
||||
const targetTabName = TAB_NAMES[newTab];
|
||||
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到${targetTabName}功能需要关闭当前的${currentTabName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用户确认后,重置WebRTC状态
|
||||
resetWebRTCState();
|
||||
console.log(`已断开${currentTabName}连接,切换到${targetTabName}`);
|
||||
}
|
||||
|
||||
// 执行tab切换
|
||||
setActiveTab(newTab);
|
||||
|
||||
// 更新URL(对于WebRTC功能)
|
||||
if (WEBRTC_FEATURES[newTab]) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', WEBRTC_FEATURES[newTab]);
|
||||
params.set('mode', 'send'); // 默认模式
|
||||
const newUrl = `?${params.toString()}`;
|
||||
window.history.pushState({}, '', newUrl);
|
||||
} else {
|
||||
// 非WebRTC功能,清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeTab, hasActiveConnection, resetWebRTCState]);
|
||||
|
||||
// 获取连接状态信息
|
||||
const getConnectionInfo = useCallback(() => {
|
||||
return {
|
||||
hasConnection: hasActiveConnection(),
|
||||
currentRoom: currentRoom,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected
|
||||
};
|
||||
}, [hasActiveConnection, currentRoom, isConnected, isConnecting, isPeerConnected]);
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
handleTabChange,
|
||||
getConnectionInfo,
|
||||
hasInitialized,
|
||||
// 导出确认对话框状态
|
||||
confirmDialogState: dialogState,
|
||||
closeConfirmDialog: closeDialog
|
||||
};
|
||||
};
|
||||
210
chuan-next/src/hooks/ui/useURLHandler.ts
Normal file
210
chuan-next/src/hooks/ui/useURLHandler.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useWebRTCStore } from './webRTCStore';
|
||||
import { useConfirmDialog } from './useConfirmDialog';
|
||||
|
||||
// 支持的功能类型
|
||||
export type FeatureType = 'webrtc' | 'message' | 'desktop';
|
||||
|
||||
// 功能类型的显示名称
|
||||
const FEATURE_NAMES: Record<FeatureType, string> = {
|
||||
webrtc: '文件传输',
|
||||
message: '文字传输',
|
||||
desktop: '桌面共享'
|
||||
};
|
||||
|
||||
interface UseURLHandlerProps<T = string> {
|
||||
featureType: FeatureType;
|
||||
onModeChange: (mode: T) => void;
|
||||
onAutoJoinRoom?: (code: string) => void;
|
||||
onDisconnect?: () => void; // 新增:断开连接的回调
|
||||
modeConverter?: {
|
||||
// 将URL模式转换为组件内部模式
|
||||
fromURL: (urlMode: 'send' | 'receive') => T;
|
||||
// 将组件内部模式转换为URL模式
|
||||
toURL: (componentMode: T) => 'send' | 'receive';
|
||||
};
|
||||
}
|
||||
|
||||
export const useURLHandler = <T = 'send' | 'receive'>({
|
||||
featureType,
|
||||
onModeChange,
|
||||
onAutoJoinRoom,
|
||||
onDisconnect,
|
||||
modeConverter
|
||||
}: UseURLHandlerProps<T>) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const urlProcessedRef = useRef(false);
|
||||
|
||||
// 获取WebRTC全局状态
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom,
|
||||
reset: resetWebRTCState
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 检查是否有活跃连接
|
||||
const hasActiveConnection = useCallback(() => {
|
||||
return isConnected || isConnecting || isPeerConnected;
|
||||
}, [isConnected, isConnecting, isPeerConnected]);
|
||||
|
||||
// 功能切换确认
|
||||
const switchToFeature = useCallback(async (targetFeatureType: FeatureType, mode?: 'send' | 'receive', code?: string) => {
|
||||
// 如果是同一个功能,直接切换
|
||||
if (targetFeatureType === featureType) {
|
||||
if (mode) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', targetFeatureType);
|
||||
params.set('mode', mode);
|
||||
if (code) {
|
||||
params.set('code', code);
|
||||
} else if (mode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果有活跃连接,需要确认
|
||||
if (hasActiveConnection()) {
|
||||
const currentFeatureName = FEATURE_NAMES[featureType];
|
||||
const targetFeatureName = FEATURE_NAMES[targetFeatureType];
|
||||
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到${targetFeatureName}功能需要关闭当前的${currentFeatureName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用户确认后,断开当前连接
|
||||
try {
|
||||
if (onDisconnect) {
|
||||
await onDisconnect();
|
||||
}
|
||||
resetWebRTCState();
|
||||
showToast(`已断开${currentFeatureName}连接`, 'info');
|
||||
} catch (error) {
|
||||
console.error('断开连接时出错:', error);
|
||||
showToast('断开连接时出错,但将继续切换功能', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到新功能
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', targetFeatureType);
|
||||
if (mode) {
|
||||
params.set('mode', mode);
|
||||
}
|
||||
if (code) {
|
||||
params.set('code', code);
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
return true;
|
||||
}, [featureType, hasActiveConnection, onDisconnect, resetWebRTCState, showToast, router, searchParams, showConfirmDialog]);
|
||||
|
||||
// 从URL参数中获取初始模式(仅在首次加载时处理)
|
||||
useEffect(() => {
|
||||
// 使用 ref 确保只处理一次,避免严格模式的重复调用
|
||||
if (urlProcessedRef.current) {
|
||||
console.log('URL已处理过,跳过重复处理');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type') as FeatureType;
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// 只在首次加载且URL中有对应功能类型时处理
|
||||
if (!hasProcessedInitialUrl && type === featureType && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log(`=== 处理初始URL参数 [${featureType}] ===`);
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
// 立即标记为已处理,防止重复
|
||||
urlProcessedRef.current = true;
|
||||
|
||||
// 转换模式(如果有转换器的话)
|
||||
const componentMode = modeConverter ? modeConverter.fromURL(urlMode) : urlMode as T;
|
||||
onModeChange(componentMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
|
||||
// 自动加入房间(只在receive模式且有code时)
|
||||
if (code && urlMode === 'receive' && onAutoJoinRoom) {
|
||||
console.log('URL中有取件码,自动加入房间');
|
||||
onAutoJoinRoom(code);
|
||||
}
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl, featureType, onModeChange, onAutoJoinRoom, modeConverter]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: T) => {
|
||||
console.log(`=== 手动切换模式 [${featureType}] ===`);
|
||||
console.log('新模式:', newMode);
|
||||
|
||||
onModeChange(newMode);
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', featureType);
|
||||
|
||||
// 转换模式(如果有转换器的话)
|
||||
const urlMode = modeConverter ? modeConverter.toURL(newMode) : newMode as string;
|
||||
params.set('mode', urlMode);
|
||||
|
||||
// 如果切换到发送模式,移除code参数
|
||||
if (urlMode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router, featureType, onModeChange, modeConverter]);
|
||||
|
||||
// 更新URL中的房间代码
|
||||
const updateRoomCode = useCallback((code: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', featureType);
|
||||
params.set('code', code);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router, featureType]);
|
||||
|
||||
// 获取当前URL中的房间代码
|
||||
const getCurrentRoomCode = useCallback(() => {
|
||||
return searchParams.get('code') || '';
|
||||
}, [searchParams]);
|
||||
|
||||
// 清除URL参数
|
||||
const clearURLParams = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('type');
|
||||
params.delete('mode');
|
||||
params.delete('code');
|
||||
|
||||
const newURL = params.toString() ? `?${params.toString()}` : '/';
|
||||
router.push(newURL, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
return {
|
||||
updateMode,
|
||||
updateRoomCode,
|
||||
getCurrentRoomCode,
|
||||
clearURLParams,
|
||||
switchToFeature,
|
||||
hasActiveConnection,
|
||||
// 导出对话框状态供组件使用
|
||||
confirmDialogState: dialogState,
|
||||
closeConfirmDialog: closeDialog
|
||||
};
|
||||
};
|
||||
46
chuan-next/src/hooks/ui/webRTCStore.ts
Normal file
46
chuan-next/src/hooks/ui/webRTCStore.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface WebRTCState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
error: string | null;
|
||||
canRetry: boolean; // 新增:是否可以重试
|
||||
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
}
|
||||
|
||||
interface WebRTCStore extends WebRTCState {
|
||||
updateState: (updates: Partial<WebRTCState>) => void;
|
||||
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
|
||||
reset: () => void;
|
||||
resetToInitial: () => void; // 新增:完全重置到初始状态
|
||||
}
|
||||
|
||||
const initialState: WebRTCState = {
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false, // 初始状态下不需要重试
|
||||
currentRoom: null,
|
||||
};
|
||||
|
||||
export const useWebRTCStore = create<WebRTCStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
updateState: (updates) => set((state) => ({
|
||||
...state,
|
||||
...updates,
|
||||
})),
|
||||
|
||||
setCurrentRoom: (room) => set((state) => ({
|
||||
...state,
|
||||
currentRoom: room,
|
||||
})),
|
||||
|
||||
reset: () => set(initialState),
|
||||
|
||||
resetToInitial: () => set(initialState), // 完全重置到初始状态
|
||||
}));
|
||||
227
chuan-next/src/hooks/webrtc/DataChannelManager.ts
Normal file
227
chuan-next/src/hooks/webrtc/DataChannelManager.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler, MessageHandler, DataHandler } from './types';
|
||||
|
||||
interface DataChannelManagerConfig {
|
||||
channelName: string;
|
||||
onMessage?: MessageHandler;
|
||||
onData?: DataHandler;
|
||||
ordered?: boolean;
|
||||
maxRetransmits?: number;
|
||||
}
|
||||
|
||||
export class DataChannelManager extends EventEmitter {
|
||||
private dataChannel: RTCDataChannel | null = null;
|
||||
private config: DataChannelManagerConfig;
|
||||
private messageQueue: WebRTCMessage[] = [];
|
||||
private dataQueue: ArrayBuffer[] = [];
|
||||
private isReady = false;
|
||||
|
||||
constructor(config: DataChannelManagerConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
ordered: true,
|
||||
maxRetransmits: 3,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
initializeDataChannel(dataChannel: RTCDataChannel): void {
|
||||
this.dataChannel = dataChannel;
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
if (this.dataChannel) {
|
||||
throw new WebRTCError('DC_ALREADY_EXISTS', '数据通道已存在', false);
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataChannel = pc.createDataChannel(this.config.channelName, {
|
||||
ordered: this.config.ordered,
|
||||
maxRetransmits: this.config.maxRetransmits,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
return this.dataChannel;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('DC_CREATE_FAILED', '创建数据通道失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.dataChannel) return;
|
||||
|
||||
this.dataChannel.onopen = () => {
|
||||
console.log(`[DataChannel] 数据通道已打开: ${this.config.channelName}`);
|
||||
this.isReady = true;
|
||||
this.flushQueues();
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
|
||||
};
|
||||
|
||||
this.dataChannel.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebRTCMessage;
|
||||
console.log(`[DataChannel] 收到消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
|
||||
|
||||
if (this.config.onMessage) {
|
||||
this.config.onMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DataChannel] 解析消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
|
||||
});
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log(`[DataChannel] 收到数据: ${event.data.byteLength} bytes`);
|
||||
|
||||
if (this.config.onData) {
|
||||
this.config.onData(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.dataChannel.onerror = (error) => {
|
||||
console.error(`[DataChannel] 数据通道错误: ${this.config.channelName}`, error);
|
||||
|
||||
const errorMessage = this.getDetailedErrorMessage();
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_ERROR', errorMessage, true)
|
||||
});
|
||||
};
|
||||
|
||||
this.dataChannel.onclose = () => {
|
||||
console.log(`[DataChannel] 数据通道已关闭: ${this.config.channelName}`);
|
||||
this.isReady = false;
|
||||
this.emit('disconnected', {
|
||||
type: 'disconnected',
|
||||
reason: `数据通道关闭: ${this.config.channelName}`
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private getDetailedErrorMessage(): string {
|
||||
if (!this.dataChannel) return '数据通道不可用';
|
||||
|
||||
switch (this.dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
return '数据通道正在连接中,请稍候...';
|
||||
case 'closing':
|
||||
return '数据通道正在关闭,连接即将断开';
|
||||
case 'closed':
|
||||
return '数据通道已关闭,P2P连接失败';
|
||||
default:
|
||||
return '数据通道连接失败,可能是网络环境受限';
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(message: WebRTCMessage): boolean {
|
||||
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
|
||||
this.messageQueue.push(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataChannel.send(JSON.stringify(message));
|
||||
console.log(`[DataChannel] 发送消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannel] 发送消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_SEND_ERROR', '发送消息失败', true, error as Error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sendData(data: ArrayBuffer): boolean {
|
||||
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
|
||||
this.dataQueue.push(data);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataChannel.send(data);
|
||||
console.log(`[DataChannel] 发送数据: ${data.byteLength} bytes`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannel] 发送数据失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_SEND_DATA_ERROR', '发送数据失败', true, error as Error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueues(): void {
|
||||
// 发送排队的消息
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送排队的数据
|
||||
while (this.dataQueue.length > 0) {
|
||||
const data = this.dataQueue.shift();
|
||||
if (data) {
|
||||
this.sendData(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getState(): RTCDataChannelState {
|
||||
return this.dataChannel?.readyState || 'closed';
|
||||
}
|
||||
|
||||
isChannelReady(): boolean {
|
||||
return this.isReady && this.dataChannel?.readyState === 'open';
|
||||
}
|
||||
|
||||
getBufferedAmount(): number {
|
||||
return this.dataChannel?.bufferedAmount || 0;
|
||||
}
|
||||
|
||||
getBufferedAmountLowThreshold(): number {
|
||||
return this.dataChannel?.bufferedAmountLowThreshold || 0;
|
||||
}
|
||||
|
||||
setBufferedAmountLowThreshold(threshold: number): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.bufferedAmountLowThreshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
onBufferedAmountLow(handler: () => void): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.onbufferedamountlow = handler;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.close();
|
||||
this.dataChannel = null;
|
||||
}
|
||||
|
||||
this.isReady = false;
|
||||
this.messageQueue = [];
|
||||
this.dataQueue = [];
|
||||
|
||||
this.emit('disconnected', { type: 'disconnected', reason: '数据通道已关闭' });
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler<ConnectionEvent>): this {
|
||||
return super.on(event, handler);
|
||||
}
|
||||
|
||||
emit(eventName: string, event?: ConnectionEvent): boolean {
|
||||
return super.emit(eventName, event);
|
||||
}
|
||||
}
|
||||
197
chuan-next/src/hooks/webrtc/MessageRouter.ts
Normal file
197
chuan-next/src/hooks/webrtc/MessageRouter.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { WebRTCMessage, MessageHandler, DataHandler } from './types';
|
||||
|
||||
interface ChannelHandlers {
|
||||
messageHandlers: Set<MessageHandler>;
|
||||
dataHandlers: Set<DataHandler>;
|
||||
}
|
||||
|
||||
export class MessageRouter {
|
||||
private channels = new Map<string, ChannelHandlers>();
|
||||
private defaultChannelHandlers: ChannelHandlers | null = null;
|
||||
|
||||
constructor() {
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
private createDefaultChannel(): void {
|
||||
this.defaultChannelHandlers = {
|
||||
messageHandlers: new Set(),
|
||||
dataHandlers: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
|
||||
let channelHandlers = this.channels.get(channel);
|
||||
|
||||
if (!channelHandlers) {
|
||||
channelHandlers = {
|
||||
messageHandlers: new Set(),
|
||||
dataHandlers: new Set(),
|
||||
};
|
||||
this.channels.set(channel, channelHandlers);
|
||||
}
|
||||
|
||||
channelHandlers.messageHandlers.add(handler);
|
||||
|
||||
// 返回取消注册函数
|
||||
return () => {
|
||||
channelHandlers!.messageHandlers.delete(handler);
|
||||
|
||||
// 如果通道没有处理器了,删除通道
|
||||
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerDataHandler(channel: string, handler: DataHandler): () => void {
|
||||
let channelHandlers = this.channels.get(channel);
|
||||
|
||||
if (!channelHandlers) {
|
||||
channelHandlers = {
|
||||
messageHandlers: new Set(),
|
||||
dataHandlers: new Set(),
|
||||
};
|
||||
this.channels.set(channel, channelHandlers);
|
||||
}
|
||||
|
||||
channelHandlers.dataHandlers.add(handler);
|
||||
|
||||
// 返回取消注册函数
|
||||
return () => {
|
||||
channelHandlers!.dataHandlers.delete(handler);
|
||||
|
||||
// 如果通道没有处理器了,删除通道
|
||||
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerDefaultMessageHandler(handler: MessageHandler): () => void {
|
||||
if (!this.defaultChannelHandlers) {
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
this.defaultChannelHandlers?.messageHandlers.add(handler);
|
||||
|
||||
return () => {
|
||||
this.defaultChannelHandlers?.messageHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
registerDefaultDataHandler(handler: DataHandler): () => void {
|
||||
if (!this.defaultChannelHandlers) {
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
this.defaultChannelHandlers?.dataHandlers.add(handler);
|
||||
|
||||
return () => {
|
||||
this.defaultChannelHandlers?.dataHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
routeMessage(message: WebRTCMessage): void {
|
||||
const channel = message.channel;
|
||||
|
||||
if (channel) {
|
||||
// 路由到特定通道
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
if (channelHandlers && channelHandlers.messageHandlers.size > 0) {
|
||||
channelHandlers.messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error(`消息处理器错误 (通道: ${channel}):`, error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到默认处理器
|
||||
if (this.defaultChannelHandlers?.messageHandlers.size) {
|
||||
this.defaultChannelHandlers.messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error('默认消息处理器错误:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('没有找到消息处理器:', message.type, channel || 'default');
|
||||
}
|
||||
}
|
||||
|
||||
routeData(data: ArrayBuffer, channel?: string): void {
|
||||
if (channel) {
|
||||
// 路由到特定通道
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
if (channelHandlers && channelHandlers.dataHandlers.size > 0) {
|
||||
channelHandlers.dataHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`数据处理器错误 (通道: ${channel}):`, error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到默认处理器
|
||||
if (this.defaultChannelHandlers?.dataHandlers.size) {
|
||||
this.defaultChannelHandlers.dataHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error('默认数据处理器错误:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('没有找到数据处理器,数据大小:', data.byteLength, 'bytes');
|
||||
}
|
||||
}
|
||||
|
||||
hasHandlers(channel?: string): boolean {
|
||||
if (channel) {
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
return channelHandlers ?
|
||||
(channelHandlers.messageHandlers.size > 0 || channelHandlers.dataHandlers.size > 0) :
|
||||
false;
|
||||
}
|
||||
|
||||
return this.defaultChannelHandlers ?
|
||||
(this.defaultChannelHandlers.messageHandlers.size > 0 || this.defaultChannelHandlers.dataHandlers.size > 0) :
|
||||
false;
|
||||
}
|
||||
|
||||
getChannelList(): string[] {
|
||||
return Array.from(this.channels.keys());
|
||||
}
|
||||
|
||||
getHandlerCount(channel?: string): { message: number; data: number } {
|
||||
if (channel) {
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
return channelHandlers ? {
|
||||
message: channelHandlers.messageHandlers.size,
|
||||
data: channelHandlers.dataHandlers.size,
|
||||
} : { message: 0, data: 0 };
|
||||
}
|
||||
|
||||
return this.defaultChannelHandlers ? {
|
||||
message: this.defaultChannelHandlers.messageHandlers.size,
|
||||
data: this.defaultChannelHandlers.dataHandlers.size,
|
||||
} : { message: 0, data: 0 };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.channels.clear();
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
clearChannel(channel: string): void {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
293
chuan-next/src/hooks/webrtc/PeerConnectionManager.ts
Normal file
293
chuan-next/src/hooks/webrtc/PeerConnectionManager.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebRTCError, WebRTCConfig, ConnectionEvent, EventHandler } from './types';
|
||||
|
||||
interface PeerConnectionManagerConfig extends WebRTCConfig {
|
||||
onSignalingMessage: (message: any) => void;
|
||||
onTrack?: (event: RTCTrackEvent) => void;
|
||||
}
|
||||
|
||||
interface NegotiationOptions {
|
||||
offerToReceiveAudio?: boolean;
|
||||
offerToReceiveVideo?: boolean;
|
||||
}
|
||||
|
||||
export class PeerConnectionManager extends EventEmitter {
|
||||
private pc: RTCPeerConnection | null = null;
|
||||
private config: PeerConnectionManagerConfig;
|
||||
private isNegotiating = false;
|
||||
private negotiationQueue: Array<() => Promise<void>> = [];
|
||||
private localCandidates: RTCIceCandidate[] = [];
|
||||
private remoteCandidates: RTCIceCandidate[] = [];
|
||||
|
||||
constructor(config: PeerConnectionManagerConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async createPeerConnection(): Promise<RTCPeerConnection> {
|
||||
if (this.pc) {
|
||||
this.destroyPeerConnection();
|
||||
}
|
||||
|
||||
try {
|
||||
this.pc = new RTCPeerConnection({
|
||||
iceServers: this.config.iceServers,
|
||||
iceCandidatePoolSize: this.config.iceCandidatePoolSize,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
|
||||
return this.pc;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('PC_CREATE_FAILED', '创建PeerConnection失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.pc) return;
|
||||
|
||||
this.pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.localCandidates.push(event.candidate);
|
||||
this.config.onSignalingMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
} else {
|
||||
console.log('[PeerConnection] ICE收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.oniceconnectionstatechange = () => {
|
||||
console.log('[PeerConnection] ICE连接状态:', this.pc!.iceConnectionState);
|
||||
|
||||
switch (this.pc!.iceConnectionState) {
|
||||
case 'connected':
|
||||
case 'completed':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
|
||||
break;
|
||||
case 'failed':
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('ICE_FAILED', 'ICE连接失败', true)
|
||||
});
|
||||
break;
|
||||
case 'disconnected':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onconnectionstatechange = () => {
|
||||
console.log('[PeerConnection] 连接状态:', this.pc!.connectionState);
|
||||
|
||||
switch (this.pc!.connectionState) {
|
||||
case 'connected':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
|
||||
break;
|
||||
case 'failed':
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('CONNECTION_FAILED', 'WebRTC连接失败', true)
|
||||
});
|
||||
break;
|
||||
case 'disconnected':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.ontrack = (event) => {
|
||||
console.log('[PeerConnection] 收到轨道:', event.track.kind);
|
||||
if (this.config.onTrack) {
|
||||
this.config.onTrack(event);
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onsignalingstatechange = () => {
|
||||
console.log('[PeerConnection] 信令状态:', this.pc!.signalingState);
|
||||
if (this.pc!.signalingState === 'stable') {
|
||||
this.isNegotiating = false;
|
||||
this.processNegotiationQueue();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async createOffer(options: NegotiationOptions = {}): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
const offerOptions: RTCOfferOptions = {
|
||||
offerToReceiveAudio: options.offerToReceiveAudio ?? true,
|
||||
offerToReceiveVideo: options.offerToReceiveVideo ?? true,
|
||||
};
|
||||
|
||||
const offer = await this.pc.createOffer(offerOptions);
|
||||
await this.pc.setLocalDescription(offer);
|
||||
|
||||
return offer;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('OFFER_FAILED', '创建Offer失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
const answer = await this.pc.createAnswer();
|
||||
await this.pc.setLocalDescription(answer);
|
||||
|
||||
return answer;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('ANSWER_FAILED', '创建Answer失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pc.setRemoteDescription(description);
|
||||
|
||||
// 添加缓存的远程候选
|
||||
for (const candidate of this.remoteCandidates) {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
}
|
||||
this.remoteCandidates = [];
|
||||
} catch (error) {
|
||||
throw new WebRTCError('REMOTE_DESC_FAILED', '设置远程描述失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (!this.pc) {
|
||||
this.remoteCandidates.push(new RTCIceCandidate(candidate));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
} catch (error) {
|
||||
console.warn('添加ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
return this.pc.addTrack(track, stream);
|
||||
} catch (error) {
|
||||
throw new WebRTCError('ADD_TRACK_FAILED', '添加轨道失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
removeTrack(sender: RTCRtpSender): void {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
this.pc.removeTrack(sender);
|
||||
} catch (error) {
|
||||
throw new WebRTCError('REMOVE_TRACK_FAILED', '移除轨道失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
return this.pc.createDataChannel(label, options);
|
||||
}
|
||||
|
||||
async renegotiate(options: NegotiationOptions = {}): Promise<void> {
|
||||
if (!this.pc || this.isNegotiating) {
|
||||
this.negotiationQueue.push(() => this.doRenegotiate(options));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.doRenegotiate(options);
|
||||
}
|
||||
|
||||
private async doRenegotiate(options: NegotiationOptions): Promise<void> {
|
||||
if (!this.pc || this.isNegotiating) return;
|
||||
|
||||
this.isNegotiating = true;
|
||||
|
||||
try {
|
||||
const offer = await this.createOffer(options);
|
||||
this.config.onSignalingMessage({
|
||||
type: 'offer',
|
||||
payload: offer
|
||||
});
|
||||
} catch (error) {
|
||||
this.isNegotiating = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private processNegotiationQueue(): void {
|
||||
if (this.negotiationQueue.length === 0) return;
|
||||
|
||||
const nextNegotiation = this.negotiationQueue.shift();
|
||||
if (nextNegotiation) {
|
||||
nextNegotiation().catch(error => {
|
||||
console.error('处理协商队列失败:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): Promise<RTCStatsReport> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
return this.pc.getStats();
|
||||
}
|
||||
|
||||
getConnectionState(): RTCPeerConnectionState {
|
||||
return this.pc?.connectionState || 'closed';
|
||||
}
|
||||
|
||||
getIceConnectionState(): RTCIceConnectionState {
|
||||
return this.pc?.iceConnectionState || 'closed';
|
||||
}
|
||||
|
||||
getSignalingState(): RTCSignalingState {
|
||||
return this.pc?.signalingState || 'stable';
|
||||
}
|
||||
|
||||
destroyPeerConnection(): void {
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
this.isNegotiating = false;
|
||||
this.negotiationQueue = [];
|
||||
this.localCandidates = [];
|
||||
this.remoteCandidates = [];
|
||||
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler<ConnectionEvent>): this {
|
||||
return super.on(event, handler);
|
||||
}
|
||||
|
||||
emit(eventName: string, event?: ConnectionEvent): boolean {
|
||||
return super.emit(eventName, event);
|
||||
}
|
||||
}
|
||||
455
chuan-next/src/hooks/webrtc/WebRTCManager.ts
Normal file
455
chuan-next/src/hooks/webrtc/WebRTCManager.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocketManager } from './WebSocketManager';
|
||||
import { PeerConnectionManager } from './PeerConnectionManager';
|
||||
import { DataChannelManager } from './DataChannelManager';
|
||||
import { MessageRouter } from './MessageRouter';
|
||||
import {
|
||||
WebRTCConnectionState,
|
||||
WebRTCMessage,
|
||||
WebRTCConfig,
|
||||
WebRTCError,
|
||||
MessageHandler,
|
||||
DataHandler
|
||||
} from './types';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
|
||||
interface WebRTCManagerConfig extends Partial<WebRTCConfig> {
|
||||
dataChannelName?: string;
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
interface SignalingMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export class WebRTCManager extends EventEmitter {
|
||||
private wsManager: WebSocketManager;
|
||||
private pcManager: PeerConnectionManager;
|
||||
private dcManager: DataChannelManager;
|
||||
private messageRouter: MessageRouter;
|
||||
private config: WebRTCManagerConfig;
|
||||
|
||||
private state: WebRTCConnectionState;
|
||||
private currentRoom: { code: string; role: 'sender' | 'receiver' } | null = null;
|
||||
private isUserDisconnecting = false;
|
||||
private abortController = new AbortController();
|
||||
|
||||
constructor(config: WebRTCManagerConfig = {}) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:global.stun.twilio.com:3478' },
|
||||
],
|
||||
iceCandidatePoolSize: 10,
|
||||
chunkSize: 256 * 1024,
|
||||
maxRetries: 5,
|
||||
retryDelay: 1000,
|
||||
ackTimeout: 5000,
|
||||
dataChannelName: 'shared-channel',
|
||||
enableLogging: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.state = {
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false,
|
||||
currentRoom: null,
|
||||
};
|
||||
|
||||
// 初始化各个管理器
|
||||
this.wsManager = new WebSocketManager({
|
||||
url: '',
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.pcManager = new PeerConnectionManager({
|
||||
iceServers: this.config.iceServers!,
|
||||
iceCandidatePoolSize: this.config.iceCandidatePoolSize!,
|
||||
chunkSize: this.config.chunkSize!,
|
||||
maxRetries: this.config.maxRetries!,
|
||||
retryDelay: this.config.retryDelay!,
|
||||
ackTimeout: this.config.ackTimeout!,
|
||||
onSignalingMessage: this.handleSignalingMessage.bind(this),
|
||||
onTrack: this.handleTrack.bind(this),
|
||||
});
|
||||
|
||||
this.dcManager = new DataChannelManager({
|
||||
channelName: this.config.dataChannelName!,
|
||||
onMessage: this.handleDataChannelMessage.bind(this),
|
||||
onData: this.handleDataChannelData.bind(this),
|
||||
});
|
||||
|
||||
this.messageRouter = new MessageRouter();
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// WebSocket 事件处理
|
||||
this.wsManager.on('connecting', (event) => {
|
||||
this.updateState({ isConnecting: true, error: null });
|
||||
});
|
||||
|
||||
this.wsManager.on('connected', (event) => {
|
||||
this.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnecting: false,
|
||||
isConnected: true
|
||||
});
|
||||
});
|
||||
|
||||
this.wsManager.on('disconnected', (event: any) => {
|
||||
this.updateState({ isWebSocketConnected: false });
|
||||
|
||||
if (!this.isUserDisconnecting) {
|
||||
this.updateState({
|
||||
error: event.reason,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.wsManager.on('error', (event: any) => {
|
||||
this.updateState({
|
||||
error: event.error.message,
|
||||
canRetry: event.error.retryable
|
||||
});
|
||||
});
|
||||
|
||||
this.wsManager.on('message', (message: any) => {
|
||||
this.handleWebSocketMessage(message);
|
||||
});
|
||||
|
||||
// PeerConnection 事件处理
|
||||
this.pcManager.on('state-change', (event: any) => {
|
||||
this.updateState(event.state);
|
||||
});
|
||||
|
||||
this.pcManager.on('error', (event: any) => {
|
||||
this.updateState({
|
||||
error: event.error.message,
|
||||
canRetry: event.error.retryable
|
||||
});
|
||||
});
|
||||
|
||||
// DataChannel 事件处理
|
||||
this.dcManager.on('state-change', (event: any) => {
|
||||
this.updateState(event.state);
|
||||
});
|
||||
|
||||
this.dcManager.on('error', (event: any) => {
|
||||
this.updateState({
|
||||
error: event.error.message,
|
||||
canRetry: event.error.retryable
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(updates: Partial<WebRTCConnectionState>): void {
|
||||
this.state = { ...this.state, ...updates };
|
||||
this.emit('state-change', { type: 'state-change', state: updates });
|
||||
}
|
||||
|
||||
private handleWebSocketMessage(message: SignalingMessage): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 收到信令消息:', message.type);
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'peer-joined':
|
||||
this.handlePeerJoined(message.payload);
|
||||
break;
|
||||
case 'offer':
|
||||
this.handleOffer(message.payload);
|
||||
break;
|
||||
case 'answer':
|
||||
this.handleAnswer(message.payload);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
this.handleIceCandidate(message.payload);
|
||||
break;
|
||||
case 'error':
|
||||
this.handleError(message);
|
||||
break;
|
||||
case 'disconnection':
|
||||
this.handleDisconnection(message);
|
||||
break;
|
||||
default:
|
||||
if (this.config.enableLogging) {
|
||||
console.warn('[WebRTCManager] 未知消息类型:', message.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: SignalingMessage): void {
|
||||
this.wsManager.send(message);
|
||||
}
|
||||
|
||||
private handleTrack(event: RTCTrackEvent): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 收到媒体轨道:', event.track.kind);
|
||||
}
|
||||
// 这里可以添加轨道处理逻辑,或者通过事件传递给业务层
|
||||
}
|
||||
|
||||
private handleDataChannelMessage(message: WebRTCMessage): void {
|
||||
this.messageRouter.routeMessage(message);
|
||||
}
|
||||
|
||||
private handleDataChannelData(data: ArrayBuffer): void {
|
||||
// 默认路由到文件传输通道
|
||||
this.messageRouter.routeData(data, 'file-transfer');
|
||||
}
|
||||
|
||||
private async handlePeerJoined(payload: any): Promise<void> {
|
||||
if (!this.currentRoom) return;
|
||||
|
||||
const { role } = payload;
|
||||
const { role: currentRole } = this.currentRoom;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 对方加入房间:', role);
|
||||
}
|
||||
|
||||
if (currentRole === 'sender' && role === 'receiver') {
|
||||
this.updateState({ isPeerConnected: true });
|
||||
try {
|
||||
await this.pcManager.createOffer();
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 创建Offer失败:', error);
|
||||
}
|
||||
} else if (currentRole === 'receiver' && role === 'sender') {
|
||||
this.updateState({ isPeerConnected: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOffer(payload: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
await this.pcManager.setRemoteDescription(payload);
|
||||
const answer = await this.pcManager.createAnswer();
|
||||
this.handleSignalingMessage({ type: 'answer', payload: answer });
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 处理Offer失败:', error);
|
||||
this.updateState({
|
||||
error: '处理连接请求失败',
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnswer(payload: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
await this.pcManager.setRemoteDescription(payload);
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 处理Answer失败:', error);
|
||||
this.updateState({
|
||||
error: '处理连接响应失败',
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIceCandidate(payload: RTCIceCandidateInit): Promise<void> {
|
||||
try {
|
||||
await this.pcManager.addIceCandidate(payload);
|
||||
} catch (error) {
|
||||
console.warn('[WebRTCManager] 添加ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(message: any): void {
|
||||
this.updateState({
|
||||
error: message.error || '信令服务器错误',
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
|
||||
private handleDisconnection(message: any): void {
|
||||
this.updateState({
|
||||
isPeerConnected: false,
|
||||
error: '对方已离开房间',
|
||||
canRetry: true
|
||||
});
|
||||
|
||||
// 清理P2P连接但保持WebSocket连接
|
||||
this.pcManager.destroyPeerConnection();
|
||||
this.dcManager.close();
|
||||
}
|
||||
|
||||
async connect(roomCode: string, role: 'sender' | 'receiver'): Promise<void> {
|
||||
if (this.state.isConnecting) {
|
||||
console.warn('[WebRTCManager] 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUserDisconnecting = false;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
this.currentRoom = { code: roomCode, role };
|
||||
this.updateState({
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
currentRoom: { code: roomCode, role }
|
||||
});
|
||||
|
||||
// 创建PeerConnection
|
||||
const pc = await this.pcManager.createPeerConnection();
|
||||
|
||||
// 如果是发送方,创建数据通道
|
||||
if (role === 'sender') {
|
||||
this.dcManager.createDataChannel(pc);
|
||||
}
|
||||
|
||||
// 连接WebSocket
|
||||
const baseWsUrl = getWsUrl();
|
||||
if (!baseWsUrl) {
|
||||
throw new WebRTCError('WS_URL_NOT_CONFIGURED', 'WebSocket URL未配置', false);
|
||||
}
|
||||
|
||||
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
|
||||
this.wsManager = new WebSocketManager({
|
||||
url: wsUrl,
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
await this.wsManager.connect();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 连接失败:', error);
|
||||
this.updateState({
|
||||
error: error instanceof WebRTCError ? error.message : '连接失败',
|
||||
isConnecting: false,
|
||||
canRetry: true
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 主动断开连接');
|
||||
}
|
||||
|
||||
this.isUserDisconnecting = true;
|
||||
this.abortController.abort();
|
||||
|
||||
// 通知对方断开连接
|
||||
this.wsManager.send({
|
||||
type: 'disconnection',
|
||||
payload: { reason: '用户主动断开' }
|
||||
});
|
||||
|
||||
// 清理所有连接
|
||||
this.dcManager.close();
|
||||
this.pcManager.destroyPeerConnection();
|
||||
this.wsManager.disconnect();
|
||||
this.messageRouter.clear();
|
||||
|
||||
// 重置状态
|
||||
this.currentRoom = null;
|
||||
this.updateState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false,
|
||||
currentRoom: null,
|
||||
});
|
||||
}
|
||||
|
||||
async retry(): Promise<void> {
|
||||
if (!this.currentRoom) {
|
||||
throw new WebRTCError('NO_ROOM_INFO', '没有房间信息,无法重试', false);
|
||||
}
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 重试连接:', this.currentRoom);
|
||||
}
|
||||
|
||||
this.disconnect();
|
||||
await this.connect(this.currentRoom.code, this.currentRoom.role);
|
||||
}
|
||||
|
||||
sendMessage(message: WebRTCMessage, channel?: string): boolean {
|
||||
const messageWithChannel = channel ? { ...message, channel } : message;
|
||||
return this.dcManager.sendMessage(messageWithChannel);
|
||||
}
|
||||
|
||||
sendData(data: ArrayBuffer): boolean {
|
||||
return this.dcManager.sendData(data);
|
||||
}
|
||||
|
||||
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
|
||||
return this.messageRouter.registerMessageHandler(channel, handler);
|
||||
}
|
||||
|
||||
registerDataHandler(channel: string, handler: DataHandler): () => void {
|
||||
return this.messageRouter.registerDataHandler(channel, handler);
|
||||
}
|
||||
|
||||
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
|
||||
return this.pcManager.addTrack(track, stream);
|
||||
}
|
||||
|
||||
removeTrack(sender: RTCRtpSender): void {
|
||||
this.pcManager.removeTrack(sender);
|
||||
}
|
||||
|
||||
onTrack(handler: (event: RTCTrackEvent) => void): void {
|
||||
// 简化实现,直接设置处理器
|
||||
this.pcManager.on('track', (event) => {
|
||||
if (event.type === 'state-change' && 'onTrack' in this.config) {
|
||||
// 这里需要适配事件类型
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPeerConnection(): RTCPeerConnection | null {
|
||||
// 返回内部 PeerConnection 的引用
|
||||
return (this.pcManager as any).pc;
|
||||
}
|
||||
|
||||
async createOfferNow(): Promise<boolean> {
|
||||
try {
|
||||
await this.pcManager.createOffer();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 创建Offer失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getChannelState(): RTCDataChannelState {
|
||||
return this.dcManager.getState();
|
||||
}
|
||||
|
||||
isConnectedToRoom(roomCode: string, role: 'sender' | 'receiver'): boolean {
|
||||
return this.currentRoom?.code === roomCode &&
|
||||
this.currentRoom?.role === role &&
|
||||
this.state.isConnected;
|
||||
}
|
||||
|
||||
getState(): WebRTCConnectionState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
getConfig(): WebRTCManagerConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
218
chuan-next/src/hooks/webrtc/WebSocketManager.ts
Normal file
218
chuan-next/src/hooks/webrtc/WebSocketManager.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler } from './types';
|
||||
|
||||
interface WebSocketManagerConfig {
|
||||
url: string;
|
||||
reconnectAttempts?: number;
|
||||
reconnectDelay?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export class WebSocketManager extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: WebSocketManagerConfig;
|
||||
private reconnectCount = 0;
|
||||
private isConnecting = false;
|
||||
private isUserDisconnecting = false;
|
||||
private messageQueue: WebSocketMessage[] = [];
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(config: WebSocketManagerConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
timeout: 10000,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.isConnecting || this.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
this.isUserDisconnecting = false;
|
||||
this.emit('connecting', { type: 'connecting' });
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(this.config.url);
|
||||
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
throw new WebRTCError('WS_TIMEOUT', 'WebSocket连接超时', true);
|
||||
}
|
||||
}, this.config.timeout);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnecting = false;
|
||||
this.reconnectCount = 0;
|
||||
this.ws = ws;
|
||||
this.setupEventHandlers();
|
||||
this.flushMessageQueue();
|
||||
this.emit('connected', { type: 'connected' });
|
||||
resolve();
|
||||
};
|
||||
|
||||
ws.onerror = (errorEvent) => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnecting = false;
|
||||
const wsError = new WebRTCError('WS_ERROR', 'WebSocket连接错误', true, new Error('WebSocket连接错误'));
|
||||
this.emit('error', { type: 'error', error: wsError });
|
||||
reject(wsError);
|
||||
};
|
||||
|
||||
ws.onclose = (closeEvent) => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
|
||||
if (!this.isUserDisconnecting) {
|
||||
this.handleReconnect();
|
||||
}
|
||||
|
||||
this.emit('disconnected', {
|
||||
type: 'disconnected',
|
||||
reason: `WebSocket关闭: ${closeEvent.code} - ${closeEvent.reason}`
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
if (error instanceof WebRTCError) {
|
||||
this.emit('error', { type: 'error', error });
|
||||
throw error;
|
||||
}
|
||||
throw new WebRTCError('WS_CONNECTION_FAILED', 'WebSocket连接失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.ws) return;
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.emit('message', message);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (errorEvent) => {
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_ERROR', 'WebSocket错误', true, new Error('WebSocket错误'))
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.ws = null;
|
||||
if (!this.isUserDisconnecting) {
|
||||
this.handleReconnect();
|
||||
}
|
||||
this.emit('disconnected', {
|
||||
type: 'disconnected',
|
||||
reason: `WebSocket关闭: ${event.code} - ${event.reason}`
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private handleReconnect(): void {
|
||||
if (this.reconnectCount >= this.config.reconnectAttempts!) {
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_RECONNECT_FAILED', '重连失败,已达最大重试次数', false)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.config.reconnectDelay! * Math.pow(2, this.reconnectCount);
|
||||
this.reconnectCount++;
|
||||
|
||||
console.log(`WebSocket重连中... (${this.reconnectCount}/${this.config.reconnectAttempts}),延迟: ${delay}ms`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.emit('retry', { type: 'retry' });
|
||||
this.connect().catch(error => {
|
||||
console.error('WebSocket重连失败:', error);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
send(message: WebSocketMessage): boolean {
|
||||
if (!this.isConnected()) {
|
||||
this.messageQueue.push(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws!.send(JSON.stringify(message));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('发送WebSocket消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_SEND_ERROR', '发送消息失败', true, error as Error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private flushMessageQueue(): void {
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.isUserDisconnecting = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, '用户主动断开');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.messageQueue = [];
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
isConnectingState(): boolean {
|
||||
return this.isConnecting;
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler<ConnectionEvent>): this {
|
||||
return super.on(event, handler);
|
||||
}
|
||||
|
||||
emit(eventName: string, event?: ConnectionEvent): boolean {
|
||||
return super.emit(eventName, event);
|
||||
}
|
||||
}
|
||||
7
chuan-next/src/hooks/webrtc/index.ts
Normal file
7
chuan-next/src/hooks/webrtc/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// WebRTC核心功能
|
||||
export * from './DataChannelManager';
|
||||
export * from './MessageRouter';
|
||||
export * from './PeerConnectionManager';
|
||||
export * from './WebRTCManager';
|
||||
export * from './WebSocketManager';
|
||||
export * from './types';
|
||||
58
chuan-next/src/hooks/webrtc/types.ts
Normal file
58
chuan-next/src/hooks/webrtc/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// WebRTC 核心类型定义
|
||||
|
||||
// 基础连接状态
|
||||
export interface WebRTCConnectionState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
error: string | null;
|
||||
canRetry: boolean;
|
||||
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
export interface WebRTCMessage<T = any> {
|
||||
type: string;
|
||||
payload: T;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
// 消息处理器类型
|
||||
export type MessageHandler = (message: WebRTCMessage) => void;
|
||||
export type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
// WebRTC 配置
|
||||
export interface WebRTCConfig {
|
||||
iceServers: RTCIceServer[];
|
||||
iceCandidatePoolSize: number;
|
||||
chunkSize: number;
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
ackTimeout: number;
|
||||
}
|
||||
|
||||
// 错误类型
|
||||
export class WebRTCError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public retryable: boolean = false,
|
||||
public cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'WebRTCError';
|
||||
}
|
||||
}
|
||||
|
||||
// 连接事件
|
||||
export type ConnectionEvent =
|
||||
| { type: 'connecting' }
|
||||
| { type: 'connected' }
|
||||
| { type: 'disconnected'; reason?: string }
|
||||
| { type: 'error'; error: WebRTCError }
|
||||
| { type: 'retry' }
|
||||
| { type: 'state-change'; state: Partial<WebRTCConnectionState> };
|
||||
|
||||
// 事件处理器
|
||||
export type EventHandler<T extends ConnectionEvent> = (event: T) => void;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -11,23 +11,6 @@ interface ApiResponse {
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface CreateRoomData {
|
||||
type?: string;
|
||||
content?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface CreateTextRoomData {
|
||||
type: string;
|
||||
content: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface UpdateFilesData {
|
||||
roomId: string;
|
||||
files: File[];
|
||||
}
|
||||
|
||||
export class ClientAPI {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -94,20 +77,10 @@ export class ClientAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间(统一接口)
|
||||
* 创建房间(简化版本)- 后端会忽略传入的参数
|
||||
*/
|
||||
async createRoom(data: CreateRoomData): Promise<ApiResponse> {
|
||||
return this.post('/api/create-room', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本房间
|
||||
*/
|
||||
async createTextRoom(content: string): Promise<ApiResponse> {
|
||||
return this.post('/api/create-room', {
|
||||
type: 'text',
|
||||
content: content
|
||||
});
|
||||
async createRoom(): Promise<ApiResponse> {
|
||||
return this.post('/api/create-room', {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,13 +113,6 @@ export class ClientAPI {
|
||||
async getWebRTCRoomStatus(code: string): Promise<ApiResponse> {
|
||||
return this.get(`/api/webrtc-room-status?code=${code}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件
|
||||
*/
|
||||
async updateFiles(data: UpdateFilesData): Promise<ApiResponse> {
|
||||
return this.post('/api/update-files', data);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
|
||||
161
chuan-next/src/lib/webrtc-support.ts
Normal file
161
chuan-next/src/lib/webrtc-support.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* WebRTC 浏览器支持检测工具
|
||||
*/
|
||||
|
||||
export interface WebRTCSupport {
|
||||
isSupported: boolean;
|
||||
missing: string[];
|
||||
details: {
|
||||
rtcPeerConnection: boolean;
|
||||
dataChannel: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测浏览器是否支持WebRTC及其相关功能
|
||||
*/
|
||||
export function detectWebRTCSupport(): WebRTCSupport {
|
||||
const missing: string[] = [];
|
||||
const details = {
|
||||
rtcPeerConnection: false,
|
||||
getUserMedia: false,
|
||||
getDisplayMedia: false,
|
||||
dataChannel: false,
|
||||
};
|
||||
|
||||
// 检测 RTCPeerConnection
|
||||
if (typeof RTCPeerConnection !== 'undefined') {
|
||||
details.rtcPeerConnection = true;
|
||||
} else {
|
||||
missing.push('RTCPeerConnection');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 检测 DataChannel 支持
|
||||
try {
|
||||
if (typeof RTCPeerConnection !== 'undefined') {
|
||||
const pc = new RTCPeerConnection();
|
||||
const dc = pc.createDataChannel('test');
|
||||
if (dc) {
|
||||
details.dataChannel = true;
|
||||
}
|
||||
pc.close();
|
||||
}
|
||||
} catch (error) {
|
||||
missing.push('DataChannel');
|
||||
}
|
||||
|
||||
const isSupported = missing.length === 0;
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
missing,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器信息
|
||||
*/
|
||||
export function getBrowserInfo(): {
|
||||
name: string;
|
||||
version: string;
|
||||
isSupported: boolean;
|
||||
recommendations?: string[];
|
||||
} {
|
||||
const userAgent = navigator.userAgent;
|
||||
let browserName = 'Unknown';
|
||||
let version = 'Unknown';
|
||||
let isSupported = true;
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// Chrome
|
||||
if (/Chrome/.test(userAgent) && !/Edg/.test(userAgent)) {
|
||||
browserName = 'Chrome';
|
||||
const match = userAgent.match(/Chrome\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 23;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Chrome 23 或更新版本');
|
||||
}
|
||||
}
|
||||
// Firefox
|
||||
else if (/Firefox/.test(userAgent)) {
|
||||
browserName = 'Firefox';
|
||||
const match = userAgent.match(/Firefox\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 22;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Firefox 22 或更新版本');
|
||||
}
|
||||
}
|
||||
// Safari
|
||||
else if (/Safari/.test(userAgent) && !/Chrome/.test(userAgent)) {
|
||||
browserName = 'Safari';
|
||||
const match = userAgent.match(/Version\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 11;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Safari 11 或更新版本');
|
||||
}
|
||||
}
|
||||
// Edge
|
||||
else if (/Edg/.test(userAgent)) {
|
||||
browserName = 'Edge';
|
||||
const match = userAgent.match(/Edg\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 12;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Edge 12 或更新版本');
|
||||
}
|
||||
}
|
||||
// Internet Explorer
|
||||
else if (/MSIE|Trident/.test(userAgent)) {
|
||||
browserName = 'Internet Explorer';
|
||||
isSupported = false;
|
||||
recommendations.push(
|
||||
'请使用现代浏览器,如 Chrome、Firefox、Safari 或 Edge',
|
||||
'Internet Explorer 不支持 WebRTC'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: browserName,
|
||||
version,
|
||||
isSupported,
|
||||
recommendations: recommendations.length > 0 ? recommendations : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐的浏览器列表
|
||||
*/
|
||||
export function getRecommendedBrowsers(): Array<{
|
||||
name: string;
|
||||
minVersion: string;
|
||||
downloadUrl: string;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
name: 'Google Chrome',
|
||||
minVersion: '23+',
|
||||
downloadUrl: 'https://www.google.com/chrome/',
|
||||
},
|
||||
{
|
||||
name: 'Mozilla Firefox',
|
||||
minVersion: '22+',
|
||||
downloadUrl: 'https://www.mozilla.org/firefox/',
|
||||
},
|
||||
{
|
||||
name: 'Safari',
|
||||
minVersion: '11+',
|
||||
downloadUrl: 'https://www.apple.com/safari/',
|
||||
},
|
||||
{
|
||||
name: 'Microsoft Edge',
|
||||
minVersion: '12+',
|
||||
downloadUrl: 'https://www.microsoft.com/edge',
|
||||
},
|
||||
];
|
||||
}
|
||||
74
chuan-next/tailwind.config.js
Normal file
74
chuan-next/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
@@ -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==
|
||||
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
file-transfer-go:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# 可选:使用已发布的镜像
|
||||
# file-transfer-go:
|
||||
# image: matrixseven/file-transfer:latest
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
# restart: unless-stopped
|
||||
163
docker-release.sh
Executable file
163
docker-release.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==============================================
|
||||
# Docker 发布脚本
|
||||
# 支持单架构和多架构构建
|
||||
# ==============================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 配置
|
||||
DOCKER_HUB_USER=${DOCKER_HUB_USER:-"matrixseven"} # 替换为你的 Docker Hub 用户名
|
||||
REPO_NAME="file-transfer-go"
|
||||
IMAGE_NAME="${DOCKER_HUB_USER}/${REPO_NAME}"
|
||||
VERSION="v1.0.5"
|
||||
|
||||
print_header() {
|
||||
echo -e "${PURPLE}========================================${NC}"
|
||||
echo -e "${PURPLE}🐳 $1${NC}"
|
||||
echo -e "${PURPLE}========================================${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
# 检查 Docker 是否支持多架构构建
|
||||
check_multiarch_support() {
|
||||
if command -v docker buildx >/dev/null 2>&1; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
}
|
||||
|
||||
# 登录 Docker Hub
|
||||
docker_login() {
|
||||
print_info "登录 Docker Hub..."
|
||||
if ! docker info | grep -q "Username: ${DOCKER_HUB_USER}"; then
|
||||
echo -e "${YELLOW}请输入 Docker Hub 登录信息:${NC}"
|
||||
docker login
|
||||
else
|
||||
print_success "已登录 Docker Hub"
|
||||
fi
|
||||
}
|
||||
|
||||
# 推送镜像到 Docker Hub
|
||||
push_to_dockerhub() {
|
||||
print_info "推送镜像到 Docker Hub..."
|
||||
docker push "${IMAGE_NAME}:${VERSION}"
|
||||
docker push "${IMAGE_NAME}:latest"
|
||||
print_success "镜像推送完成"
|
||||
}
|
||||
|
||||
# 单架构构建(当前方法)
|
||||
build_single_arch() {
|
||||
print_header "单架构 Docker 镜像构建"
|
||||
|
||||
print_info "构建镜像: ${IMAGE_NAME}:${VERSION}"
|
||||
docker build -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" .
|
||||
|
||||
print_success "单架构镜像构建完成"
|
||||
docker images "${IMAGE_NAME}"
|
||||
}
|
||||
|
||||
# 多架构构建(需要 buildx)
|
||||
build_multiarch() {
|
||||
print_header "多架构 Docker 镜像构建"
|
||||
|
||||
print_info "创建 buildx builder"
|
||||
docker buildx create --name multiarch --use 2>/dev/null || true
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
print_info "构建多架构镜像: linux/amd64,linux/arm64"
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t "${IMAGE_NAME}:${VERSION}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
--push \
|
||||
.
|
||||
|
||||
print_success "多架构镜像构建并推送完成"
|
||||
}
|
||||
|
||||
# 显示使用说明
|
||||
show_usage() {
|
||||
print_header "Docker 镜像使用说明"
|
||||
|
||||
echo -e "${GREEN}🚀 运行镜像:${NC}"
|
||||
echo " docker run -d -p 8080:8080 ${IMAGE_NAME}:${VERSION}"
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}📦 镜像信息:${NC}"
|
||||
echo " - Docker Hub: https://hub.docker.com/r/${DOCKER_HUB_USER}/${REPO_NAME}"
|
||||
echo " - 版本: ${VERSION}"
|
||||
echo " - 大小: ~16MB"
|
||||
echo " - 架构: $(check_multiarch_support && echo "amd64, arm64" || echo "amd64")"
|
||||
echo " - 基础镜像: alpine:3.18"
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}🌟 特性:${NC}"
|
||||
echo " ✅ 静态编译,无外部依赖"
|
||||
echo " ✅ 前端文件完全嵌入"
|
||||
echo " ✅ 多平台文件传输支持"
|
||||
echo " ✅ WebRTC P2P 连接"
|
||||
echo " ✅ 桌面共享功能"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
# 登录 Docker Hub
|
||||
docker_login
|
||||
|
||||
case "${1:-single}" in
|
||||
"multi")
|
||||
if [ "$(check_multiarch_support)" = "true" ]; then
|
||||
build_multiarch # 多架构构建会自动推送
|
||||
else
|
||||
echo -e "${RED}❌ Docker buildx 不可用,回退到单架构构建${NC}"
|
||||
build_single_arch
|
||||
push_to_dockerhub
|
||||
fi
|
||||
;;
|
||||
"single"|*)
|
||||
build_single_arch
|
||||
push_to_dockerhub
|
||||
;;
|
||||
esac
|
||||
|
||||
show_usage
|
||||
}
|
||||
|
||||
# 检查参数
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
echo "用法: $0 [single|multi]"
|
||||
echo ""
|
||||
echo " single 构建单架构镜像并推送到 Docker Hub (默认,amd64)"
|
||||
echo " multi 构建多架构镜像并推送到 Docker Hub (amd64, arm64)"
|
||||
echo ""
|
||||
echo "环境变量:"
|
||||
echo " DOCKER_HUB_USER Docker Hub 用户名 (默认: matrixseven)"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 single # 单架构构建"
|
||||
echo " $0 multi # 多架构构建"
|
||||
echo " DOCKER_HUB_USER=yourname $0 single # 指定用户名"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
@@ -23,7 +23,7 @@ func (h *Handler) HandleWebRTCWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
h.webrtcService.HandleWebSocket(w, r)
|
||||
}
|
||||
|
||||
// CreateRoomHandler 创建房间API
|
||||
// CreateRoomHandler 创建房间API - 简化版本,不处理无用参数
|
||||
func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置响应为JSON格式
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -36,7 +36,7 @@ func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新房间
|
||||
// 创建新房间(忽略请求体中的无用参数)
|
||||
code := h.webrtcService.CreateNewRoom()
|
||||
log.Printf("创建房间成功: %s", code)
|
||||
|
||||
|
||||
@@ -76,6 +76,34 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if code == "" || (role != "sender" && role != "receiver") {
|
||||
log.Printf("WebRTC连接参数无效: code=%s, role=%s", code, role)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "连接参数无效",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证房间是否存在
|
||||
ws.roomsMux.RLock()
|
||||
room := ws.rooms[code]
|
||||
ws.roomsMux.RUnlock()
|
||||
|
||||
if room == nil {
|
||||
log.Printf("房间不存在: %s", code)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "房间不存在或已过期",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查房间是否已过期
|
||||
if time.Now().After(room.ExpiresAt) {
|
||||
log.Printf("房间已过期: %s", code)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "房间已过期",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,13 +155,8 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
|
||||
|
||||
room := ws.rooms[code]
|
||||
if room == nil {
|
||||
room = &WebRTCRoom{
|
||||
Code: code,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Hour), // 1小时后过期
|
||||
}
|
||||
ws.rooms[code] = room
|
||||
log.Printf("自动创建WebRTC房间: %s", code)
|
||||
log.Printf("尝试加入不存在的WebRTC房间: %s", code)
|
||||
return
|
||||
}
|
||||
|
||||
if client.Role == "sender" {
|
||||
@@ -253,19 +276,39 @@ func (ws *WebRTCService) CreateRoom(code string) {
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNewRoom 创建新房间并返回房间码
|
||||
// CreateNewRoom 创建新房间并返回房间码 - 确保不重复
|
||||
func (ws *WebRTCService) CreateNewRoom() string {
|
||||
code := ws.generatePickupCode()
|
||||
var code string
|
||||
|
||||
// 生成唯一房间码,确保不重复
|
||||
for {
|
||||
code = ws.generatePickupCode()
|
||||
ws.roomsMux.RLock()
|
||||
_, exists := ws.rooms[code]
|
||||
ws.roomsMux.RUnlock()
|
||||
|
||||
if !exists {
|
||||
break // 找到了不重复的代码
|
||||
}
|
||||
// 如果重复了,继续生成新的
|
||||
}
|
||||
|
||||
ws.CreateRoom(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// generatePickupCode 生成6位取件码
|
||||
// generatePickupCode 生成6位取件码 - 统一规则:只使用大写字母和数字,排除0和O避免混淆
|
||||
func (ws *WebRTCService) generatePickupCode() string {
|
||||
// 只使用大写字母和数字,排除容易混淆的字符:数字0和字母O
|
||||
chars := "123456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
|
||||
source := rand.NewSource(time.Now().UnixNano())
|
||||
rng := rand.New(source)
|
||||
code := rng.Intn(900000) + 100000
|
||||
return fmt.Sprintf("%d", code)
|
||||
|
||||
result := make([]byte, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
result[i] = chars[rng.Intn(len(chars))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// cleanupExpiredRooms 定期清理过期房间
|
||||
|
||||
Reference in New Issue
Block a user