新增房间验证工具函数,包含房间代码格式验证和房间状态检查逻辑

This commit is contained in:
MatrixSeven
2026-03-01 00:08:29 +08:00
parent 84d7caea8c
commit 1a6a7369b9
25 changed files with 2697 additions and 1241 deletions

737
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,737 @@
# 文件快传Chuan— 系统架构与技术设计文档
> 最后更新2025-08-02
---
## 一、项目总览
**文件快传Chuan** 是一个基于 **WebRTC P2P** 的文件传输应用。核心设计理念:**所有实际数据(文件、文字、桌面画面)均通过 WebRTC 点对点直传,服务器仅承担信令中继和房间管理职责**。
### 技术栈
| 层级 | 技术选型 |
|------|---------|
| 后端 | Go 1.21 · chi/v5 路由 · gorilla/websocket |
| 前端 | Next.js 15 · React 19 · TypeScript · Tailwind CSS 4 |
| 状态管理 | Zustand 5 + React useState/useRef |
| UI 组件 | shadcn/ui (Radix UI) · Lucide Icons |
| P2P 通信 | WebRTC DataChannel文件/文字)· MediaStream桌面共享|
| 部署 | 双模式Next.js 开发模式 / Go 嵌入前端静态文件 |
### 核心特性
| 功能 | 实现方式 |
|------|---------|
| 文件传输 | WebRTC DataChannel · 256KB 分块 · CRC32 校验 · ACK 确认 |
| 文本消息 | WebRTC DataChannel · 实时双向同步 · 打字状态 |
| 桌面共享 | WebRTC MediaStream · getDisplayMedia · renegotiation |
| ICE 配置 | 默认 5 个 STUN · 支持自定义 STUN/TURN · localStorage 持久化 |
---
## 二、系统架构
### 2.1 整体架构图
```
┌──────────────────────────────────────────────────────────────────────┐
│ Go 后端 (:8080) │
│ ┌──────────────┐ ┌───────────────────┐ ┌─────────────────────┐ │
│ │ REST API │ │ WebSocket 信令 │ │ 静态文件服务 │ │
│ │ /api/* │ │ /api/ws/webrtc │ │ go:embed frontend │ │
│ │ │ │ /ws/webrtc │ │ │ │
│ │ - create-room │ │ │ │ SPA 回退 index.html │ │
│ │ - room-info │ │ 纯中继 转发消息 │ │ │ │
│ └──────────────┘ └───────────────────┘ └─────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ 内存房间管理 (sync.RWMutex) │ │
│ │ map[string]*WebRTCRoom │ │
│ │ 1小时过期 · 5分钟定时清理 │ │
│ └───────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│ WebSocket │ WebSocket
│ (信令: offer/answer/ice-candidate) │
▼ ▼
┌───────────────┐ WebRTC P2P ┌───────────────┐
│ 发送方浏览器 │ ◄══════════════════════════► │ 接收方浏览器 │
│ │ DataChannel (文件/文字) │ │
│ Next.js App │ MediaStream (桌面共享) │ Next.js App │
└───────────────┘ └───────────────┘
```
### 2.2 数据流向
```
1. 控制平面 (HTTP): 浏览器 ──── REST API ────→ Go 后端 (房间创建/查询)
2. 信令平面 (WS): 浏览器 ←──── WebSocket ──→ Go 后端 (SDP/ICE 交换)
3. 数据平面 (P2P): 浏览器 ◄═══ DataChannel ══► 浏览器 (文件/文字直传)
4. 媒体平面 (P2P): 浏览器 ◄═══ MediaStream ══► 浏览器 (桌面画面直传)
```
**关键设计:数据平面完全绕过服务器,文件和文字内容不经服务器中转。**
---
## 三、后端设计
### 3.1 目录结构
```
cmd/
├── main.go # 入口:参数解析 → 配置 → 路由 → 启动服务器
├── config.go # 配置管理:命令行 > 环境变量 > .chuan.env > 默认值
├── router.go # chi 路由注册 + 中间件 + 前端静态服务
└── server.go # HTTP Server 封装 + 优雅关闭 (SIGINT/SIGTERM)
internal/
├── handlers/
│ └── handlers.go # HTTP/WebSocket 请求处理(薄层,委托给 service
├── models/
│ └── models.go # 数据模型WebRTCRoom、WebRTCClient、RoomStatus
├── services/
│ └── webrtc_service.go # 核心信令服务:房间管理 + WebSocket 消息转发
└── web/
└── frontend.go # go:embed 嵌入前端 + SPA 回退
```
### 3.2 API 端点
| 方法 | 路径 | 功能 | 请求/响应 |
|------|------|------|-----------|
| `POST` | `/api/create-room` | 创建房间 | `{}``{success, code, message}` |
| `GET` | `/api/room-info?code=XXX` | 查询房间状态 | → `{success, status: RoomStatus}` |
| `GET` | `/api/webrtc-room-status?code=XXX` | 同上(别名)| 同上 |
| `WS` | `/api/ws/webrtc?code=&role=` | WebRTC 信令 | WebSocket 双向 |
| `WS` | `/ws/webrtc?code=&role=` | 同上(兼容路径)| 同上 |
| `GET` | `/*` | 前端静态文件 | SPA 回退 |
### 3.3 房间管理
- **房间代码**6 位,字符集 `123456789ABCDEFGHIJKLMNPQRSTUVWXYZ`(排除 0 和 O避免混淆
- **房间容量**:最多 2 人1 sender + 1 receiver
- **过期策略**:创建后 1 小时过期,每 5 分钟后台清理
- **存储方式**:纯内存 `map[string]*WebRTCRoom` + `sync.RWMutex`,无数据库
### 3.4 WebSocket 信令协议
#### 连接 URL
```
ws[s]://host/api/ws/webrtc?code=ROOM_CODE&role=sender|receiver&channel=shared
```
#### 服务端 → 客户端
| type | payload | 触发时机 |
|------|---------|---------|
| `peer-joined` | `{ role }` | 对方加入房间 |
| `disconnection` | `{ role, message }` | 对方断开连接 |
| `error` | `{ message }` | 房间不存在 / 已满 / 参数无效 |
#### 客户端 ↔ 客户端(经服务端纯转发)
| type | payload | 说明 |
|------|---------|------|
| `offer` | `RTCSessionDescription` | SDP Offer |
| `answer` | `RTCSessionDescription` | SDP Answer |
| `ice-candidate` | `RTCIceCandidate` | ICE 候选地址 |
**服务端对 offer/answer/ice-candidate 消息不做任何解析,仅中继转发给房间内另一方。**
---
## 四、前端设计(重点)
### 4.1 目录结构
```
chuan-next/src/
├── app/ # Next.js App Router
│ ├── page.tsx # 入口页SSG
│ ├── layout.tsx # 根布局字体、Toast、Umami 统计)
│ ├── globals.css # 全局样式 + 动画定义
│ ├── HomePage.tsx # 主页面组件Tab 管理 + WebRTC 状态)
│ ├── HomePageWrapper.tsx # Suspense 包裹SSR 兼容)
│ └── api/ # Next.js API Routes开发模式代理
│ ├── create-room/route.ts # → GO_BACKEND_URL/api/create-room
│ ├── room-info/route.ts # → GO_BACKEND_URL/api/room-info
│ ├── room-status/route.ts # → GO_BACKEND_URL/api/room-status
│ ├── create-text-room/route.ts
│ ├── get-text-content/route.ts
│ └── update-files/route.ts
├── components/ # UI 组件
│ ├── WebRTCFileTransfer.tsx # 文件传输整合组件659行
│ ├── WebRTCTextImageTransfer.tsx # 文字+图片传输整合组件
│ ├── DesktopShare.tsx # 桌面共享整合组件
│ ├── Hero.tsx # 页头标题、GitHub 链接)
│ ├── webrtc/ # WebRTC 功能子组件
│ │ ├── WebRTCFileUpload.tsx # 文件发送 UI
│ │ ├── WebRTCFileReceive.tsx # 文件接收 UI
│ │ ├── WebRTCTextSender.tsx # 文字发送 UI
│ │ ├── WebRTCTextReceiver.tsx # 文字接收 UI
│ │ ├── WebRTCDesktopSender.tsx # 桌面共享发送 UI
│ │ ├── WebRTCDesktopReceiver.tsx # 桌面共享接收 UI
│ │ └── WebRTCSettings.tsx # ICE 服务器配置 UI
│ └── ui/ # 基础 UI 组件 (shadcn/ui)
│ ├── button.tsx # 按钮6种 variant
│ ├── tabs.tsx # 标签页Radix Tabs
│ ├── input.tsx # 输入框
│ ├── card.tsx # 卡片
│ ├── dialog.tsx # 对话框
│ ├── progress.tsx # 进度条
│ ├── textarea.tsx # 文本域
│ ├── toast.tsx # Toast (Radix)
│ ├── toast-simple.tsx # 轻量 Toast (自定义)
│ ├── toaster.tsx # Toast 渲染
│ └── confirm-dialog.tsx # 确认对话框
├── hooks/ # 自定义 Hooks核心逻辑层
│ ├── index.ts # 统一导出
│ ├── connection/ # WebRTC 连接管理
│ │ ├── useSharedWebRTCManager.ts # 整合入口
│ │ ├── useWebRTCConnectionCore.ts # 核心连接WS + PeerConnection
│ │ ├── useWebRTCDataChannelManager.ts # DataChannel 管理 + 消息路由
│ │ ├── useWebRTCTrackManager.ts # MediaStream 轨道管理
│ │ ├── useWebRTCStateManager.ts # Zustand store 封装
│ │ ├── useRoomConnection.ts # 加入房间逻辑
│ │ ├── useConnectionState.ts # 连接状态变化处理
│ │ └── useWebRTCSupport.ts # 浏览器 WebRTC 检测
│ ├── file-transfer/ # 文件传输业务
│ │ ├── useFileTransferBusiness.ts # 核心:分块传输 + CRC32 + ACK
│ │ ├── useFileListSync.ts # 文件列表实时同步
│ │ └── useFileStateManager.ts # 文件状态(选中/下载/进度)
│ ├── text-transfer/ # 文字传输业务
│ │ └── useTextTransferBusiness.ts # 实时文字同步 + 打字状态
│ ├── desktop-share/ # 桌面共享业务
│ │ └── useDesktopShareBusiness.ts # getDisplayMedia + 轨道管理
│ ├── settings/ # 设置
│ │ ├── useIceServersConfig.ts # ICE 服务器配置 + localStorage
│ │ └── useWebRTCConfigSync.ts # 配置变更监听
│ └── ui/ # UI 相关
│ ├── webRTCStore.ts # Zustand 全局状态
│ ├── useURLHandler.ts # URL 参数管理
│ ├── useTabNavigation.ts # Tab 切换管理
│ └── useConfirmDialog.ts # 确认对话框 Hook
├── lib/ # 工具库
│ ├── config.ts # 环境配置API URL / WS URL 动态计算)
│ ├── utils.ts # cn() 样式合并
│ ├── client-api.ts # ClientAPI 封装
│ ├── api-utils.ts # apiFetch() 统一请求
│ └── webrtc-support.ts # WebRTC 支持检测
└── types/
└── index.ts # 全局类型定义
```
### 4.2 组件层次结构
```
RootLayout (layout.tsx)
└─ ToastProvider (toast-simple.tsx)
└─ HomePageWrapper (Suspense)
└─ HomePage
├─ Hero # 页头
├─ WebRTCUnsupportedModal # WebRTC 不支持提示
├─ ConfirmDialog # Tab 切换确认
└─ Tabs (5个标签)
├─ [Tab: webrtc] WebRTCFileTransfer
│ ├─ 模式切换 (发送/接收)
│ ├─ [发送模式] WebRTCFileUpload
│ │ ├─ 拖拽上传区域 / 文件列表
│ │ ├─ RoomInfoDisplay (取件码 + 二维码 + 复制)
│ │ ├─ ConnectionStatus (连接状态)
│ │ └─ 传输进度条
│ └─ [接收模式] WebRTCFileReceive
│ ├─ 取件码输入 (6位)
│ ├─ ConnectionStatus (连接状态)
│ └─ 文件列表 + 下载按钮
├─ [Tab: message] WebRTCTextImageTransfer
│ ├─ 模式切换 (发送/接收)
│ ├─ [发送模式] WebRTCTextSender
│ │ ├─ Textarea 编辑器
│ │ ├─ 图片发送按钮
│ │ └─ RoomInfoDisplay
│ └─ [接收模式] WebRTCTextReceiver
│ ├─ 取件码输入
│ └─ 实时文字显示 + 图片接收
├─ [Tab: desktop] DesktopShare
│ ├─ 模式切换 (共享/查看)
│ ├─ [共享模式] WebRTCDesktopSender
│ │ └─ 选择屏幕 → 开始共享
│ └─ [查看模式] WebRTCDesktopReceiver
│ └─ DesktopViewer (video 标签)
├─ [Tab: wechat] WeChatGroup
│ └─ 微信群二维码
└─ [Tab: settings] WebRTCSettings
└─ ICE 服务器配置表单
```
### 4.3 Hooks 架构(核心设计)
前端逻辑层采用 **分层 Hooks 架构**,自底向上组合:
```
┌─────────────────────────────┐
│ Zustand Store │ ← 最底层:全局状态
│ (webRTCStore.ts) │
│ isConnected, isConnecting │
│ currentRoom, error │
└──────────────┬──────────────┘
┌──────────────────────┼──────────────────────┐
│ │ │
┌────────▼────────┐ ┌─────────▼──────────┐ ┌───────▼─────────┐
│ StateManager │ │ DataChannelManager │ │ TrackManager │
│ (Zustand 封装) │ │ (消息路由 + 收发) │ │ (媒体轨道管理) │
└────────┬────────┘ └─────────┬──────────┘ └───────┬─────────┘
│ │ │
└──────────────────────┼──────────────────────┘
┌──────────────▼──────────────┐
│ ConnectionCore │ ← WebSocket + PeerConnection
│ (useWebRTCConnectionCore) │ 信令处理 + ICE 交换
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ SharedWebRTCManager │ ← 整合入口4合1
│ (useSharedWebRTCManager) │ 返回统一 WebRTCConnection
└──────────────┬──────────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
┌────────▼────────┐ ┌──────────▼──────────┐ ┌────────▼────────┐
│ FileTransfer │ │ TextTransfer │ │ DesktopShare │
│ Business │ │ Business │ │ Business │
│ (文件分块+校验) │ │ (实时同步文字) │ │ (屏幕采集+推流) │
└────────┬────────┘ └──────────┬──────────┘ └────────┬────────┘
│ │ │
└────────────────────────┼────────────────────────┘
┌──────────────▼──────────────┐
│ 业务级组件 (TSX) │
│ WebRTCFileTransfer.tsx 等 │
└─────────────────────────────┘
```
#### 4.3.1 连接层 — `useWebRTCConnectionCore`
**职责**:管理 WebSocket 信令连接 + RTCPeerConnection 生命周期。
```
connect(code, role)
├─ 1. WebSocket 连接到 ws://host/api/ws/webrtc?code=XXX&role=YYY
├─ 2. 收到 "peer-joined" → 创建 RTCPeerConnection
│ └─ ICE 服务器从 getIceServersConfig() 获取
├─ 3. Sender 创建 DataChannel → 创建 Offer
│ Receiver 等待 ondatachannel
├─ 4. offer/answer/ice-candidate 通过 WebSocket 交换
├─ 5. ICE 连接建立 → DataChannel open → 连接完成
└─ 6. 断开时:发送 disconnection → 清理 PeerConnection
```
#### 4.3.2 数据通道层 — `useWebRTCDataChannelManager`
**职责**:管理 DataChannel 消息的路由分发。
**核心设计:多 channel 路由**
所有 JSON 消息通过 `channel` 字段路由到不同的业务处理器:
```typescript
// 消息格式
{
channel: "file-transfer" | "text-transfer" | "desktop-share",
type: "具体消息类型",
payload: { ... }
}
```
```
DataChannel.onmessage
├─ typeof data === 'string' → JSON.parse
│ ├─ channel === 'file-transfer' → fileTransferHandler
│ ├─ channel === 'text-transfer' → textTransferHandler
│ └─ channel === 'desktop-share' → desktopShareHandler
└─ typeof data === ArrayBuffer → 二进制数据
└─ 优先路由到 'file-transfer' handler文件块数据
```
**注意**:三个功能(文件/文字/桌面)共享同一个 DataChannel`shared-channel`),而非各自创建独立通道。
#### 4.3.3 轨道管理层 — `useWebRTCTrackManager`
**职责**:管理 MediaStream 轨道(仅桌面共享使用)。
- `addTrack(track, stream)` — 添加视频/音频轨道
- `removeTrack(sender)` — 移除轨道
- `createOfferNow()` — 添加轨道后触发重协商
- `onTrack` 回调 — 接收远程媒体流
### 4.4 状态管理设计
采用 **三层状态模型**
| 层级 | 技术 | 用途 | 示例 |
|------|------|------|------|
| 全局共享状态 | Zustand | WebRTC 连接状态 | `isConnected`, `currentRoom`, `error` |
| 组件级状态 | React useState | UI 交互状态 | `selectedFiles`, `mode`, `progress` |
| 引用状态 | React useRef | 回调/缓冲区/定时器 | `receiveBufferRef`, `messageHandlers`, 防抖 timer |
#### Zustand Store 结构
```typescript
interface WebRTCState {
// 连接状态
isConnected: boolean; // 总体连接状态
isConnecting: boolean; // 正在连接中
isWebSocketConnected: boolean; // WebSocket 层连接
isPeerConnected: boolean; // PeerConnection 层连接
// 错误状态
error: string | null;
canRetry: boolean;
// 房间信息
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
// Actions
updateState(partial: Partial<WebRTCState>): void;
setCurrentRoom(room: { code: string; role: string } | null): void;
resetToInitial(): void;
}
```
### 4.5 文件传输详细设计
#### 4.5.1 DataChannel 消息协议
**file-transfer channel 消息类型:**
| 方向 | type | payload | 说明 |
|------|------|---------|------|
| S→R | `file-list` | `FileInfo[]` | 发送方文件列表(实时同步) |
| R→S | `file-request` | `{ fileId, fileName }` | 接收方请求下载 |
| S→R | `file-metadata` | `{ id, name, size, type }` | 文件元信息 |
| S→R | `file-chunk-info` | `{ fileId, chunkIndex, totalChunks, checksum }` | 块元信息(含 CRC32 |
| S→R | *(二进制)* | `ArrayBuffer` (≤256KB) | 文件块数据 |
| R→S | `file-chunk-ack` | `{ fileId, chunkIndex, success, checksum }` | 块确认(含校验结果) |
| S→R | `file-complete` | `{ fileId }` | 文件传输完成 |
#### 4.5.2 传输参数
| 参数 | 值 | 说明 |
|------|-----|------|
| 块大小 | 256 KB | 每个 chunk 的最大字节数 |
| 最大重试 | 5 次 | 单个 chunk 校验失败后的重试上限 |
| 退避策略 | 指数退避 | 重试间隔指数增长 |
| 校验算法 | CRC32 | 每个 chunk 独立校验 |
| 流控 | 自适应 | 根据平均传输速度动态调整发送间隔 |
| DataChannel 配置 | `ordered: true, maxRetransmits: 3` | 有序可靠传输 |
#### 4.5.3 完整传输时序
```
发送方 信令服务器 接收方
│ │ │
│ 1. POST /api/create-room │ │
│ ──────────────────────────────────► │ │
│ ◄── {success: true, code: "AB12CD"} │ │
│ │ │
│ 2. WS connect (role=sender) │ │
│ ═══════════════════════════════════► │ │
│ │ │
│ │ 3. GET /api/room-info?code=... │
│ │ ◄────────────────────────────── │
│ │ ── {success, status} ─────────► │
│ │ │
│ │ 4. WS connect (role=receiver) │
│ │ ◄═══════════════════════════════ │
│ │ │
│ 5. ◄── peer-joined ── │ ── peer-joined ──► │
│ │ │
│ 6. 创建 PeerConnection + DataChannel │
│ 7. 创建 SDP Offer │
│ ═══ offer ═══════════════════════► │ ═══ offer ═══════════════════► │
│ │ │
│ │ 8. 创建 PeerConnection │
│ │ 9. 设置 Remote/Local SDP │
│ ◄═══ answer ══════════════════════ │ ◄═══ answer ═════════════════ │
│ │ │
│ 10. ICE Candidate 交换 ◄═══════════╋══════════════════════════════► │
│ │ │
│ ══════════ DataChannel OPEN ═══════╪══════════════════════════════ │
│ │ │
│ 11. file-list ─────────────────────┼────────────────────────────────► │
│ [{id, name, size, type}, ...] │ │
│ │ │
│ ◄─────────────────────────────────┼──── 12. file-request ────────── │
│ {fileId, fileName} │ │
│ │ │
│ 13. file-metadata ────────────────┼────────────────────────────────► │
│ {id, name, size, type} │ │
│ │ │
│ ╔══ 循环每个 chunk ═══════════════╪════════════════════════════════╗ │
│ ║ 14. file-chunk-info ───────────┼──────────────────────────────► ║ │
│ ║ {fileId, chunkIndex, │ ║ │
│ ║ totalChunks, checksum} │ ║ │
│ ║ │ ║ │
│ ║ 15. [ArrayBuffer 256KB] ───────┼──────────────────────────────► ║ │
│ ║ │ ║ │
│ ║ ◄───────────────────────────────┼── 16. file-chunk-ack ─────── ║ │
│ ║ {fileId, chunkIndex, │ success, checksum} ║ │
│ ║ │ ║ │
│ ║ [如果 success=false, 指数退避重试最多5次] ║ │
│ ╚══════════════════════════════════╪══════════════════════════════╝ │
│ │ │
│ 17. file-complete ────────────────┼────────────────────────────────► │
│ {fileId} │ │
│ │ 18. 组装 Blob │
│ │ → File │
│ │ → 下载 │
```
#### 4.5.4 文件列表同步机制
发送方选择文件后,文件列表会 **实时同步** 到接收方:
```
发送方文件变更 → useFileListSync hook
├─ 150ms 防抖debounce
├─ 比对新旧文件列表(避免无变更的冗余同步)
└─ 通过 DataChannel 发送 file-list 消息
└─ 接收方更新 UI 展示文件列表
```
### 4.6 文字传输详细设计
#### 4.6.1 DataChannel 消息协议
**text-transfer channel 消息类型:**
| 方向 | type | payload | 说明 |
|------|------|---------|------|
| S→R | `text-sync` | `{ text: string }` | 实时文本内容同步 |
| 双向 | `text-typing` | `{ typing: boolean }` | 打字状态指示 |
#### 4.6.2 实时同步机制
```
发送方 Textarea 输入
├─ onChange → handleTextInputChange()
├─ 调用 textTransfer.sendTextSync(text)
│ └─ DataChannel 发送 { channel: "text-transfer", type: "text-sync", payload: { text } }
├─ 同时发送 text-typing: { typing: true }
│ └─ 1秒后自动发送 text-typing: { typing: false }
└─ 接收方
├─ onTextSync 回调 → 更新显示文本
└─ onTyping 回调 → 显示 "对方正在输入..." 动画
```
**图片传输**:复用文件传输的 `file-transfer` channel图片作为文件发送。
### 4.7 桌面共享详细设计
```
发送方
├─ navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
├─ 获取 MediaStream → 提取 video/audio Track
├─ trackManager.addTrack(track, stream)
│ └─ peerConnection.addTrack()
├─ trackManager.createOfferNow() ← 触发 SDP 重协商
│ └─ 新 Offer → WebSocket → 对方 → Answer → WebSocket → 本方
└─ 接收方
├─ ontrack 事件 → 获取远程 MediaStream
└─ <video> 元素播放 → DesktopViewer 组件
```
### 4.8 ICE 配置设计
#### 默认 STUN 服务器
```typescript
const DEFAULT_ICE_SERVERS = [
{ urls: 'stun:stun.easyvoip.com:3478' },
{ urls: 'stun:stun.miwifi.com:3478' },
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:global.stun.twilio.com:3478' },
];
```
#### 用户自定义
- 通过 WebRTCSettings 界面添加 STUN/TURN 服务器
- 配置持久化到 `localStorage`key: `webrtc-ice-config`
- `getIceServersConfig()` 导出给非 React 代码使用
- 配置变更后提示用户断开重连
### 4.9 环境适配设计
```
┌─────────────────────────────────────────┐
│ config.ts 环境判断 │
│ │
│ isStaticMode = Next.js 构建输出判断 │
│ isDev = NODE_ENV === 'development' │
└────────────────┬────────────────────────┘
┌─────────────────┼─────────────────┐
│ │ │
┌────────▼────────┐ ┌─────▼──────┐ ┌───────▼───────┐
│ 开发模式 │ │ SSG 静态模式 │ │ 嵌入 Go 模式 │
│ (yarn dev) │ │ (build:ssg) │ │ (go run) │
│ │ │ │ │ │
│ API: │ │ API: │ │ API: │
│ /api/* → Next.js │ │ 直连 Go │ │ 同源 /api/* │
│ API Route 代理 │ │ 后端地址 │ │ │
│ │ │ │ │ │
│ WS: │ │ WS: │ │ WS: │
│ ws://localhost │ │ 当前域名 │ │ 当前域名 │
│ :8080/... │ │ ws[s]:// │ │ ws[s]:// │
└──────────────────┘ └─────────────┘ └───────────────┘
```
#### URL 动态计算逻辑
```typescript
// API URL
getApiUrl()
? '/api' ( Next.js API Route GO_BACKEND_URL)
? getDirectBackendUrl() + '/api' ( Go )
// WebSocket URL
getWsUrl()
? 'ws://localhost:8080' ( Go WS)
? 'ws[s]://' + window.location.host ()
```
### 4.10 URL 路由设计
前端通过 URL 参数控制功能和模式:
```
/?type=webrtc&mode=send → 文件传输-发送模式
/?type=webrtc&mode=receive → 文件传输-接收模式
/?type=webrtc&mode=receive&code=ABC123 → 自动填入取件码
/?type=message&mode=send → 文字消息-发送模式
/?type=desktop&mode=send → 桌面共享-共享模式
```
**URL 参数映射**
- `type`: `webrtc` | `message` | `desktop` | `wechat` | `settings` → 对应 Tab
- `mode`: `send` | `receive` → 发送/接收子模式
- `code`: 6 位取件码 → 自动填入并可自动加入房间
---
## 五、关键设计决策
### 5.1 为什么用纯 P2P
- **隐私**:文件内容不经服务器,用户数据零留存
- **成本**:服务器仅做信令,带宽成本极低
- **性能**:局域网环境下可接近网卡极限速度
### 5.2 为什么共享单个 DataChannel
三个功能(文件/文字/桌面控制)共享同一个 `shared-channel`,通过 JSON `channel` 字段路由:
- **简化连接管理**:只需建立一次 P2P 连接
- **减少信令开销**:无需为每个功能单独协商
- **统一状态管理**:连接/断开状态全局一致
### 5.3 为什么用 CRC32 + ACK
WebRTC DataChannel 本身基于 SCTP配置为 `ordered: true, maxRetransmits: 3`),已有一定可靠性。额外的 CRC32 + ACK 是 **应用层二次保障**
- 防止极端情况下 SCTP 重传仍失败后的数据损坏
- 提供块级粒度的错误恢复(只重传失败的块,无需从头开始)
- 接收方独立验证数据完整性
### 5.4 为什么 256KB 块大小?
- WebRTC DataChannel 最大消息大小约 256KB不同浏览器有差异
- 小块有利于进度反馈的精度
- 小块降低单次重传的代价
### 5.5 为什么关闭 React Strict Mode
```typescript
// next.config.ts
reactStrictMode: false
```
React 18+ Strict Mode 会在开发环境 **双重调用** effect导致
- WebSocket 连接被创建两次
- PeerConnection 生命周期混乱
- DataChannel 状态不一致
---
## 六、部署模式
### 模式一:开发模式
```bash
# 终端 1Go 后端
go run cmd/main.go # :8080
# 终端 2Next.js 前端
cd chuan-next && yarn dev # :3000 (turbopack)
```
前端 API 调用链:`浏览器 → localhost:3000/api/* → Next.js API Route → localhost:8080/api/*`
### 模式二生产模式Go 嵌入前端)
```bash
cd chuan-next && yarn build:ssg # 输出到 out/
cp -r out/ ../internal/web/frontend/ # 复制到 Go embed 目录
go build -o chuan cmd/*.go # 编译
./chuan # :8080 同时提供 API + 前端
```
所有请求都由 Go 单进程处理,前端静态文件通过 `go:embed` 嵌入二进制。
---
## 七、安全与限制
| 维度 | 现状 |
|------|------|
| 加密 | WebRTC 自带 DTLS 加密P2P 数据全程加密传输 |
| 认证 | 无用户认证,仅靠 6 位取件码 |
| 房间安全 | 取件码空间 34^6 ≈ 15 亿,暴力破解概率低 |
| 数据留存 | 服务器零数据留存,所有文件仅在浏览器内存/临时存储 |
| 并发限制 | 每房间最多 2 人,单进程内存管理 |
| 文件大小 | 受限于浏览器内存(大文件需接收方有足够内存组装 Blob|
| NAT 穿透 | 依赖 STUN 服务器,对称 NAT 需 TURN 服务器(默认未配置)|

471
FRONTEND_ANALYSIS.md Normal file
View File

@@ -0,0 +1,471 @@
# 前端代码架构分析 & 优化计划
> 最后更新2026-02-28
---
## 目录
- [一、全局数据](#一全局数据)
- [二、文件清单与行数](#二文件清单与行数)
- [三、核心问题分析](#三核心问题分析)
- [3.1 过度抽象的连接层5 层 Hook 嵌套)](#31-过度抽象的连接层5-层-hook-嵌套)
- [3.2 "Shared" 名不副实 — 多处独立创建连接](#32-shared-名不副实--多处独立创建连接)
- [3.3 WebRTCFileTransfer 组件是复杂度炸弹](#33-webrtcfiletransfer-组件是复杂度炸弹)
- [3.4 到处重复的类型定义和验证逻辑](#34-到处重复的类型定义和验证逻辑)
- [3.5 useTabNavigation 不应耦合连接管理](#35-usetabnavigation-不应耦合连接管理)
- [3.6 其他问题汇总](#36-其他问题汇总)
- [四、优化计划](#四优化计划)
- [Phase 1消除重复、统一类型](#phase-1消除重复统一类型)
- [Phase 2扁平化 Hook 层](#phase-2扁平化-hook-层)
- [Phase 3拆分超级组件](#phase-3拆分超级组件)
- [Phase 4基础设施清理](#phase-4基础设施清理)
- [五、优化总览](#五优化总览)
---
## 一、全局数据
| 指标 | 数值 |
|------|------|
| 总行数 | **11,776 行** (69 个 .ts/.tsx 文件) |
| Hooks 文件 | **19 个**, ~3,500 行 |
| 组件文件 | **20 个**, ~5,500 行 |
| `console.log/warn/error` | **428 处** |
| `useEffect` 调用 | **75 处** |
| `setTimeout` / 延时等待 | **28 处** |
| `FileInfo` 接口重复定义 | **7 处** |
---
## 二、文件清单与行数
### 2.1 Hooks 层19 个文件,~3,500 行)
#### Connection 模块8 个文件,~1,640 行)
| 文件 | 行数 | 关键导出 | 职责 |
|------|------|----------|------|
| `hooks/connection/index.ts` | 5 | 4 个 re-export | 桶文件 |
| `hooks/connection/useWebRTCConnectionCore.ts` | **569** | `useWebRTCConnectionCore()` | **最大最复杂的 hook** — WebSocket 连接、PeerConnection 创建、信令消息处理(offer/answer/ICE/peer-joined/disconnection)、房间管理 |
| `hooks/connection/useWebRTCDataChannelManager.ts` | **355** | `useWebRTCDataChannelManager()` | 数据通道创建(sender/receiver 分支)、消息/二进制数据分发、通道注册 |
| `hooks/connection/useWebRTCTrackManager.ts` | 229 | `useWebRTCTrackManager()` | 媒体轨道添加/移除、createOffer、onTrack 轮询重试 |
| `hooks/connection/useWebRTCStateManager.ts` | 77 | `useWebRTCStateManager()` | 对 zustand store 的薄封装 |
| `hooks/connection/useSharedWebRTCManager.ts` | 118 | `useSharedWebRTCManager()``WebRTCConnection` | **门面模式** — 组合 4 个子 manager暴露统一的 `WebRTCConnection` 接口 |
| `hooks/connection/useConnectionState.ts` | 137 | `useConnectionState()` | 连接错误展示/状态清理的 side-effect hook |
| `hooks/connection/useRoomConnection.ts` | 110 | `useRoomConnection()` | 房间验证逻辑(HTTP check + connect) |
| `hooks/connection/useWebRTCSupport.ts` | 40 | `useWebRTCSupport()` | WebRTC 浏览器兼容性检测 |
**复杂度观察:**
- `useWebRTCConnectionCore.ts` 是**整个代码库最复杂的文件569 行)**。它在 `ws.onmessage` 中处理了 7 种信令消息类型,每种都有嵌套的 `if/else` 分支处理 sender/receiver 角色、reconnect 状态、PeerConnection 是否存在。特别是 `answer` 处理(约 L268-L350有 3 层 `if/else` 嵌套 + 异常恢复逻辑。
- `useWebRTCDataChannelManager` 中 sender 和 receiver 的 `onerror` 处理器是**完全重复的代码**(各约 30 行相同的 `switch` 语句)。
- `useWebRTCTrackManager.onTrack` 中有**轮询重试机制**50 次 × 每 100ms是一种脆弱的模式。
#### File-Transfer 模块4 个文件,~916 行)
| 文件 | 行数 | 关键导出 | 职责 |
|------|------|----------|------|
| `hooks/file-transfer/index.ts` | 4 | re-export | 桶文件 |
| `hooks/file-transfer/useFileTransferBusiness.ts` | **676** | `useFileTransferBusiness(connection)` | **第二大 hook** — 文件分块传输(256KB chunks)、CRC32 校验和、ACK 确认、重试(5 次指数退避)、流控、进度追踪 |
| `hooks/file-transfer/useFileStateManager.ts` | 171 | `useFileStateManager()` | 文件选择、文件列表维护、进度状态管理 |
| `hooks/file-transfer/useFileListSync.ts` | 65 | `useFileListSync()` | 防抖式文件列表同步 |
**复杂度观察:**
- `useFileTransferBusiness` 内含**自实现的 CRC32 校验算法**`calculateChecksum`, `simpleChecksum`)和完整的**可靠传输协议**ACK/重传/超时/流控),这本质上是在应用层重建了 TCP 的功能。
- `useFileStateManager` 有 3 个 `useEffect` 都在监听并同步文件列表,任何一个变化都可能触发链式更新,存在复杂的依赖关系。
#### Text-Transfer 模块2 个文件,~177 行)
| 文件 | 行数 | 关键导出 |
|------|------|----------|
| `hooks/text-transfer/index.ts` | 2 | re-export |
| `hooks/text-transfer/useTextTransferBusiness.ts` | 175 | `useTextTransferBusiness(connection)` |
结构简洁。发送 `text-sync``text-typing` 消息。连接状态从 `connection` 参数同步。
#### Desktop-Share 模块2 个文件,~545 行)
| 文件 | 行数 | 关键导出 |
|------|------|----------|
| `hooks/desktop-share/index.ts` | 2 | re-export |
| `hooks/desktop-share/useDesktopShareBusiness.ts` | **543** | `useDesktopShareBusiness()` |
**复杂度观察:**
- 与 file-transfer/text-transfer 不同,**桌面共享直接内部调用** `useSharedWebRTCManager()`,而非从外部接收一个 `connection` 参数。这导致了**不一致的依赖注入模式**。
- `setupVideoSending` 中有大量 `await new Promise(resolve => setTimeout(resolve, ...))` 等待连接稳定的代码500ms + 2000ms是一种脆弱的时序控制。
#### Settings 模块3 个文件,~283 行)
| 文件 | 行数 | 关键导出 |
|------|------|----------|
| `hooks/settings/index.ts` | 3 | re-export |
| `hooks/settings/useIceServersConfig.ts` | 251 | `useIceServersConfig()`, `getIceServersConfig()` |
| `hooks/settings/useWebRTCConfigSync.ts` | 29 | `useWebRTCConfigSync()` |
`useIceServersConfig` 同时导出 hookReact 组件用)和独立函数 `getIceServersConfig()`(非组件代码用),设计合理。
#### UI 模块5 个文件,~496 行)
| 文件 | 行数 | 关键导出 |
|------|------|----------|
| `hooks/ui/webRTCStore.ts` | 46 | `useWebRTCStore` (zustand) |
| `hooks/ui/useTabNavigation.ts` | 183 | `useTabNavigation()` |
| `hooks/ui/useURLHandler.ts` | 210 | `useURLHandler()` |
| `hooks/ui/useConfirmDialog.ts` | 52 | `useConfirmDialog()` |
| `hooks/ui/index.ts` | 5 | re-export |
**复杂度观察:**
- `useTabNavigation` **内部调用了** `useSharedWebRTCManager()``useURLHandler()``useConfirmDialog()`,使得一个 "UI Tab 导航" hook 深度耦合了 WebRTC 连接管理逻辑。
- `useURLHandler` 是泛型 hook支持 `modeConverter` 进行模式映射(如 desktop share 的 `share/view``send/receive`)。
---
### 2.2 组件层20 个文件,~5,500 行)
#### 顶层业务组件
| 文件 | 行数 | 关键导出 | 职责 |
|------|------|----------|------|
| `components/WebRTCFileTransfer.tsx` | **658** | `WebRTCFileTransfer` | **最大的组件** — 文件传输页面容器,组合 7 个 hooks + 2 个子组件 |
| `components/WebRTCTextImageTransfer.tsx` | 104 | `WebRTCTextImageTransfer` | 文本传输页面容器,较简洁 |
| `components/DesktopShare.tsx` | 199 | `DesktopShare` (default) | 桌面共享页面容器,含 `useScreenShareSupport` 内部 hook |
| `components/WebRTCSettings.tsx` | 565 | `WebRTCSettings` (default) | ICE 服务器配置页(自包含,含 `AddServerModal` 内部组件) |
#### WebRTC 子组件(纯展示/交互层)
| 文件 | 行数 | 职责 |
|------|------|------|
| `components/webrtc/WebRTCFileUpload.tsx` | 357 | 发送方文件列表 UI拖拽上传、文件展示、取件码展示 |
| `components/webrtc/WebRTCFileReceive.tsx` | 399 | 接收方取件码输入 + 文件下载列表 UI |
| `components/webrtc/WebRTCTextSender.tsx` | **465** | 文本发送方(**内含自己的连接管理逻辑** |
| `components/webrtc/WebRTCTextReceiver.tsx` | **385** | 文本接收方(**内含自己的连接管理逻辑** |
| `components/webrtc/WebRTCDesktopSender.tsx` | 325 | 桌面共享发送方 |
| `components/webrtc/WebRTCDesktopReceiver.tsx` | 370 | 桌面共享接收方(**含重复的房间验证逻辑** |
**复杂度观察:**
- `WebRTCTextSender``WebRTCTextReceiver` **各自独立调用** `useSharedWebRTCManager()`,然后创建 `useTextTransferBusiness(connection)``useFileTransferBusiness(connection)`。这意味着**每个子组件都创建了自己的独立 WebRTC 连接实例**。
- `WebRTCDesktopReceiver` 中有两处几乎完全相同的房间验证逻辑(`handleJoinViewing``autoJoin` useEffect代码重复约 ~80 行。
- `WebRTCFileReceive` 中也有独立的房间验证代码(`validatePickupCode`),与 `useRoomConnection` 中的 `checkRoomStatus` 功能重复。
#### 共享/展示组件
| 文件 | 行数 | 职责 |
|------|------|------|
| `components/ConnectionStatus.tsx` | 241 | 连接状态 UI3 种模式full/compact/inline |
| `components/WebRTCConnectionStatus.tsx` | 185 | WebRTC 连接状态 UI + `WebRTCStatusIndicator` |
| `components/RoomInfoDisplay.tsx` | 123 | 取件码/QR 码展示通用组件 |
| `components/QRCodeDisplay.tsx` | 68 | QR 码 canvas 渲染 |
| `components/DesktopViewer.tsx` | **546** | 桌面视频播放器(全屏/统计/控制/鼠标隐藏) |
| `components/Hero.tsx` | 42 | 页面顶部标题/链接 |
| `components/RoomStatusDisplay.tsx` | 39 | 房间实时状态展示 |
| `components/WebRTCUnsupportedModal.tsx` | 186 | 浏览器不支持 WebRTC 弹窗 |
| `components/WeChatGroup.tsx` | 68 | 微信群二维码页 |
`ConnectionStatus``WebRTCConnectionStatus` 是**两个独立的连接状态组件**,功能高度重叠但接口不同:前者使用 zustand store后者接受 `WebRTCConnection` prop。
---
### 2.3 页面层
| 文件 | 行数 | 职责 |
|------|------|------|
| `app/page.tsx` | 13 | 入口,渲染 `HomePageWrapper` |
| `app/HomePageWrapper.tsx` | 14 | Suspense 包装层 |
| `app/HomePage.tsx` | 206 | **主页面** — Tab 布局,渲染 5 个 Tab 内容 |
| `app/layout.tsx` | 40 | RootLayout + `ToastProvider` |
| `app/help/HelpPage.tsx` | 761 | 帮助页面 |
### 2.4 Types
`types/index.ts`51 行)— 定义了 `FileInfo`, `TransferProgress`, `RoomStatus`, `FileChunk`, `WebSocketMessage`, `UseWebSocketReturn`
**问题:** `FileInfo` 类型在此文件中定义了一次,但在 `WebRTCFileTransfer.tsx``WebRTCFileUpload.tsx``WebRTCFileReceive.tsx``useFileTransferBusiness.ts``useFileStateManager.ts``useFileListSync.ts` 中各自**重新定义了完全相同的 `FileInfo` 接口**(至少 7 处重复定义)。`UseWebSocketReturn` 类型实际没有被任何代码使用。
### 2.5 Lib 工具层
| 文件 | 行数 | 职责 |
|------|------|------|
| `lib/config.ts` | 150 | 环境配置、URL 构造(`getWsUrl`, `getBackendUrl` 等) |
| `lib/api-utils.ts` | 144 | 统一 fetch 封装(`apiFetch`, `apiGet`, `apiPost`...) |
| `lib/client-api.ts` | 119 | `ClientAPI`OOP style API 封装) |
| `lib/webrtc-support.ts` | 162 | WebRTC 特性检测 + 浏览器信息 |
| `lib/utils.ts` | 6 | `cn()` — tailwind 合并工具 |
| `lib/static-config.ts` | 35 | 静态/动态页面路由定义 |
**问题:** `api-utils.ts``client-api.ts` 是**两套并行的 API 调用方案**。`ClientAPI` 是 class-based 封装,`apiFetch` 是函数式封装,功能完全重叠。实际代码中组件大多直接调用 `fetch()`,两个 utils 文件利用率低。
---
## 三、核心问题分析
### 3.1 过度抽象的连接层5 层 Hook 嵌套)
从 UI 按钮点击到实际网络传输,需穿越 **5 层抽象**
```
组件 (WebRTCFileTransfer) 658 行
└→ useFileTransferBusiness(connection) 676 行
└→ useSharedWebRTCManager() 118 行 ← 门面
├→ useWebRTCStateManager() 77 行 ← zustand 薄封装
├→ useWebRTCDataChannelManager() 355 行 ← DC 管理
├→ useWebRTCTrackManager() 229 行 ← 轨道管理
└→ useWebRTCConnectionCore() 569 行 ← WS+信令+PC
```
`useWebRTCStateManager` 只是对 47 行的 zustand store 做了 `useCallback` 包装,**额外增加 77 行代码但零业务价值**。`useSharedWebRTCManager` 又对 4 个子 manager 做了一层门面包装。
**状态流动图:**
```
zustand Store (webRTCStore)
↑ 写入
useWebRTCStateManager
↑ 使用
useWebRTCConnectionCore / useWebRTCDataChannelManager
↑ 组合
useSharedWebRTCManager → 返回 WebRTCConnection 对象
↑ 调用 ↓ 传入
组件层 useFileTransferBusiness(connection)
(WebRTCFileTransfer) useTextTransferBusiness(connection)
↓ 使用 useDesktopShareBusiness() ← 内部直接调用 Manager
useFileStateManager
useFileListSync
useConnectionState
useRoomConnection
useURLHandler
useTabNavigation
```
### 3.2 "Shared" 名不副实 — 多处独立创建连接
`useSharedWebRTCManager()`**4 处被独立调用**,每次返回新实例:
| 调用位置 | 文件 | 行号 | 问题 |
|----------|------|------|------|
| 文件传输 | `WebRTCFileTransfer.tsx` | L37 | 文件传输的连接 |
| 文字发送 | `WebRTCTextSender.tsx` | L34 | **自己创建连接** |
| 文字接收 | `WebRTCTextReceiver.tsx` | L40 | **自己创建连接** |
| 桌面共享 | `useDesktopShareBusiness.ts` | L15 | 在 hook 内部直接调用 |
每个组件各自调用 `useSharedWebRTCManager()` → 各自创建独立的 PeerConnection 和 DataChannel。**"shared" 名不副实** —— hook 每次调用返回新实例。
`useDesktopShareBusiness` 是**第四种模式**:在 hook 内部自行调用 `useSharedWebRTCManager()`,与 file/text 模块的依赖注入模式不一致。
### 3.3 WebRTCFileTransfer 组件是复杂度炸弹
658 行的组件内:
- **使用 7 个 Hook**`useSharedWebRTCManager`, `useFileTransferBusiness`, `useFileListSync`, `useFileStateManager`, `useRoomConnection`, `useURLHandler`, `useConnectionState`
- **9 个 `useEffect`**:多个 effect 监听重叠的状态(`isConnected`, `isConnecting`, `error`),一次状态变化触发多个 effect 连锁执行
- **与 `useConnectionState` 完全重复的错误处理**
`WebRTCFileTransfer.tsx` 第 316-368 行的错误处理 if/else 链:
```typescript
// 组件内的错误处理 (L316-L368)
if (error.includes('WebSocket')) {
errorMessage = '服务器连接失败...';
} else if (error.includes('数据通道')) { ... }
```
`useConnectionState.ts` 第 46-64 行**完全相同**
```typescript
// Hook 内的错误处理 (L46-L64) — 一模一样的 if/else
if (error.includes('WebSocket')) {
errorMessage = '服务器连接失败...';
} else if (error.includes('数据通道')) { ... }
```
**结果:同一个错误被 Toast 弹出两次。**
**9 个 useEffect 明细:**
| # | 监听依赖 | 职责 | 问题 |
|---|---------|------|------|
| 1 | `onFileListReceived, mode` | 文件列表接收 | 正常 |
| 2 | `onFileReceived, updateFileStatus` | 文件接收完成 | 正常 |
| 3 | `onFileProgress, mode, isConnected, error` | 进度更新 | 正常 |
| 4 | `onFileRequested, mode, selectedFiles, ...` | 文件请求处理 | 正常 |
| 5 | `error, mode, showToast, lastError` | 错误处理 | ⚠️ 与 useConnectionState 重复 |
| 6 | `isWebSocketConnected, isConnected, isConnecting, ...` | 连接状态清理 | ⚠️ 与 useConnectionState 重复 |
| 7 | `isConnected, isPeerConnected, isConnecting, ...` | 日志输出 | ⚠️ 纯日志,可删除 |
| 8 | `connection.isPeerConnected, mode, syncFileListToReceiver` | P2P 建立时同步 | 正常 |
| 9 | `selectedFiles, mode, pickupCode` | 文件选择变化同步 | ⚠️ 与 useFileStateManager 部分重复 |
### 3.4 到处重复的类型定义和验证逻辑
**`FileInfo` 接口在 7 个文件中各自定义**,且字段不完全一致:
| 文件 | 行号 | 字段差异 |
|------|------|---------|
| `types/index.ts` | L2 | `export interface FileInfo`**无 status/progress**,有 lastModified |
| `WebRTCFileTransfer.tsx` | L13 | `interface FileInfo`**有 status/progress** |
| `WebRTCFileUpload.tsx` | L10 | `interface FileInfo` — 有 status/progress |
| `WebRTCFileReceive.tsx` | L10 | `interface FileInfo` — 有 status/progress |
| `useFileListSync.ts` | L3 | `interface FileInfo` — 有 status/progress |
| `useFileStateManager.ts` | L3 | `interface FileInfo` — 有 status/progress |
| `useFileTransferBusiness.ts` | L25 | `interface FileInfo` — 有 status/progress |
**房间验证逻辑至少有 4 处重复实现**
| 位置 | 函数名 | 行数 |
|------|--------|------|
| `hooks/connection/useRoomConnection.ts` | `checkRoomStatus` + `joinRoom` | ~60 行 |
| `components/webrtc/WebRTCFileReceive.tsx` | `validatePickupCode` | ~40 行 |
| `components/webrtc/WebRTCTextReceiver.tsx` | `joinRoom` | ~50 行 |
| `components/webrtc/WebRTCDesktopReceiver.tsx` | `handleJoinViewing` + `autoJoin` | ~80 行 |
### 3.5 useTabNavigation 不应耦合连接管理
`useTabNavigation.ts` 是一个 **UI 导航 hook**,却 import 并调用了:
- `useSharedWebRTCManager()` — 获取 `disconnect` 方法
- `useWebRTCStore` — 直接读取连接状态
- `useURLHandler()` — URL 管理
- `useConfirmDialog()` — 确认弹窗
Tab 切换逻辑不应该知道 WebRTC 的存在,连接生命周期应由更上层管理。
### 3.6 其他问题汇总
| 问题 | 严重程度 | 详情 |
|------|---------|------|
| **428 条 console.log** 散布在生产代码 | 🟡 中 | 使用 emoji 前缀(如 🔧🚀📤),无级别控制,生产环境产生大量噪音 |
| **两个功能重叠的连接状态组件** | 🟡 中 | `ConnectionStatus`241 行,使用 zustand+ `WebRTCConnectionStatus`185 行,使用 prop426 行总计 |
| **两套 API 封装并存** | 🟡 中 | `api-utils.ts`(函数式)+ `client-api.ts`class-based263 行,但组件大多直接用 `fetch()` |
| **`types/index.ts` 中的死代码** | 🟢 低 | `UseWebSocketReturn` 等类型无人引用 |
| **28 处 `setTimeout` 作为同步机制** | 🟡 中 | 500ms/2000ms 硬编码等待连接稳定,在低性能设备上可能不够,在高性能设备上浪费时间 |
| **`useWebRTCTrackManager.onTrack` 轮询** | 🟢 低 | 50 次 × 100ms 轮询等待轨道就绪,应改为事件驱动 |
| **DataChannel `onerror` 处理重复** | 🟡 中 | sender 和 receiver 分支中约 30 行完全相同的 switch 语句 |
---
## 四、优化计划
### Phase 1消除重复、统一类型
> 影响大,改动小。预估耗时:半天。
| 任务 | 预估时间 | 效果 |
|------|---------|------|
| 统一 `FileInfo`(含 status/progress`types/index.ts`,删除 7 处重复定义 | 30min | **-60 行**,消除类型漂移 |
| 抽取 `validateRoom(code)` 通用函数,替代 4 处房间验证 | 30min | **-200 行** |
| 合并 `ConnectionStatus` + `WebRTCConnectionStatus` 为一个组件 | 1h | **-150 行** |
| 删除 `useConnectionState` hook其逻辑已在组件中重复 | 15min | **-138 行**,修复双重 Toast |
| 删除 `api-utils.ts``client-api.ts`,统一为一套 | 30min | **-140 行** |
**Phase 1 预估净减少:~690 行**
---
### Phase 2扁平化 Hook 层
> 核心改造风险中等。预估耗时1-2 天。
**目标5 层 → 3 层**
```
当前 5 层:
组件 → 业务Hook → SharedManager → SubManagers(4个) → Native API
目标 3 层:
组件 → 业务Hook → useWebRTCConnection(合并) → Native API
```
**具体步骤:**
1. **删除 `useWebRTCStateManager`77 行)**
- 直接在 `useWebRTCConnectionCore` 中使用 `useWebRTCStore`
- 一个 hook 包装另一个 hook 再包装 zustand store 毫无必要
2. **合并 `useSharedWebRTCManager` + `useWebRTCConnectionCore`**
- `useSharedWebRTCManager` 只是 4 个子模块的胶水代码
- 将其与 `useWebRTCConnectionCore` 合并为 `useWebRTCConnection`
- 内联数据通道和轨道管理逻辑
- 用清晰的函数分组而非文件拆分来组织代码
3. **统一连接注入模式**
-`useDesktopShareBusiness` 也改为接受 `connection` 参数
- 与 file/text 保持一致
**改造后的目标结构:**
```
hooks/
useWebRTCConnection.ts ← 合并后的唯一连接 hook (~600行)
useFileTransfer.ts ← file-transfer 业务
useTextTransfer.ts ← text-transfer 业务
useDesktopShare.ts ← desktop 业务(接受 connection 参数)
useIceServersConfig.ts ← 保留
webRTCStore.ts ← 保留(直接使用,不再包装)
useURLHandler.ts ← 保留
useTabNavigation.ts ← 删除 WebRTC 依赖
useConfirmDialog.ts ← 保留
```
**预估净减少:~400 行 + 消除 2 层抽象**
---
### Phase 3拆分超级组件
> 降低单文件复杂度。预估耗时1 天。
**`WebRTCFileTransfer.tsx`658 行)→ 拆分为:**
| 新文件 | 职责 | 预估行数 |
|--------|------|----------|
| `WebRTCFileTransfer.tsx` | 仅做 mode 切换 + 子组件渲染 | ~60 行 |
| `useSenderLogic.ts` | 房间创建 + 文件列表同步 | ~150 行 |
| `useReceiverLogic.ts` | 加入房间 + 下载管理 | ~120 行 |
| `useTransferEffects.ts` | 集中管理 useEffect | ~100 行 |
**9 个 `useEffect` 优化为 4 个:**
| 当前 | 优化后 |
|------|--------|
| `onFileListReceived` effect | → 合并到 `useTransferEffects` |
| `onFileReceived` effect | → 合并到 `useTransferEffects` |
| `onFileProgress` effect | → 合并到 `useTransferEffects` |
| `onFileRequested` effect | → 合并到 `useTransferEffects` |
| 错误处理 effect × 2 (重复) | → 删除 1 个,保留 1 个 |
| 连接日志 effect × 2 | → 删除(改用 logger 工具) |
| `selectedFiles` 同步 effect | → 保留,移入 `useSenderLogic` |
---
### Phase 4基础设施清理
> 代码卫生。预估耗时:半天。
| 任务 | 效果 |
|------|------|
| 引入 `logger.ts` 工具dev 输出 / prod 静默),替换 428 个 `console.log` | 清洁生产日志 |
| 把 `setTimeout` 同步替换为事件监听 Promise监听 `connectionstatechange` / `datachannel.open` | 消除脆弱时序 |
| `useTabNavigation` 改为通过回调通知父组件处理断连,不直接依赖 WebRTC | 关注点分离 |
| 清理 `types/index.ts` 中未使用的类型 | 减少死代码 |
| DataChannel `onerror` 提取为共享处理器 | -30 行重复 |
---
## 五、优化总览
| Phase | 目标 | 代码影响 | 难度 | 耗时 |
|-------|------|---------|------|------|
| **Phase 1** | 消除重复 | **-690 行** | ⭐ 低 | 半天 |
| **Phase 2** | 扁平化 Hook 层 5→3 | **-400 行** + 结构简化 | ⭐⭐⭐ 高 | 1-2 天 |
| **Phase 3** | 拆分超级组件 | 0重组织 | ⭐⭐ 中 | 1 天 |
| **Phase 4** | 基础设施 | **-428 行** console.log | ⭐ 低 | 半天 |
**总预估**
- 代码量11,776 行 → ~10,000 行(减少 ~15%
- 抽象层数5 层 → 3 层
- useEffect75 个 → ~50 个
- console.log428 处 → 0替换为 logger
- FileInfo 定义7 处 → 1 处
- 房间验证实现4 处 → 1 处

1237
PROTOCOL_ANALYSIS.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSharedWebRTCManager, useConnectionState, useRoomConnection } from '@/hooks/connection';
import { useSharedWebRTCManager, useRoomConnection } from '@/hooks/connection';
import { useFileTransferBusiness, useFileListSync, useFileStateManager } from '@/hooks/file-transfer';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
@@ -9,15 +9,7 @@ import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { WebRTCFileReceive } from '@/components/webrtc/WebRTCFileReceive';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
import type { FileInfo } from '@/types';
export const WebRTCFileTransfer: React.FC = () => {
const { showToast } = useToast();
@@ -101,23 +93,6 @@ export const WebRTCFileTransfer: React.FC = () => {
onAutoJoinRoom: joinRoom
});
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 generateCode = async () => {
if (selectedFiles.length === 0) {
@@ -189,286 +164,87 @@ export const WebRTCFileTransfer: React.FC = () => {
// URL处理逻辑已经移到 hook 中
};
// 处理文件列表更新
// === 注册所有数据通道回调 ===
useEffect(() => {
const cleanup = onFileListReceived((fileInfos: FileInfo[]) => {
console.log('=== 收到文件列表更新 ===');
console.log('文件列表:', fileInfos);
if (mode === 'receive') {
setFileList(fileInfos);
}
});
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') {
// 检查连接状态
const cleanups = [
onFileListReceived((fileInfos: FileInfo[]) => {
if (mode === 'receive') setFileList(fileInfos);
}),
onFileReceived((fileData: { id: string; file: File }) => {
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
updateFileStatus(fileData.id, 'completed', 100);
}),
onFileProgress((progressInfo) => {
if (!isConnected || error) return;
setCurrentTransferFile({
fileId: progressInfo.fileId,
fileName: progressInfo.fileName,
progress: progressInfo.progress
});
updateFileProgress(progressInfo.fileId, progressInfo.fileName, progressInfo.progress);
if (progressInfo.progress >= 100 && mode === 'send') {
setCurrentTransferFile(null);
}
}),
onFileRequested((fileId: string, fileName: string) => {
if (mode !== 'send') return;
if (!isConnected || error) {
console.log('连接已断开,无法发送文件');
showToast('连接已断开,无法发送文件', "error");
return;
}
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
// 在发送方的selectedFiles中查找对应文件
const file = selectedFiles.find(f => f.name === fileName);
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading - 统一使用updateFileStatus
updateFileStatus(fileId, 'downloading', 0);
// 发送文件
try {
sendFile(file, fileId);
// 移除不必要的Toast - 传输开始状态在UI中已经显示
} catch (sendError) {
console.error('发送文件失败:', sendError);
showToast(`发送文件失败: ${fileName}`, "error");
// 重置文件状态 - 统一使用updateFileStatus
updateFileStatus(fileId, 'ready', 0);
}
} else {
console.warn('接收模式下收到文件请求,忽略');
})
];
return () => cleanups.forEach(cleanup => cleanup());
}, [onFileListReceived, onFileReceived, onFileProgress, onFileRequested,
mode, isConnected, error, selectedFiles, sendFile, showToast,
updateFileStatus, updateFileProgress]);
// === 错误处理和连接状态清理 ===
const lastErrorRef = useRef<string>('');
useEffect(() => {
// 处理新错误
if (error && error !== lastErrorRef.current) {
lastErrorRef.current = error;
const errorMap: [string, string][] = [
['WebSocket', '服务器连接失败,请检查网络连接或稍后重试'],
['数据通道', '数据通道连接失败,请重新尝试连接'],
['连接超时', '连接超时,请检查网络状况或重新尝试'],
['连接失败', 'WebRTC连接失败可能是网络环境限制请尝试刷新页面'],
['信令错误', '信令服务器错误,请稍后重试'],
['创建连接失败', '无法建立P2P连接请检查网络设置'],
];
const matched = errorMap.find(([key]) => error.includes(key));
showToast(matched ? matched[1] : error, "error");
}
// 连接断开时清理传输状态
const isDisconnected = pickupCode && !isConnecting && (!isConnected || error);
if (isDisconnected && (fileList.length > 0 || currentTransferFile)) {
if (!error && !isWebSocketConnected) {
showToast('与服务器的连接已断开,请重新连接', "error");
}
});
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error, showToast, updateFileStatus]);
// 处理连接错误
const [lastError, setLastError] = useState<string>('');
useEffect(() => {
if (error && error !== lastError) {
console.log('=== 连接错误处理 ===');
console.log('错误信息:', error);
console.log('当前模式:', mode);
// 根据错误类型显示不同的提示
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);
// 重置所有正在传输的文件状态
setFileList(prev => prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
));
}
}
}, [error, mode, showToast, lastError]);
// 监听连接状态变化和清理传输状态
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 = fileList.length > 0;
// 只有在之前有连接活动时才显示断开提示和清理状态
if (hasFileList || hasCurrentTransfer) {
if (!isWebSocketConnected && pickupCode) {
showToast('与服务器的连接已断开,请重新连接', "error");
}
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;
});
}
}
// WebSocket连接成功时的提示
if (isWebSocketConnected && isConnecting && !isConnected) {
console.log('WebSocket已连接正在建立P2P连接...');
}
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileList.length]);
// 监听连接状态变化并提供日志
useEffect(() => {
console.log('=== WebRTC连接状态变化 ===');
console.log('连接状态:', {
isConnected,
isConnecting,
isWebSocketConnected,
pickupCode,
mode,
selectedFilesCount: selectedFiles.length,
fileListCount: fileList.length
});
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, fileList.length]);
// 监听P2P连接建立时的状态变化
useEffect(() => {
if (connection.isPeerConnected && mode === 'send' && fileList.length > 0) {
console.log('P2P连接已建立数据通道首次打开初始化文件列表');
// 数据通道第一次打开时进行初始化
syncFileListToReceiver(fileList, '数据通道初始化');
}
}, [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)
setCurrentTransferFile(null);
setFileList(prev => {
const hasDownloading = prev.some(item => item.status === 'downloading');
return hasDownloading
? prev.map(item => item.status === 'downloading' ? { ...item, status: 'ready' as const, progress: 0 } : item)
: prev;
});
setFileList(newFileInfos);
}
}, [selectedFiles, mode, pickupCode]);
}, [error, isConnected, isConnecting, isWebSocketConnected, pickupCode, showToast, currentTransferFile, fileList.length]);
// 请求下载文件(接收方调用)
const requestFile = (fileId: string) => {

View File

@@ -8,6 +8,7 @@ import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
import DesktopViewer from '@/components/DesktopViewer';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { validateRoomCode, checkRoomStatus, handleNetworkError } from '@/lib/room-utils';
interface WebRTCDesktopReceiverProps {
className?: string;
@@ -37,8 +38,9 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
const trimmedCode = inputCode.trim();
// 检查房间代码格式
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位房间代码', "error");
const validationError = validateRoomCode(trimmedCode);
if (validationError) {
showToast(validationError, "error");
return;
}
@@ -53,35 +55,9 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
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();
const result = await checkRoomStatus(trimmedCode);
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查房间代码是否正确';
}
showToast(errorMessage, "error");
return;
}
// 检查房间是否已满
if (result.is_room_full) {
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
showToast(result.error || '房间验证失败', "error");
return;
}
@@ -94,26 +70,11 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
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;
}
}
const errorMessage = error instanceof Error ? handleNetworkError(error) : '加入观看失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
setIsJoiningRoom(false); // 重置加入房间状态
setIsJoiningRoom(false);
}
}, [desktopShare, inputCode, isJoiningRoom, showToast]);
@@ -148,8 +109,9 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
const trimmedCode = initialCode.trim();
// 检查房间代码格式
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('房间代码格式不正确', "error");
const validationError = validateRoomCode(trimmedCode);
if (validationError) {
showToast(validationError, "error");
return;
}
@@ -157,36 +119,11 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
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();
const result = await checkRoomStatus(trimmedCode);
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查房间代码是否正确';
}
showToast(errorMessage, "error");
return;
}
// 检查房间是否已满
if (result.is_room_full) {
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
showToast(result.error || '房间验证失败', "error");
return;
}
@@ -198,22 +135,7 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
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;
}
}
const errorMessage = error instanceof Error ? handleNetworkError(error) : '自动加入观看失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);

