mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-03-15 04:50:58 +08:00
新增房间验证工具函数,包含房间代码格式验证和房间状态检查逻辑
This commit is contained in:
737
ARCHITECTURE.md
Normal file
737
ARCHITECTURE.md
Normal 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
|
||||
# 终端 1:Go 后端
|
||||
go run cmd/main.go # :8080
|
||||
|
||||
# 终端 2:Next.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
471
FRONTEND_ANALYSIS.md
Normal 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` 同时导出 hook(React 组件用)和独立函数 `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 | 连接状态 UI(3 种模式: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 行,使用 prop),426 行总计 |
|
||||
| **两套 API 封装并存** | 🟡 中 | `api-utils.ts`(函数式)+ `client-api.ts`(class-based),263 行,但组件大多直接用 `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 层
|
||||
- useEffect:75 个 → ~50 个
|
||||
- console.log:428 处 → 0(替换为 logger)
|
||||
- FileInfo 定义:7 处 → 1 处
|
||||
- 房间验证实现:4 处 → 1 处
|
||||
1237
PROTOCOL_ANALYSIS.md
Normal file
1237
PROTOCOL_ANALYSIS.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// 连接相关的hooks
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
export { useRoomConnection } from './useRoomConnection';
|
||||
export { useSharedWebRTCManager } from './useSharedWebRTCManager';
|
||||
export { useWebRTCSupport } from './useWebRTCSupport';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { WebRTCStateManager } from '../ui/webRTCStore';
|
||||
|
||||
// 消息类型
|
||||
export interface WebRTCMessage {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { WebRTCStateManager } from '../ui/webRTCStore';
|
||||
|
||||
/**
|
||||
* WebRTC 媒体轨道管理器
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
94
chuan-next/src/lib/room-utils.ts
Normal file
94
chuan-next/src/lib/room-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user