View File

@@ -6,15 +6,8 @@ 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;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
import { checkRoomStatus } from '@/lib/room-utils';
import type { FileInfo } from '@/types';
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;
@@ -68,50 +61,18 @@ export function WebRTCFileReceive({
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 });
const result = await checkRoomStatus(code);
if (!response.ok || !data.success) {
let errorMessage = data.message || '取件码验证失败';
// 特殊处理房间人数已满的情况
if (data.message?.includes('房间人数已满') || data.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (data.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (data.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
// 显示toast错误提示
showToast(errorMessage, 'error');
console.log('验证失败:', errorMessage);
if (!result.success) {
showToast(result.error || '取件码验证失败', 'error');
console.log('验证失败:', result.error);
return false;
}
// 检查房间是否已满
if (data.is_room_full) {
const errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
showToast(errorMessage, 'error');
console.log('房间已满:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
console.log('取件码验证成功');
return true;
} catch (error) {
console.error('验证取件码时发生错误:', error);
const errorMessage = '网络错误,请检查连接后重试';
// 显示toast错误提示
showToast(errorMessage, 'error');
return false;
} finally {
setIsValidating(false);
}

View File

@@ -5,16 +5,7 @@ import { Button } from '@/components/ui/button';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
import type { FileInfo } from '@/types';
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;

View File

@@ -9,6 +9,7 @@ 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';
import { checkRoomStatus } from '@/lib/room-utils';
interface WebRTCTextReceiverProps {
initialCode?: string;
@@ -136,39 +137,22 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
try {
console.log('=== 开始加入房间 ===', code);
// 验证房间
const response = await fetch(`/api/room-info?code=${code}`);
const roomData = await response.json();
if (!response.ok) {
let errorMessage = roomData.error || '房间不存在或已过期';
// 特殊处理房间人数已满的情况
if (roomData.message?.includes('房间人数已满') || roomData.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (roomData.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (roomData.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
throw new Error(errorMessage);
}
// 检查房间是否已满
if (roomData.is_room_full) {
throw new Error('当前房间人数已满,正在传输中无法加入,请稍后再试');
const result = await checkRoomStatus(code);
if (!result.success) {
showToast(result.error || '加入房间失败', "error");
return;
}
console.log('=== 房间验证成功 ===', roomData);
console.log('=== 房间验证成功 ===');
setPickupCode(code);
// 连接到房间
await connectAll(code, 'receiver');
} catch (error: any) {
} catch (error: unknown) {
console.error('加入房间失败:', error);
showToast(error.message || '加入房间失败', "error");
const message = error instanceof Error ? error.message : '加入房间失败';
showToast(message, "error");
} finally {
setIsValidating(false);
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useWebRTCStateManager } from './useWebRTCStateManager';
import { useCallback, useMemo } from 'react';
import { useWebRTCStore, type WebRTCStateManager } from '../ui/webRTCStore';
import { useWebRTCDataChannelManager, WebRTCMessage } from './useWebRTCDataChannelManager';
import { useWebRTCTrackManager } from './useWebRTCTrackManager';
import { useWebRTCConnectionCore } from './useWebRTCConnectionCore';
@@ -50,8 +50,27 @@ export interface WebRTCConnection {
* 整合所有模块,提供统一的接口
*/
export function useSharedWebRTCManager(): WebRTCConnection {
// 创建各个管理器实例
const stateManager = useWebRTCStateManager();
// 直接从 zustand store 创建状态管理器
const store = useWebRTCStore();
const stateManager: WebRTCStateManager = useMemo(() => ({
getState: () => ({
isConnected: store.isConnected,
isConnecting: store.isConnecting,
isWebSocketConnected: store.isWebSocketConnected,
isPeerConnected: store.isPeerConnected,
error: store.error,
canRetry: store.canRetry,
currentRoom: store.currentRoom,
}),
updateState: store.updateState,
setCurrentRoom: store.setCurrentRoom,
resetToInitial: store.resetToInitial,
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') =>
store.currentRoom?.code === roomCode &&
store.currentRoom?.role === role &&
store.isConnected,
}), [store]);
const dataChannelManager = useWebRTCDataChannelManager(stateManager);
const trackManager = useWebRTCTrackManager(stateManager);
const connectionCore = useWebRTCConnectionCore(
@@ -60,8 +79,15 @@ export function useSharedWebRTCManager(): WebRTCConnection {
trackManager
);
// 获取当前状态
const state = stateManager.getState();
// 从 store 获取当前状态
const state = {
isConnected: store.isConnected,
isConnecting: store.isConnecting,
isWebSocketConnected: store.isWebSocketConnected,
isPeerConnected: store.isPeerConnected,
error: store.error,
canRetry: store.canRetry,
};
// 创建 createOfferNow 方法
const createOfferNow = useCallback(async () => {

View File

@@ -2,7 +2,7 @@
import { useRef, useCallback } from 'react';
import { getWsUrl } from '@/lib/config';
import { getIceServersConfig } from '../settings/useIceServersConfig';
import { WebRTCStateManager } from './useWebRTCStateManager';
import { WebRTCStateManager } from '../ui/webRTCStore';
import { WebRTCDataChannelManager, WebRTCMessage } from './useWebRTCDataChannelManager';
import { WebRTCTrackManager } from './useWebRTCTrackManager';

View File

@@ -1,5 +1,5 @@
import { useRef, useCallback } from 'react';
import { WebRTCStateManager } from './useWebRTCStateManager';
import { WebRTCStateManager } from '../ui/webRTCStore';
// 消息类型
export interface WebRTCMessage {

View File

@@ -1,78 +0,0 @@
import { useCallback } from 'react';
import { useWebRTCStore } from '../ui/webRTCStore';
// 基础连接状态
export interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
canRetry: boolean;
}
/**
* WebRTC 状态管理器
* 负责连接状态的统一管理
*/
export interface WebRTCStateManager {
// 获取当前状态
getState: () => WebRTCState;
// 更新状态
updateState: (updates: Partial<WebRTCState>) => void;
// 设置当前房间
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
// 重置到初始状态
resetToInitial: () => void;
// 检查是否已连接到指定房间
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') => boolean;
}
/**
* WebRTC 状态管理 Hook
* 封装对 webRTCStore 的操作,提供状态更新和查询的统一接口
*/
export function useWebRTCStateManager(): WebRTCStateManager {
const webrtcStore = useWebRTCStore();
const getState = useCallback((): WebRTCState => {
return {
isConnected: webrtcStore.isConnected,
isConnecting: webrtcStore.isConnecting,
isWebSocketConnected: webrtcStore.isWebSocketConnected,
isPeerConnected: webrtcStore.isPeerConnected,
error: webrtcStore.error,
canRetry: webrtcStore.canRetry,
};
}, [webrtcStore]);
const updateState = useCallback((updates: Partial<WebRTCState>) => {
webrtcStore.updateState(updates);
}, [webrtcStore]);
const setCurrentRoom = useCallback((room: { code: string; role: 'sender' | 'receiver' } | null) => {
webrtcStore.setCurrentRoom(room);
}, [webrtcStore]);
const resetToInitial = useCallback(() => {
webrtcStore.resetToInitial();
}, [webrtcStore]);
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return webrtcStore.currentRoom?.code === roomCode &&
webrtcStore.currentRoom?.role === role &&
webrtcStore.isConnected;
}, [webrtcStore]);
return {
getState,
updateState,
setCurrentRoom,
resetToInitial,
isConnectedToRoom,
};
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef } from 'react';
import { WebRTCStateManager } from './useWebRTCStateManager';
import { WebRTCStateManager } from '../ui/webRTCStore';
/**
* WebRTC 媒体轨道管理器

View File

@@ -1,13 +1,5 @@
import { useRef, useCallback, useEffect } from 'react';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
import type { FileInfo } from '@/types';
interface UseFileListSyncProps {
sendFileList: (fileInfos: FileInfo[]) => void;

View File

@@ -1,13 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
import type { FileInfo } from '@/types';
interface UseFileStateManagerProps {
mode: 'send' | 'receive';

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from '../connection/useSharedWebRTCManager';
import type { FileInfo } from '@/types';
// 文件传输状态
interface FileTransferState {
@@ -21,16 +22,6 @@ interface FileReceiveProgress {
progress: number;
}
// 文件信息
interface FileInfo {
id: string;
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
}
// 文件元数据
interface FileMetadata {
id: string;

View File

@@ -1,20 +1,32 @@
import { create } from 'zustand';
interface WebRTCState {
export interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
canRetry: boolean; // 新增:是否可以重试
canRetry: boolean;
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
}
/**
* WebRTC 状态管理器接口
* 供 ConnectionCore / DataChannelManager / TrackManager 作为参数使用
*/
export interface WebRTCStateManager {
getState: () => WebRTCState;
updateState: (updates: Partial<WebRTCState>) => void;
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
resetToInitial: () => void;
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') => boolean;
}
interface WebRTCStore extends WebRTCState {
updateState: (updates: Partial<WebRTCState>) => void;
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
reset: () => void;
resetToInitial: () => void; // 新增:完全重置到初始状态
resetToInitial: () => void;
}
const initialState: WebRTCState = {
@@ -23,7 +35,7 @@ const initialState: WebRTCState = {
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false, // 初始状态下不需要重试
canRetry: false,
currentRoom: null,
};
@@ -42,5 +54,5 @@ export const useWebRTCStore = create<WebRTCStore>((set) => ({
reset: () => set(initialState),
resetToInitial: () => set(initialState), // 完全重置到初始状态
resetToInitial: () => set(initialState),
}));

View File

@@ -1,144 +0,0 @@
/**
* API 调用工具函数
* 统一处理开发模式和静态模式下的API调用
*/
import { config, getApiUrl, getDirectBackendUrl, getWsUrl } from './config';
/**
* 统一的 fetch 函数自动处理不同环境下的API调用
*/
export async function apiFetch(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
let url: string;
// 检查是否在客户端
const isClient = typeof window !== 'undefined';
// 检查是否为静态导出模式 - 修复开发模式判断
const isStaticExport =
process.env.NEXT_EXPORT === 'true' ||
(process.env.NODE_ENV === 'production' && isClient && !window.location.origin.includes('localhost:3000'));
if (isClient) {
if (isStaticExport) {
// 静态模式:直接调用后端
url = getDirectBackendUrl(endpoint);
} else {
// 开发模式:通过 Next.js API 路由
url = getApiUrl(endpoint);
}
} else {
// 服务器端:直接调用后端
url = getDirectBackendUrl(endpoint);
}
console.log(`[API] 模式检查: isClient=${isClient}, isStatic=${isStaticExport}`);
console.log(`[API] 调用: ${endpoint} -> ${url}`);
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
}
/**
* GET 请求
*/
export async function apiGet(endpoint: string, options: RequestInit = {}): Promise<Response> {
return apiFetch(endpoint, {
...options,
method: 'GET',
});
}
/**
* POST 请求
*/
export async function apiPost(
endpoint: string,
data?: any,
options: RequestInit = {}
): Promise<Response> {
return apiFetch(endpoint, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
/**
* PUT 请求
*/
export async function apiPut(
endpoint: string,
data?: any,
options: RequestInit = {}
): Promise<Response> {
return apiFetch(endpoint, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
/**
* DELETE 请求
*/
export async function apiDelete(endpoint: string, options: RequestInit = {}): Promise<Response> {
return apiFetch(endpoint, {
...options,
method: 'DELETE',
});
}
/**
* 文件上传请求
*/
export async function apiUpload(
endpoint: string,
formData: FormData,
options: RequestInit = {}
): Promise<Response> {
// 文件上传不设置 Content-Type让浏览器自动设置
const { headers, ...restOptions } = options;
return apiFetch(endpoint, {
...restOptions,
method: 'POST',
body: formData,
headers: headers, // 不包含 Content-Type
});
}
/**
* 获取 WebSocket URL
*/
export function getWebSocketUrl(): string {
return getWsUrl(); // 使用实时获取的 WebSocket URL
}
/**
* 显示当前API配置信息调试用
*/
export function debugApiConfig() {
const isClient = typeof window !== 'undefined';
const isStaticExport =
process.env.NEXT_EXPORT === 'true' ||
process.env.NODE_ENV === 'production' && !process.env.NEXT_PUBLIC_API_BASE_URL?.includes('localhost:3000') ||
(isClient && !window.location.pathname.startsWith('/api/'));
console.log('[API Debug] 配置信息:', {
isClient,
isStaticExport,
config: config.api,
environment: process.env.NODE_ENV,
nextExport: process.env.NEXT_EXPORT,
currentUrl: isClient ? window.location.href : 'server-side',
});
}

View File

@@ -1,119 +0,0 @@
/**
* 客户端 API 工具类
* 用于在静态导出模式下直接与 Go 后端通信
*/
import { config } from './config';
interface ApiResponse {
success: boolean;
message?: string;
data?: unknown;
}
export class ClientAPI {
private baseUrl: string;
constructor() {
// 根据环境选择合适的API地址
if (config.isStatic) {
// 静态模式:直接连接 Go 后端
this.baseUrl = config.api.directBackendUrl;
} else {
// 开发模式:通过 Next.js API 路由
this.baseUrl = config.api.baseUrl;
}
}
/**
* 发送 POST 请求
*/
async post(endpoint: string, data: unknown): Promise<ApiResponse> {
const url = this.baseUrl.replace(/\/$/, '') + (endpoint.startsWith('/') ? endpoint : `/${endpoint}`);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json() as ApiResponse;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
/**
* 发送 GET 请求
*/
async get(endpoint: string): Promise<ApiResponse> {
const url = this.baseUrl.replace(/\/$/, '') + (endpoint.startsWith('/') ? endpoint : `/${endpoint}`);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json() as ApiResponse;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
/**
* 创建房间(简化版本)- 后端会忽略传入的参数
*/
async createRoom(): Promise<ApiResponse> {
return this.post('/api/create-room', {});
}
/**
* 获取文本内容
*/
async getTextContent(code: string): Promise<ApiResponse> {
return this.get(`/api/get-text-content?code=${code}`);
}
/**
* 更新文本内容
*/
async updateTextContent(code: string, content: string): Promise<ApiResponse> {
return this.post('/api/update-text-content', {
code: code,
content: content
});
}
/**
* 获取房间信息
*/
async getRoomInfo(code: string): Promise<ApiResponse> {
return this.get(`/api/room-info?code=${code}`);
}
/**
* 获取WebRTC房间状态
*/
async getWebRTCRoomStatus(code: string): Promise<ApiResponse> {
return this.get(`/api/webrtc-room-status?code=${code}`);
}
}
// 导出单例实例
export const clientAPI = new ClientAPI();

View File

@@ -0,0 +1,94 @@
/**
* 房间验证工具函数
* 统一房间代码验证和房间状态检查逻辑
*/
export interface RoomValidationResult {
success: boolean;
error?: string;
data?: Record<string, unknown>;
}
/**
* 验证房间代码格式
*/
export function validateRoomCode(code: string): string | null {
const trimmedCode = code.trim();
if (!trimmedCode || trimmedCode.length !== 6) {
return '请输入正确的6位取件码';
}
return null;
}
/**
* 检查房间状态(调用 /api/room-info
* 统一处理房间不存在、过期、已满、发送方不在线等情况
*/
export async function checkRoomStatus(code: string): Promise<RoomValidationResult> {
try {
const response = await fetch(`/api/room-info?code=${code}`);
if (!response.ok) {
return {
success: false,
error: `HTTP ${response.status}: 无法检查房间状态`,
};
}
const result = await response.json();
if (!result.success) {
let errorMessage = result.message || '房间不存在或已过期';
if (result.message?.includes('房间人数已满') || result.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (result.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (result.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
return { success: false, error: errorMessage };
}
// 检查房间是否已满
if (result.is_room_full) {
return {
success: false,
error: '当前房间人数已满,正在传输中无法加入,请稍后再试',
};
}
// 检查发送方是否在线
if (!result.sender_online) {
return {
success: false,
error: '发送方不在线,请确认取件码是否正确或联系发送方',
};
}
return { success: true, data: result };
} catch (error) {
const message =
error instanceof Error
? handleNetworkError(error)
: '检查房间状态失败';
return { success: false, error: message };
}
}
/**
* 网络错误统一处理
*/
export function handleNetworkError(error: Error): string {
if (error.message.includes('network') || error.message.includes('fetch')) {
return '网络连接失败,请检查网络状况';
} else if (error.message.includes('timeout')) {
return '请求超时,请重试';
} else if (error.message.includes('HTTP 404')) {
return '房间不存在,请检查取件码';
} else if (error.message.includes('HTTP 500')) {
return '服务器错误,请稍后重试';
} else if (error.message.includes('房间人数已满') || error.message.includes('正在传输中无法加入')) {
return '当前房间人数已满,正在传输中无法加入,请稍后再试';
}
return error.message;
}

View File

@@ -4,6 +4,8 @@ export interface FileInfo {
name: string;
size: number;
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
lastModified?: number;
}
@@ -30,22 +32,3 @@ export interface RoomStatus {
}[];
created_at: string;
}
export interface FileChunk {
offset: number;
data: Uint8Array;
}
export interface WebSocketMessage {
type: string;
payload: Record<string, unknown>;
}
// WebSocket 钩子状态
export interface UseWebSocketReturn {
websocket: WebSocket | null;
isConnected: boolean;
connect: (code: string, role: 'sender' | 'receiver') => void;
disconnect: () => void;
sendMessage: (message: WebSocketMessage) => void;
}