mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-15 01:34:46 +08:00
Compare commits
6 Commits
feature/do
...
v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5d74433b | ||
|
|
8e4c42bbbe | ||
|
|
6d5b4329db | ||
|
|
dfa225e68e | ||
|
|
4faf1c3141 | ||
|
|
86fd9ec08c |
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: 🐛 Bug 报告
|
||||
description: 报告一个错误或问题
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "需要调查"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写错误报告!请详细描述遇到的问题,这将帮助我们更快地定位和解决问题。
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请详细描述您遇到的问题
|
||||
placeholder: |
|
||||
例如:在文件传输过程中,当传输大文件时连接会意外断开...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment-environment
|
||||
attributes:
|
||||
label: 部署环境
|
||||
description: 您使用的是什么部署方式?
|
||||
options:
|
||||
- 二进制部署(下载发布的可执行文件)
|
||||
- 自行构建(从源码编译)
|
||||
- Docker 部署
|
||||
- 官方演示站
|
||||
- 其他(请在详细信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment-details
|
||||
attributes:
|
||||
label: 环境详细信息
|
||||
description: 请提供环境相关信息
|
||||
placeholder: |
|
||||
- 操作系统:Linux Ubuntu 20.04 / Windows 10 / macOS 12.x
|
||||
- 浏览器:Chrome 120.x / Firefox 121.x / Safari 17.x
|
||||
- 网络环境:局域网 / 公网 / NAT环境
|
||||
- 设备类型:PC / 移动设备
|
||||
- 其他相关信息...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 请描述如何复现这个问题
|
||||
placeholder: |
|
||||
1. 打开应用
|
||||
2. 点击 '文件传输'
|
||||
3. 选择一个大文件 (>100MB)
|
||||
4. 点击发送
|
||||
5. 观察到连接断开...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志
|
||||
description: |
|
||||
请提供相关的错误日志、控制台输出或服务器日志
|
||||
提示:您可以在浏览器开发者工具的控制台中查看客户端日志
|
||||
render: text
|
||||
placeholder: |
|
||||
[2024-01-15 10:30:45] [ERROR] WebRTC连接失败: ICE连接超时
|
||||
[2024-01-15 10:30:45] [INFO] 尝试重新连接...
|
||||
|
||||
或者粘贴浏览器控制台的错误信息...
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: |
|
||||
如果适用,请添加截图或录屏来帮助解释您的问题
|
||||
您可以直接拖拽图片到这个文本框中
|
||||
placeholder: 拖拽图片文件到这里,或者粘贴图片链接
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 其他信息
|
||||
description: 任何其他可能有助于解决问题的信息
|
||||
placeholder: |
|
||||
- 问题发生的频率:每次 / 偶尔 / 特定条件下
|
||||
- 是否在多个设备上都出现
|
||||
- 最近是否有环境变化
|
||||
- 其他可能相关的信息...
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📚 项目文档
|
||||
url: https://github.com/MatrixSeven/file-transfer-go/blob/main/README.md
|
||||
about: 查看项目使用文档和部署指南
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/MatrixSeven/file-transfer-go/discussions
|
||||
about: 参与社区讨论,分享使用经验和想法
|
||||
- name: 🌐 官方演示
|
||||
url: https://transfer.52python.cn/
|
||||
about: 访问官方演示站点体验功能(如果可用)
|
||||
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: ✨ 功能请求
|
||||
description: 建议一个新功能或改进
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "功能请求"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您提出功能建议!您的想法对改进项目非常重要。请详细描述您的建议。
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 建议功能
|
||||
description: 请清楚简洁地描述您希望实现的功能
|
||||
placeholder: |
|
||||
例如:希望添加文件加密传输功能,在传输过程中对文件进行端到端加密...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: 需求原因
|
||||
description: 请解释为什么需要这个功能,它解决了什么问题?
|
||||
placeholder: |
|
||||
例如:
|
||||
- 当前在传输敏感文件时缺乏安全保障
|
||||
- 在公网环境下传输文件可能被第三方截获
|
||||
- 企业用户需要确保数据传输的安全性
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-cases
|
||||
attributes:
|
||||
label: 使用场景
|
||||
description: 请描述这个功能的具体使用场景和出发点
|
||||
placeholder: |
|
||||
例如:
|
||||
- 医疗机构传输患者档案时需要加密保护
|
||||
- 企业内部传输财务报表等敏感文档
|
||||
- 个人用户传输私人照片和视频时希望保护隐私
|
||||
- 在不受信任的网络环境下进行文件传输
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
description: 您认为这个功能的优先级如何?
|
||||
options:
|
||||
- 低(Nice to have)
|
||||
- 中(重要但不紧急)
|
||||
- 高(对用户体验很重要)
|
||||
- 关键(阻碍正常使用)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 其他信息
|
||||
description: 任何其他可能有助于理解和实现这个功能的信息
|
||||
placeholder: |
|
||||
- 类似功能的参考应用或网站
|
||||
- 相关技术文档或标准
|
||||
- 社区讨论链接
|
||||
- 其他补充说明
|
||||
|
||||
54
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
54
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: 💬 问题咨询
|
||||
description: 使用问题、配置疑问或一般性讨论
|
||||
title: "[Question] "
|
||||
labels: ["question", "需要回复"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
如果您有使用问题、配置疑问或想要讨论项目相关话题,请使用这个模板。
|
||||
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
description: 请选择您的问题类型
|
||||
options:
|
||||
- 使用问题(如何使用某个功能)
|
||||
- 配置问题(部署和设置相关)
|
||||
- 技术咨询(技术实现相关)
|
||||
- 功能理解(不确定某个功能如何工作)
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: 具体问题
|
||||
description: 请详细描述您的问题
|
||||
placeholder: |
|
||||
例如:我想在内网环境下部署这个应用,但是不知道如何配置STUN服务器...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 如果相关,请提供环境信息
|
||||
placeholder: |
|
||||
- 操作系统:
|
||||
- 部署方式:
|
||||
- 网络环境:
|
||||
- 浏览器版本:
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: 确认事项
|
||||
options:
|
||||
- label: 我已经查看了项目文档和README
|
||||
required: true
|
||||
- label: 我已经搜索了现有的Issues
|
||||
required: true
|
||||
11
README.md
11
README.md
@@ -3,7 +3,7 @@
|
||||
|
||||
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
|
||||
|
||||
## [在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
|
||||
## [在线体验](https://transfer.52python.cn) • [关注我](https://x.com/_MatrixSeven) • [帮助文档](https://transfer.52python.cn/help)
|
||||
|
||||

|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
|
||||
## 🔄 最近更新日志
|
||||
|
||||
### 2025-09-5
|
||||
- ✅ **WEBRTC链接恢复** - 关闭页面后在打开,进行数据链接恢复
|
||||
- ✅ **定义TURN配置** - 支持自定义中继TURN配置
|
||||
- ✅ **优化移动端提示** - 优化各种场景的错误提示
|
||||
- ✅ **帮助文档** - 常见问题说明文档更新
|
||||
|
||||
### 2025-09-1
|
||||
- ✅ **移动端桌面全屏** - 优化移动端下UI,并解决全屏问题
|
||||
|
||||
### 2025-08-28
|
||||
- ✅ **完善Docker部署支持** - 优化Docker配置,支持一键部署和多环境配置
|
||||
- ✅ **优化README文档** - 更新项目说明,完善部署指南和技术栈信息
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# 简化版 SSG 构建脚本
|
||||
# 专注于 API 路由的处理和静态导出
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 配置
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
API_DIR="$PROJECT_ROOT/src/app/api"
|
||||
TEMP_API_DIR="/tmp/nextjs-api-$(date +%s)"
|
||||
|
||||
echo -e "${GREEN}🚀 开始 SSG 静态导出构建...${NC}"
|
||||
|
||||
# 错误处理函数
|
||||
cleanup_on_error() {
|
||||
echo -e "${RED}❌ 构建失败,正在恢复文件...${NC}"
|
||||
if [ -d "$TEMP_API_DIR" ]; then
|
||||
if [ -d "$TEMP_API_DIR/api" ]; then
|
||||
mv "$TEMP_API_DIR/api" "$API_DIR" 2>/dev/null || true
|
||||
echo -e "${YELLOW}📂 已恢复 API 目录${NC}"
|
||||
fi
|
||||
rm -rf "$TEMP_API_DIR"
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置错误处理
|
||||
trap cleanup_on_error ERR INT TERM
|
||||
|
||||
# 步骤 1: 备份 API 路由
|
||||
if [ -d "$API_DIR" ]; then
|
||||
echo -e "${YELLOW}📦 备份 API 路由...${NC}"
|
||||
mkdir -p "$TEMP_API_DIR"
|
||||
mv "$API_DIR" "$TEMP_API_DIR/"
|
||||
echo "✅ API 路由已备份到临时目录"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ API 目录不存在,跳过备份${NC}"
|
||||
fi
|
||||
|
||||
# 步骤 2: 清理构建文件
|
||||
echo -e "${YELLOW}🧹 清理之前的构建...${NC}"
|
||||
rm -rf .next out
|
||||
|
||||
# 步骤 3: 执行静态构建
|
||||
echo -e "${YELLOW}🔨 执行静态导出构建...${NC}"
|
||||
NEXT_EXPORT=true yarn build
|
||||
|
||||
# 步骤 4: 验证构建结果
|
||||
if [ -d "out" ] && [ -f "out/index.html" ]; then
|
||||
echo -e "${GREEN}✅ 静态导出构建成功!${NC}"
|
||||
|
||||
# 显示构建统计
|
||||
file_count=$(find out -type f | wc -l)
|
||||
dir_size=$(du -sh out | cut -f1)
|
||||
echo "📊 构建统计:"
|
||||
echo " - 文件数量: $file_count"
|
||||
echo " - 总大小: $dir_size"
|
||||
else
|
||||
echo -e "${RED}❌ 构建验证失败${NC}"
|
||||
cleanup_on_error
|
||||
fi
|
||||
|
||||
# 步骤 5: 恢复 API 路由
|
||||
if [ -d "$TEMP_API_DIR/api" ]; then
|
||||
echo -e "${YELLOW}🔄 恢复 API 路由...${NC}"
|
||||
mv "$TEMP_API_DIR/api" "$API_DIR"
|
||||
echo "✅ API 路由已恢复"
|
||||
fi
|
||||
|
||||
# 步骤 6: 清理临时文件
|
||||
rm -rf "$TEMP_API_DIR"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 SSG 构建完成!${NC}"
|
||||
echo -e "${GREEN}📁 静态文件位于: ./out/${NC}"
|
||||
echo -e "${GREEN}🚀 部署命令: npx serve out${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 提示: 静态版本会直接连接到 Go 后端 (localhost:8080)${NC}"
|
||||
@@ -1,25 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Upload, MessageSquare, Monitor, Users } from 'lucide-react';
|
||||
import { Upload, MessageSquare, Monitor, Users, Settings } from 'lucide-react';
|
||||
import Hero from '@/components/Hero';
|
||||
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
|
||||
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
|
||||
import DesktopShare from '@/components/DesktopShare';
|
||||
import WeChatGroup from '@/components/WeChatGroup';
|
||||
import WebRTCSettings from '@/components/WebRTCSettings';
|
||||
import { WebRTCUnsupportedModal } from '@/components/WebRTCUnsupportedModal';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useWebRTCSupport } from '@/hooks/connection';
|
||||
import { useTabNavigation, TabType } from '@/hooks/ui';
|
||||
import { useWebRTCConfigSync } from '@/hooks/settings';
|
||||
|
||||
export default function HomePage() {
|
||||
// WebRTC配置同步
|
||||
useWebRTCConfigSync();
|
||||
|
||||
// 使用tab导航hook
|
||||
const {
|
||||
activeTab,
|
||||
handleTabChange,
|
||||
getConnectionInfo,
|
||||
hasInitialized,
|
||||
confirmDialogState,
|
||||
closeConfirmDialog
|
||||
} = useTabNavigation();
|
||||
@@ -34,18 +37,6 @@ export default function HomePage() {
|
||||
showUnsupportedModalManually,
|
||||
} = useWebRTCSupport();
|
||||
|
||||
// 桌面共享功能的占位符函数(保持向后兼容)
|
||||
const handleStartSharing = async () => {
|
||||
console.log('开始桌面共享');
|
||||
};
|
||||
|
||||
const handleStopSharing = async () => {
|
||||
console.log('停止桌面共享');
|
||||
};
|
||||
|
||||
const handleJoinSharing = async (code: string) => {
|
||||
console.log('加入桌面共享:', code);
|
||||
};
|
||||
|
||||
// 处理Tabs组件的字符串参数
|
||||
const handleTabChangeWrapper = (value: string) => {
|
||||
@@ -98,7 +89,7 @@ export default function HomePage() {
|
||||
<Tabs value={activeTab} onValueChange={handleTabChangeWrapper} className="w-full">
|
||||
{/* Tabs Navigation - 横向布局 */}
|
||||
<div className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-4 max-w-2xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsList className="grid w-full grid-cols-5 max-w-3xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsTrigger
|
||||
value="webrtc"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
@@ -136,7 +127,15 @@ export default function HomePage() {
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">微信群</span>
|
||||
<span className="sm:hidden">微信</span>
|
||||
</TabsTrigger>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-orange-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-orange-600"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">中继设置</span>
|
||||
<span className="sm:hidden">设置</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* WebRTC 不支持时的提示 */}
|
||||
@@ -164,6 +163,10 @@ export default function HomePage() {
|
||||
<TabsContent value="wechat" className="mt-0 animate-fade-in-up">
|
||||
<WeChatGroup />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCSettings />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
761
chuan-next/src/app/help/HelpPage.tsx
Normal file
761
chuan-next/src/app/help/HelpPage.tsx
Normal file
@@ -0,0 +1,761 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Book,
|
||||
Server,
|
||||
Download,
|
||||
Code,
|
||||
Container,
|
||||
Globe,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Lightbulb,
|
||||
HelpCircle,
|
||||
Upload,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
Settings,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Wifi,
|
||||
Users,
|
||||
Home,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ id, title, icon, children }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className="mb-6 scroll-mt-16 lg:scroll-mt-20">
|
||||
<div className="flex items-center gap-3 mb-4 lg:mb-6">
|
||||
<div className="p-2 lg:p-3 bg-blue-100 rounded-lg">
|
||||
{icon}
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-gray-900">{title}</h2>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language = "bash" }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-green-400 text-sm font-mono">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoBoxProps {
|
||||
type: 'info' | 'warning' | 'tip' | 'error';
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function InfoBox({ type, title, children }: InfoBoxProps) {
|
||||
const styles = {
|
||||
info: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
icon: <Info className="w-5 h-5 text-blue-600" />,
|
||||
titleColor: 'text-blue-900'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
icon: <AlertTriangle className="w-5 h-5 text-yellow-600" />,
|
||||
titleColor: 'text-yellow-900'
|
||||
},
|
||||
tip: {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
icon: <Lightbulb className="w-5 h-5 text-green-600" />,
|
||||
titleColor: 'text-green-900'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
icon: <AlertTriangle className="w-5 h-5 text-red-600" />,
|
||||
titleColor: 'text-red-900'
|
||||
}
|
||||
};
|
||||
|
||||
const style = styles[type];
|
||||
|
||||
return (
|
||||
<div className={`${style.bg} ${style.border} border rounded-lg p-4 my-4`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{style.icon}
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-semibold ${style.titleColor} mb-2`}>{title}</h4>
|
||||
<div className="text-sm text-gray-700">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
const [activeSection, setActiveSection] = useState('deployment');
|
||||
const [sidebarLeft, setSidebarLeft] = useState(0);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'deployment',
|
||||
title: '部署指南',
|
||||
icon: <Server className="w-5 h-5 text-blue-600" />,
|
||||
children: [
|
||||
{ id: 'docker-deployment', title: 'Docker 部署', icon: <Container className="w-4 h-4 text-blue-500" /> },
|
||||
{ id: 'binary-deployment', title: '二进制部署', icon: <Download className="w-4 h-4 text-green-500" /> },
|
||||
{ id: 'build-deployment', title: '自行构建', icon: <Code className="w-4 h-4 text-purple-500" /> },
|
||||
]
|
||||
},
|
||||
{ id: 'desktop-share', title: '桌面共享权限问题', icon: <Monitor className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'port-config', title: '自定义端口配置', icon: <Settings className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'security', title: '全局域网部署', icon: <Shield className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'data-transfer', title: '数据传输机制', icon: <Wifi className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'contact', title: '交流反馈群', icon: <Users className="w-5 h-5 text-blue-600" /> },
|
||||
];
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
setActiveSection(sectionId);
|
||||
// 更新 URL hash
|
||||
window.history.pushState(null, '', `#${sectionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化时检查 URL hash 并滚动到对应位置
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash) {
|
||||
// 延迟一下确保 DOM 已经渲染完成
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
setActiveSection(hash);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听滚动事件来更新活跃的章节和 URL hash
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollPosition = window.scrollY + 100;
|
||||
|
||||
// 检查所有可能的section ID(包括子目录)
|
||||
const allSectionIds = sections.reduce<string[]>((acc, section) => {
|
||||
acc.push(section.id);
|
||||
if (section.children) {
|
||||
acc.push(...section.children.map(child => child.id));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
for (const sectionId of allSectionIds) {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const { offsetTop, offsetHeight } = element;
|
||||
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
|
||||
setActiveSection(sectionId);
|
||||
// 更新 URL hash,但不触发页面滚动
|
||||
if (window.location.hash !== `#${sectionId}`) {
|
||||
window.history.replaceState(null, '', `#${sectionId}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [sections]);
|
||||
|
||||
// 计算侧边栏的位置
|
||||
useEffect(() => {
|
||||
const updateSidebarPosition = () => {
|
||||
const container = document.querySelector('.w-\\[95\\%\\]');
|
||||
if (container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerLeft = containerRect.left;
|
||||
// 计算第一列的位置(24px padding + grid gap)
|
||||
setSidebarLeft(containerLeft + 24);
|
||||
}
|
||||
};
|
||||
|
||||
updateSidebarPosition();
|
||||
window.addEventListener('resize', updateSidebarPosition);
|
||||
return () => window.removeEventListener('resize', updateSidebarPosition);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-[95%] lg:w-[70%] max-w-none mx-auto p-4 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 lg:mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="p-3 bg-blue-100 rounded-xl">
|
||||
<Book className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">使用帮助</h1>
|
||||
</div>
|
||||
<p className="text-base lg:text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
详细的部署指南和使用说明,帮助您快速上手文件传输工具
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* 返回首页按钮 - 桌面端固定定位 */}
|
||||
<div className="hidden lg:block">
|
||||
<div
|
||||
className="fixed bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-20"
|
||||
style={{ left: `${sidebarLeft}px`, top: '2rem', width: '256px' }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors hover:bg-blue-50 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">返回首页</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 返回首页按钮 - 移动端固定定位 */}
|
||||
<div className="lg:hidden">
|
||||
<div className="fixed left-4 top-4 z-20">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg shadow-lg border border-gray-200 text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">返回首页</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏目录 - 桌面端固定定位 */}
|
||||
<div className="hidden lg:block">
|
||||
<div
|
||||
className="fixed w-64 bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-h-[calc(100vh-10rem)] overflow-y-auto z-10"
|
||||
style={{ left: `${sidebarLeft}px`, top: '7rem' }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">目录</h3>
|
||||
<nav className="space-y-2">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{section.icon}
|
||||
<span className="text-sm font-medium">{section.title}</span>
|
||||
<ChevronRight className="w-4 h-4 ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* 子目录 */}
|
||||
{section.children && (
|
||||
<div className="ml-8 mt-1 space-y-1">
|
||||
{section.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => scrollToSection(child.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
|
||||
activeSection === child.id
|
||||
? 'bg-blue-100 text-blue-600 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="text-xs text-gray-700">{child.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端目录 - 粘性定位 */}
|
||||
<div className="lg:hidden mb-6 mt-16">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-3">目录</h3>
|
||||
<nav className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{section.icon}
|
||||
<span className="text-xs font-medium">{section.title}</span>
|
||||
<ChevronRight className="w-3 h-3 ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* 子目录 */}
|
||||
{section.children && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{section.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => scrollToSection(child.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
|
||||
activeSection === child.id
|
||||
? 'bg-blue-100 text-blue-600 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="text-xs text-gray-700">{child.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容 */}
|
||||
<div className="lg:ml-72 lg:mr-4">
|
||||
{/* 部署指南 */}
|
||||
<Section id="deployment" title="部署指南" icon={<Server className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<p className="text-gray-700 mb-6">
|
||||
文件传输工具支持多种部署方式,您可以根据自己的需求选择最适合的部署方案。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Docker 部署 */}
|
||||
<div className="scroll-mt-20" id="docker-deployment">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Container className="w-6 h-6 text-blue-600" />
|
||||
Docker 部署
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">方法一:使用 Docker Compose(推荐)</h4>
|
||||
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
docker-compose up -d`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">方法二:直接使用 Docker 镜像</h4>
|
||||
<CodeBlock code={`docker run -d -p 8080:8080 --name file-transfer-go matrixseven/file-transfer-go:latest`} />
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="部署提示">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Docker Compose 方式会自动处理依赖和网络配置</li>
|
||||
<li>服务启动后访问 <code className="bg-gray-100 px-2 py-1 rounded">http://localhost:8080</code></li>
|
||||
<li>可以通过修改 <code className="bg-gray-100 px-2 py-1 rounded">docker-compose.yml</code> 自定义端口</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二进制部署 */}
|
||||
<div className="scroll-mt-20" id="binary-deployment">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Download className="w-6 h-6 text-green-600" />
|
||||
二进制部署
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">下载预编译版本</h4>
|
||||
<p className="text-gray-700 mb-3">
|
||||
前往 <a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go/releases/"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub Releases 页面 <ExternalLink className="w-4 h-4" />
|
||||
</a> 下载对应系统的二进制包
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="font-semibold mb-2">支持的平台:</h5>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li>Linux (AMD64/ARM64)</li>
|
||||
<li>Windows (AMD64)</li>
|
||||
<li>macOS (AMD64/ARM64)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">启动服务</h4>
|
||||
<p className="text-gray-700 mb-3">下载后直接运行可执行文件即可:</p>
|
||||
<CodeBlock code={`# Linux/macOS
|
||||
chmod +x file-transfer-server-linux-amd64
|
||||
./file-transfer-server-linux-amd64
|
||||
|
||||
# Windows
|
||||
file-transfer-server-windows-amd64.exe`} />
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="注意事项">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>首次运行可能需要防火墙授权</li>
|
||||
<li>默认端口为 8080,可通过参数修改</li>
|
||||
<li>建议在生产环境使用 systemd 等进程管理工具</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自行构建 */}
|
||||
<div className="scroll-mt-20" id="build-deployment">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Code className="w-6 h-6 text-purple-600" />
|
||||
自行构建
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">环境要求</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li>Go 1.21 或更高版本</li>
|
||||
<li>Node.js 18 或更高版本</li>
|
||||
<li>Git</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">构建步骤</h4>
|
||||
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
./build-fullstack.sh
|
||||
./dist/file-transfer-go`} />
|
||||
</div>
|
||||
|
||||
<InfoBox type="warning" title="构建注意事项">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>确保网络畅通,需要下载 Go 模块和 npm 包</li>
|
||||
<li>首次构建可能需要较长时间</li>
|
||||
<li>构建脚本会自动处理前后端的编译和打包</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 桌面共享权限 */}
|
||||
<Section id="desktop-share" title="桌面共享权限问题" icon={<Monitor className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
移动端无法共享桌面?
|
||||
</h3>
|
||||
<InfoBox type="error" title="移动端限制">
|
||||
<p>这是移动端浏览器的限制,WebRTC 没有在移动浏览器端实现获取桌面视频流的功能,所以这个能力无法在移动浏览器端实现。</p>
|
||||
<p className="mt-2 font-semibold">解决方案:请使用桌面设备进行屏幕共享。</p>
|
||||
</InfoBox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
PC 端无法共享桌面?
|
||||
</h3>
|
||||
|
||||
<InfoBox type="warning" title="HTTPS 要求">
|
||||
<p>如果是自行部署,无论是部署在局域网/公网,如果要实现桌面分享,需要必须保证服务访问地址是 TLS 加密,也就是 <code className="bg-gray-100 px-2 py-1 rounded">https</code> 方式访问。</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li><code className="bg-gray-100 px-2 py-1 rounded">localhost</code> 地址可以直接分享桌面</li>
|
||||
<li>其他地址需要配置反向代理(如 nginx)启用 HTTPS</li>
|
||||
<li>这是浏览器的安全限制,直接 IP 无法分享桌面</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
<InfoBox type="tip" title="临时解决方案">
|
||||
<p>如果一定要用 IP+端口 的方式进行桌面分享,可以在浏览器设置中:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 mt-2">
|
||||
<li>打开浏览器设置</li>
|
||||
<li>搜索 WebRTC 相关设置</li>
|
||||
<li>开启 <code className="bg-gray-100 px-2 py-1 rounded">Anonymize local IPs exposed by WebRTC</code></li>
|
||||
<li>设置为 <code className="bg-gray-100 px-2 py-1 rounded">Enabled</code> 状态</li>
|
||||
</ol>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 端口配置 */}
|
||||
<Section id="port-config" title="端口配置" icon={<Settings className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">修改服务端口</h3>
|
||||
<p className="text-gray-700 mb-3">以 Linux 为例,将服务绑定到 18080 端口:</p>
|
||||
<CodeBlock code="./file-transfer-server-linux-amd64 -port 18080" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Docker 端口映射</h3>
|
||||
<p className="text-gray-700 mb-3">使用 Docker 时修改端口映射:</p>
|
||||
<CodeBlock code="docker run -d -p 18080:8080 matrixseven/file-transfer-go:latest" />
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="端口选择建议">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>避免使用系统保留端口(1-1024)</li>
|
||||
<li>确保选择的端口未被其他服务占用</li>
|
||||
<li>防火墙需要开放对应端口</li>
|
||||
<li>建议使用 8080, 3000, 8000 等常用端口</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 安全内网部署 */}
|
||||
<Section id="security" title="安全内网部署" icon={<Shield className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<InfoBox type="warning" title="实验性功能">
|
||||
<p>以下方案理论可行,但未经充分验证,请在测试环境中验证后再用于生产。</p>
|
||||
</InfoBox>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">内网部署方案</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold mb-2">1. 部署内网 DNS 服务</h4>
|
||||
<p className="text-gray-700">配置内网域名解析,避免直接使用 IP 地址访问</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold mb-2">2. 配置 STUN/TURN 服务</h4>
|
||||
<p className="text-gray-700">部署内网 STUN/TURN 服务器,处理 NAT 穿透</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold mb-2">3. 更新服务配置</h4>
|
||||
<p className="text-gray-700">在应用设置中配置自定义 STUN/TURN 服务器地址</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">STUN/TURN 服务器推荐</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li><strong>Coturn</strong>:开源 TURN/STUN 服务器</li>
|
||||
<li><strong>Janus</strong>:WebRTC 网关,包含 STUN/TURN 功能</li>
|
||||
<li><strong>自建方案</strong>:基于 Docker 快速部署</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="配置提示">
|
||||
<p>在应用的 设置 页面中,可以添加自定义 ICE 服务器:</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>STUN 服务器格式:<code className="bg-gray-100 px-2 py-1 rounded">stun:your-server.local:3478</code></li>
|
||||
<li>TURN 服务器格式:<code className="bg-gray-100 px-2 py-1 rounded">turn:your-server.local:3478</code></li>
|
||||
<li>TURN 服务器需要用户名和密码认证</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 数据传输说明 */}
|
||||
<Section id="data-transfer" title="数据传输机制" icon={<Wifi className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">传输方式</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 text-green-600">✓ 点对点传输</h4>
|
||||
<p className="text-sm text-gray-600">通过 WebRTC 建立直接连接,数据不经过服务器</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 text-blue-600">✓ 中继传输</h4>
|
||||
<p className="text-sm text-gray-600">当直连失败时,通过 TURN 服务器中继数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">传输流程</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">1</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">建立信令连接</h4>
|
||||
<p className="text-gray-600">通过 WebSocket 服务器交换连接信息</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">2</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">NAT 穿透</h4>
|
||||
<p className="text-gray-600">使用 STUN 服务器检测网络环境,尝试直连</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">3</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">数据传输</h4>
|
||||
<p className="text-gray-600">建立 P2P 连接后直接传输,或通过 TURN 中继</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="隐私保护">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>所有文件数据通过点对点传输,服务器不存储任何文件内容</li>
|
||||
<li>房间码具有时效性,连接断开后自动失效</li>
|
||||
<li>支持端到端加密,确保传输安全</li>
|
||||
<li>即使使用 TURN 中继,数据也是加密传输的</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 交流反馈 */}
|
||||
<Section id="contact" title="交流反馈" icon={<Users className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">交流群组</h3>
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700 mb-4">
|
||||
欢迎加入我们的交流群,获取最新更新、技术支持和经验分享:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
<li>报告问题和建议</li>
|
||||
<li>获取使用帮助</li>
|
||||
<li>分享部署经验</li>
|
||||
<li>了解新功能动态</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<img
|
||||
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
|
||||
alt="交流反馈群二维码"
|
||||
className="w-32 h-32 mx-auto rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-2">扫码加入交流群</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">其他联系方式</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5 text-blue-600" />
|
||||
GitHub Issues
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">提交 Bug 报告和功能请求</p>
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go/issues"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
前往 Issues 页面 →
|
||||
</a>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Book className="w-5 h-5 text-green-600" />
|
||||
项目文档
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">查看详细的技术文档</p>
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
前往项目主页 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="反馈建议">
|
||||
<p>为了更好地帮助您解决问题,请在反馈时提供:</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>详细的问题描述和复现步骤</li>
|
||||
<li>部署环境信息(Docker/二进制/自构建)</li>
|
||||
<li>浏览器类型和版本</li>
|
||||
<li>网络环境(内网/公网/NAT类型)</li>
|
||||
<li>相关的错误日志或截图</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
chuan-next/src/app/help/layout.tsx
Normal file
15
chuan-next/src/app/help/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '使用帮助 - 文件传输工具',
|
||||
description: '详细的部署指南和使用说明,帮助您快速上手文件传输工具',
|
||||
keywords: ['文件传输', '帮助文档', '部署指南', 'WebRTC', '使用说明'],
|
||||
}
|
||||
|
||||
export default function HelpLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
5
chuan-next/src/app/help/page.tsx
Normal file
5
chuan-next/src/app/help/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HelpPage from '@/app/help/HelpPage'
|
||||
|
||||
export default function Help() {
|
||||
return <HelpPage />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWebRTCStore } from '@/hooks/index';
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ConnectionStatusProps {
|
||||
}
|
||||
|
||||
// 连接状态枚举
|
||||
const getConnectionStatus = (connection: any, currentRoom: any) => {
|
||||
const getConnectionStatus = (connection: { isWebSocketConnected?: boolean; isPeerConnected?: boolean; isConnecting?: boolean; error?: string | null }, currentRoom: { code: string; role: 'sender' | 'receiver' } | null) => {
|
||||
const isWebSocketConnected = connection?.isWebSocketConnected || false;
|
||||
const isPeerConnected = connection?.isPeerConnected || false;
|
||||
const isConnecting = connection?.isConnecting || false;
|
||||
@@ -116,7 +116,7 @@ const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?:
|
||||
};
|
||||
|
||||
// 获取连接状态文字描述
|
||||
const getConnectionStatusText = (connection: any) => {
|
||||
const getConnectionStatusText = (connection: { isWebSocketConnected?: boolean; isPeerConnected?: boolean; isConnecting?: boolean; error?: string | null }) => {
|
||||
const isWebSocketConnected = connection?.isWebSocketConnected || false;
|
||||
const isPeerConnected = connection?.isPeerConnected || false;
|
||||
const isConnecting = connection?.isConnecting || false;
|
||||
@@ -155,12 +155,14 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
|
||||
error: webrtcState.error,
|
||||
};
|
||||
|
||||
const isConnected = webrtcState.isWebSocketConnected && webrtcState.isPeerConnected;
|
||||
|
||||
// 如果是内联模式,只返回状态文字
|
||||
if (inline) {
|
||||
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
|
||||
}
|
||||
|
||||
const status = getConnectionStatus(connection, currentRoom);
|
||||
const status = getConnectionStatus(connection, currentRoom ?? null);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
@@ -232,32 +234,8 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息
|
||||
{connection.error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 rounded p-2">
|
||||
{connection.error}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简化版本的 Hook,用于快速集成 - 现在已经不需要了,但保留兼容性
|
||||
export function useConnectionStatus(webrtcConnection?: any) {
|
||||
// 这个hook现在不再需要,因为ConnectionStatus组件直接使用底层连接
|
||||
// 但为了向后兼容,保留这个接口
|
||||
return useMemo(() => ({
|
||||
isWebSocketConnected: webrtcConnection?.isWebSocketConnected || false,
|
||||
isPeerConnected: webrtcConnection?.isPeerConnected || false,
|
||||
isConnecting: webrtcConnection?.isConnecting || false,
|
||||
currentRoom: webrtcConnection?.currentRoom || null,
|
||||
error: webrtcConnection?.error || null,
|
||||
}), [
|
||||
webrtcConnection?.isWebSocketConnected,
|
||||
webrtcConnection?.isPeerConnected,
|
||||
webrtcConnection?.isConnecting,
|
||||
webrtcConnection?.currentRoom,
|
||||
webrtcConnection?.error,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useURLHandler } from '@/hooks/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Share, Monitor } from 'lucide-react';
|
||||
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
|
||||
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
|
||||
import { useWebRTCStore } from '@/hooks/index';
|
||||
import { Share, Monitor, AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
|
||||
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
|
||||
|
||||
|
||||
interface DesktopShareProps {
|
||||
// 保留向后兼容性的props(已废弃,但保留接口)
|
||||
onStartSharing?: () => Promise<string>;
|
||||
onStopSharing?: () => Promise<void>;
|
||||
onJoinSharing?: (code: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function DesktopShare({
|
||||
onStartSharing,
|
||||
onStopSharing,
|
||||
onJoinSharing
|
||||
// 检测是否支持屏幕分享
|
||||
function useScreenShareSupport() {
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenShareSupport = async () => {
|
||||
try {
|
||||
// 首先检查是否存在 getDisplayMedia API
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
setIsSupported(false);
|
||||
setReason('api-not-supported');
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
if (isMobile) {
|
||||
setIsSupported(false);
|
||||
setReason('mobile');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查安全上下文 - getDisplayMedia 需要安全上下文(HTTPS 或 localhost)
|
||||
if (!window.isSecureContext) {
|
||||
const isLocalhost = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.hostname === '[::1]';
|
||||
|
||||
if (!isLocalhost) {
|
||||
setIsSupported(false);
|
||||
setReason('insecure-context');
|
||||
return;
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsSupported(true);
|
||||
setReason('');
|
||||
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error checking screen share support:', error);
|
||||
setIsSupported(false);
|
||||
setReason('unknown-error');
|
||||
}
|
||||
};
|
||||
|
||||
checkScreenShareSupport();
|
||||
}, []);
|
||||
|
||||
return { isSupported, reason };
|
||||
}
|
||||
|
||||
export default function DesktopShare({
|
||||
onJoinSharing
|
||||
}: DesktopShareProps) {
|
||||
const [mode, setMode] = useState<'share' | 'view'>('share');
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
const { isSupported, reason } = useScreenShareSupport();
|
||||
|
||||
// 使用统一的URL处理器,带模式转换
|
||||
const { updateMode, getCurrentRoomCode } = useURLHandler({
|
||||
@@ -44,14 +88,78 @@ export default function DesktopShare({
|
||||
return code;
|
||||
}, [getCurrentRoomCode]);
|
||||
|
||||
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
|
||||
const handleConnectionChange = useCallback((connection: any) => {
|
||||
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
|
||||
// 连接状态变化处理 - 为了兼容现有的子组件接口,保留它
|
||||
const handleConnectionChange = useCallback((connection: { isConnected: boolean; isWebSocketConnected: boolean }) => {
|
||||
console.log('桌面共享连接状态变化:', connection);
|
||||
}, []);
|
||||
|
||||
// 获取提示信息
|
||||
const getWarningInfo = () => {
|
||||
switch (reason) {
|
||||
case 'mobile':
|
||||
return {
|
||||
title: '移动端不支持屏幕分享',
|
||||
message: '移动端浏览器不支持获取桌面视频流,请使用桌面设备进行屏幕共享。'
|
||||
};
|
||||
case 'api-not-supported':
|
||||
return {
|
||||
title: '浏览器不支持屏幕分享',
|
||||
message: '当前浏览器不支持 getDisplayMedia API,请使用支持屏幕分享的现代浏览器(如 Chrome、Firefox、Edge 等)。'
|
||||
};
|
||||
case 'insecure-context':
|
||||
return {
|
||||
title: '需要安全上下文',
|
||||
message: '屏幕分享功能需要在安全上下文中使用(HTTPS协议或localhost),当前环境不支持。'
|
||||
};
|
||||
case 'detection-failed':
|
||||
return {
|
||||
title: '检测屏幕分享支持失败',
|
||||
message: '无法检测屏幕分享支持情况,这可能是由于浏览器限制或权限问题。'
|
||||
};
|
||||
case 'unknown-error':
|
||||
return {
|
||||
title: '未知错误',
|
||||
message: '检测屏幕分享支持时发生未知错误,请尝试刷新页面或使用其他浏览器。'
|
||||
};
|
||||
case 'ip-http':
|
||||
return {
|
||||
title: '当前环境不支持屏幕分享',
|
||||
message: '使用IP地址访问时,浏览器要求HTTPS协议才能进行屏幕分享。请配置HTTPS或使用localhost访问。'
|
||||
};
|
||||
case 'non-https':
|
||||
return {
|
||||
title: '需要HTTPS协议',
|
||||
message: '屏幕分享功能需要在HTTPS环境下使用,请使用HTTPS协议访问或在本地环境测试。'
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const warningInfo = getWarningInfo();
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 环境不支持提示 */}
|
||||
{!isSupported && warningInfo && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-amber-900 mb-1">{warningInfo.title}</h3>
|
||||
<p className="text-amber-800 text-sm mb-3">{warningInfo.message}</p>
|
||||
<Link
|
||||
href="/help#desktop-share"
|
||||
className="inline-flex items-center gap-2 text-sm text-amber-700 hover:text-amber-900 underline"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看详细解决方案
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模式选择器 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
@@ -59,6 +167,7 @@ export default function DesktopShare({
|
||||
variant={mode === 'share' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('share')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
disabled={!isSupported && mode === 'share'}
|
||||
>
|
||||
<Share className="w-4 h-4 mr-2" />
|
||||
共享桌面
|
||||
@@ -79,7 +188,7 @@ export default function DesktopShare({
|
||||
{mode === 'share' ? (
|
||||
<WebRTCDesktopSender onConnectionChange={handleConnectionChange} />
|
||||
) : (
|
||||
<WebRTCDesktopReceiver
|
||||
<WebRTCDesktopReceiver
|
||||
initialCode={getInitialCode()}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,12 @@ export default function DesktopViewer({
|
||||
if (videoRef.current && stream) {
|
||||
console.log('[DesktopViewer] 🎬 设置视频流,轨道数量:', stream.getTracks().length);
|
||||
stream.getTracks().forEach(track => {
|
||||
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, track.enabled, track.readyState);
|
||||
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, '启用:', track.enabled, '状态:', track.readyState);
|
||||
// 确保轨道已启用
|
||||
if (!track.enabled) {
|
||||
console.log('[DesktopViewer] 🔓 启用轨道:', track.id);
|
||||
track.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
videoRef.current.srcObject = stream;
|
||||
@@ -82,7 +87,31 @@ export default function DesktopViewer({
|
||||
console.log('[DesktopViewer] 📹 视频暂停');
|
||||
setIsPlaying(false);
|
||||
};
|
||||
const handleError = (e: Event) => console.error('[DesktopViewer] 📹 视频播放错误:', e);
|
||||
const handleError = (e: Event) => {
|
||||
console.error('[DesktopViewer] 📹 视频播放错误:', e);
|
||||
// 尝试重新加载流
|
||||
console.log('[DesktopViewer] 🔄 尝试重新加载视频流');
|
||||
setTimeout(() => {
|
||||
if (videoRef.current && stream) {
|
||||
videoRef.current.srcObject = null;
|
||||
videoRef.current.srcObject = stream;
|
||||
if (!hasAttemptedAutoplayRef.current) {
|
||||
hasAttemptedAutoplayRef.current = true;
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
console.log('[DesktopViewer] ✅ 重新加载后视频播放成功');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('[DesktopViewer] 📹 重新加载后自动播放仍被阻止:', err.message);
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
@@ -144,7 +173,33 @@ export default function DesktopViewer({
|
||||
// 全屏时自动隐藏控制栏,鼠标移动时显示
|
||||
setShowControls(false);
|
||||
} else {
|
||||
// 退出全屏时显示控制栏
|
||||
setShowControls(true);
|
||||
|
||||
// 延迟检查视频状态,确保全屏切换完成
|
||||
setTimeout(() => {
|
||||
if (videoRef.current && stream) {
|
||||
console.log('[DesktopViewer] 🔄 退出全屏,检查视频状态');
|
||||
|
||||
// 确保视频流正确设置
|
||||
const currentSrcObject = videoRef.current.srcObject;
|
||||
if (!currentSrcObject || currentSrcObject !== stream) {
|
||||
videoRef.current.srcObject = stream;
|
||||
}
|
||||
|
||||
// 检查视频是否暂停
|
||||
if (videoRef.current.paused) {
|
||||
console.log('[DesktopViewer] ⏸️ 退出全屏后视频已暂停,显示播放按钮');
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(true);
|
||||
hasAttemptedAutoplayRef.current = true; // 标记已尝试过自动播放
|
||||
} else {
|
||||
console.log('[DesktopViewer] ▶️ 退出全屏后视频仍在播放');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
}
|
||||
}
|
||||
}, 200); // 延迟200ms确保全屏切换完成
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,7 +208,7 @@ export default function DesktopViewer({
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
}, [stream]);
|
||||
|
||||
// 鼠标移动处理(全屏时)
|
||||
const handleMouseMove = useCallback(() => {
|
||||
@@ -207,13 +262,43 @@ export default function DesktopViewer({
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
if (!containerRef.current) return;
|
||||
if (!videoRef.current) return;
|
||||
|
||||
try {
|
||||
if (isFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
// 退出全屏
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
// 退出iOS全屏模式
|
||||
if ((document as any).webkitExitFullscreen) {
|
||||
await (document as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出视频全屏模式
|
||||
if ((videoRef.current as any).webkitExitFullscreen) {
|
||||
await (videoRef.current as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出Android全屏模式
|
||||
if ((videoRef.current as any).exitFullscreen) {
|
||||
await (videoRef.current as any).exitFullscreen();
|
||||
}
|
||||
} else {
|
||||
await containerRef.current.requestFullscreen();
|
||||
// 进入标准全屏
|
||||
if (videoRef.current.requestFullscreen) {
|
||||
await videoRef.current.requestFullscreen();
|
||||
}
|
||||
// 进入iOS全屏模式
|
||||
else if ((videoRef.current as any).webkitRequestFullscreen) {
|
||||
await (videoRef.current as any).webkitRequestFullscreen();
|
||||
}
|
||||
// 进入iOS视频全屏模式
|
||||
else if ((videoRef.current as any).webkitEnterFullscreen) {
|
||||
await (videoRef.current as any).webkitEnterFullscreen();
|
||||
}
|
||||
// 进入Android全屏模式
|
||||
else if ((videoRef.current as any).requestFullscreen) {
|
||||
await (videoRef.current as any).requestFullscreen();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DesktopViewer] 全屏切换失败:', error);
|
||||
@@ -223,9 +308,22 @@ export default function DesktopViewer({
|
||||
// 退出全屏
|
||||
const exitFullscreen = useCallback(async () => {
|
||||
try {
|
||||
// 退出标准全屏
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
// 退出iOS全屏模式
|
||||
if ((document as any).webkitExitFullscreen) {
|
||||
await (document as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出视频全屏模式
|
||||
if (videoRef.current && (videoRef.current as any).webkitExitFullscreen) {
|
||||
await (videoRef.current as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出Android全屏模式
|
||||
if (videoRef.current && (videoRef.current as any).exitFullscreen) {
|
||||
await (videoRef.current as any).exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DesktopViewer] 退出全屏失败:', error);
|
||||
}
|
||||
@@ -301,15 +399,15 @@ export default function DesktopViewer({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 需要用户交互的播放覆盖层 - 只在自动播放尝试失败后显示 */}
|
||||
{hasAttemptedAutoplayRef.current && needsUserInteraction && !isPlaying && (
|
||||
{/* 需要用户交互的播放覆盖层 - 在视频暂停时显示 */}
|
||||
{((needsUserInteraction && !isPlaying) || (isConnected && !isPlaying && !needsUserInteraction && videoRef.current?.paused)) && (
|
||||
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-white z-10">
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer" onClick={handleManualPlay}>
|
||||
<Play className="w-10 h-10 text-white ml-1" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">点击播放桌面共享</h3>
|
||||
<p className="text-sm opacity-75">浏览器需要用户交互才能开始播放媒体</p>
|
||||
<p className="text-sm opacity-75">视频已暂停,点击继续播放</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -331,23 +429,23 @@ export default function DesktopViewer({
|
||||
showControls || !isFullscreen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{/* 左侧信息 */}
|
||||
<div className="flex items-center space-x-4 text-white text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
|
||||
<span>{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : '连接中'}</span>
|
||||
<span className="text-xs sm:text-sm">{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : isConnected ? '已暂停' : '连接中'}</span>
|
||||
</div>
|
||||
{videoStats.resolution !== '0x0' && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-white/30"></div>
|
||||
<span>{videoStats.resolution}</span>
|
||||
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
|
||||
<span className="text-xs sm:text-sm hidden sm:block">{videoStats.resolution}</span>
|
||||
</>
|
||||
)}
|
||||
{connectionCode && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-white/30"></div>
|
||||
<span className="font-mono">{connectionCode}</span>
|
||||
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
|
||||
<span className="font-mono text-xs sm:text-sm hidden sm:block">{connectionCode}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -372,7 +470,7 @@ export default function DesktopViewer({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-white hover:bg-white/20"
|
||||
className="text-white hover:bg-white/20 hidden sm:flex"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -413,6 +511,19 @@ export default function DesktopViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端浮动全屏按钮 - 在控制栏隐藏时显示 */}
|
||||
{!isFullscreen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={toggleFullscreen}
|
||||
className="fixed bottom-20 right-4 z-40 md:hidden bg-black/60 text-white hover:bg-black/80 rounded-full p-3 shadow-lg"
|
||||
title="全屏"
|
||||
>
|
||||
<Maximize className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{stream && !isConnected && (
|
||||
<div className="absolute top-4 left-4 bg-black/60 text-white px-3 py-2 rounded-lg text-sm flex items-center space-x-2">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Github } from 'lucide-react';
|
||||
import { Github, HelpCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
@@ -15,7 +16,7 @@ export default function Hero() {
|
||||
<span className="text-xs sm:text-sm text-slate-500">基于WebRTC的端到端服务 - 无需注册,即传即用</span>
|
||||
</p>
|
||||
|
||||
{/* GitHub开源链接 */}
|
||||
{/* GitHub开源链接和帮助 */}
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
@@ -26,7 +27,17 @@ export default function Hero() {
|
||||
<Github className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">开源项目</span>
|
||||
</a>
|
||||
<span className="text-xs text-slate-400">|</span>
|
||||
|
||||
<Link
|
||||
href="/help"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-blue-600 hover:text-blue-800 bg-blue-50 hover:bg-blue-100 rounded-full transition-colors duration-200 border border-blue-200 hover:border-blue-300"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">使用帮助</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
target="_blank"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function WeChatGroup() {
|
||||
{/* 微信群二维码 - 请将此区域替换为实际的二维码图片 */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src="https://cdn-img.luxika.cc//i/2025/08/25/68abd75c363a6.png"
|
||||
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
|
||||
alt="微信群二维码"
|
||||
className="w-64 h-64 rounded-xl"
|
||||
/>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
isPeerConnected: connection.isPeerConnected
|
||||
});
|
||||
|
||||
const { joinRoom: originalJoinRoom, isJoiningRoom } = useRoomConnection({
|
||||
const { joinRoom: originalJoinRoom } = useRoomConnection({
|
||||
connect,
|
||||
isConnecting,
|
||||
isConnected
|
||||
@@ -264,28 +264,33 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
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
|
||||
// 更新发送方文件状态为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 {
|
||||
@@ -340,86 +345,7 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
}
|
||||
}, [error, mode, showToast, lastError]);
|
||||
|
||||
// 处理文件接收
|
||||
useEffect(() => {
|
||||
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
|
||||
console.log('=== 接收到文件 ===');
|
||||
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
|
||||
|
||||
// 更新下载的文件
|
||||
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
|
||||
|
||||
// 更新文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileData.id
|
||||
? { ...item, status: 'completed' as const, progress: 100 }
|
||||
: item
|
||||
));
|
||||
|
||||
// 移除不必要的Toast - 文件完成状态在UI中已经显示
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileReceived]);
|
||||
|
||||
// 处理文件请求(发送方监听)
|
||||
useEffect(() => {
|
||||
const cleanup = onFileRequested((fileId: string, fileName: string) => {
|
||||
console.log('=== 收到文件请求 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
|
||||
|
||||
if (mode === 'send') {
|
||||
// 检查连接状态
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,无法发送文件');
|
||||
showToast('连接已断开,无法发送文件', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
|
||||
|
||||
// 在发送方的selectedFiles中查找对应文件
|
||||
const file = selectedFiles.find(f => f.name === fileName);
|
||||
|
||||
if (!file) {
|
||||
console.error('找不到匹配的文件:', fileName);
|
||||
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
|
||||
showToast(`无法找到文件: ${fileName}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
|
||||
|
||||
// 更新发送方文件状态为downloading
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId || item.name === fileName
|
||||
? { ...item, status: 'downloading' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
|
||||
// 发送文件
|
||||
try {
|
||||
sendFile(file, fileId);
|
||||
|
||||
// 移除不必要的Toast - 传输开始状态在UI中已经显示
|
||||
} catch (sendError) {
|
||||
console.error('发送文件失败:', sendError);
|
||||
showToast(`发送文件失败: ${fileName}`, "error");
|
||||
|
||||
// 重置文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId || item.name === fileName
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
} else {
|
||||
console.warn('接收模式下收到文件请求,忽略');
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error]);
|
||||
|
||||
// 监听连接状态变化和清理传输状态
|
||||
useEffect(() => {
|
||||
|
||||
547
chuan-next/src/components/WebRTCSettings.tsx
Normal file
547
chuan-next/src/components/WebRTCSettings.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Plus,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Info,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
Database,
|
||||
X,
|
||||
Wifi
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useIceServersConfig, IceServerConfig } from '@/hooks/settings/useIceServersConfig';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useWebRTCStore } from '@/hooks/ui/webRTCStore';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface AddServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (config: Omit<IceServerConfig, 'id'>) => void;
|
||||
validateServer: (config: Omit<IceServerConfig, 'id'>) => string[];
|
||||
}
|
||||
|
||||
function AddServerModal({ isOpen, onClose, onSubmit, validateServer }: AddServerModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
urls: '',
|
||||
username: '',
|
||||
credential: '',
|
||||
type: 'stun' as 'stun' | 'turn',
|
||||
enabled: true,
|
||||
});
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateServer(formData);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(formData);
|
||||
onClose();
|
||||
// 重置表单
|
||||
setFormData({
|
||||
urls: '',
|
||||
username: '',
|
||||
credential: '',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
});
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: 'stun' | 'turn') => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
type,
|
||||
username: type === 'stun' ? '' : prev.username,
|
||||
credential: type === 'stun' ? '' : prev.credential,
|
||||
}));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setErrors([]);
|
||||
setFormData({
|
||||
urls: '',
|
||||
username: '',
|
||||
credential: '',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={(e) => {
|
||||
// 点击背景关闭弹窗
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto border border-gray-200 mx-4"
|
||||
onClick={(e) => e.stopPropagation()} // 防止点击弹窗内容时关闭
|
||||
>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-blue-600" />
|
||||
<span className="hidden sm:inline">添加ICE服务器</span>
|
||||
<span className="sm:hidden">添加服务器</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 服务器类型 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器类型
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="stun"
|
||||
checked={formData.type === 'stun'}
|
||||
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">STUN</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="turn"
|
||||
checked={formData.type === 'turn'}
|
||||
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">TURN</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 服务器地址 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器地址 *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.urls}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, urls: e.target.value }))}
|
||||
placeholder={formData.type === 'stun' ? 'stun:your-server.com:3478' : 'turn:your-server.com:3478'}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formData.type === 'stun' ?
|
||||
'格式: stun:服务器地址:端口' :
|
||||
'格式: turn:服务器地址:端口'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TURN服务器认证信息 */}
|
||||
{formData.type === 'turn' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名 *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="输入TURN服务器用户名"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码 *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.credential}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, credential: e.target.value }))}
|
||||
placeholder="输入TURN服务器密码"
|
||||
className="w-full pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">配置错误</p>
|
||||
<ul className="text-sm text-red-700 mt-1 space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button type="submit" className="flex-1 min-h-[44px] justify-center">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
添加服务器
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="sm:w-auto min-h-[44px] justify-center"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerItemProps {
|
||||
server: IceServerConfig;
|
||||
onRemove: (id: string) => void;
|
||||
canRemove: boolean;
|
||||
}
|
||||
|
||||
function ServerItem({ server, onRemove, canRemove }: ServerItemProps) {
|
||||
const isDefault = server.id.startsWith('google-') || server.id.startsWith('twilio-');
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-3 sm:p-4 bg-white">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
{server.type?.toUpperCase() || 'STUN'}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-700 break-all">
|
||||
{server.urls}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{server.username && (
|
||||
<p className="text-xs text-gray-500">
|
||||
用户名: {server.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end sm:justify-start sm:ml-4">
|
||||
{canRemove && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onRemove(server.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 min-h-[44px] min-w-[44px] px-3 py-2 sm:min-h-[36px] sm:min-w-[36px]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="ml-2 sm:hidden">删除</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebRTCSettings() {
|
||||
const {
|
||||
iceServers,
|
||||
isLoading,
|
||||
addIceServer,
|
||||
removeIceServer,
|
||||
resetToDefault,
|
||||
validateServer,
|
||||
} = useIceServersConfig();
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 获取WebRTC连接状态
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 检查是否有活跃连接
|
||||
const hasActiveConnection = isConnected || isConnecting || isPeerConnected;
|
||||
|
||||
const handleAddServer = (config: Omit<IceServerConfig, 'id'>) => {
|
||||
try {
|
||||
addIceServer(config);
|
||||
showToast('ICE服务器添加成功', 'success');
|
||||
} catch (error) {
|
||||
showToast('添加失败,请重试', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveServer = (id: string) => {
|
||||
setServerToDelete(id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const confirmDeleteServer = () => {
|
||||
if (serverToDelete) {
|
||||
try {
|
||||
removeIceServer(serverToDelete);
|
||||
showToast('ICE服务器删除成功', 'success');
|
||||
} catch (error) {
|
||||
showToast('至少需要保留一个ICE服务器', 'error');
|
||||
} finally {
|
||||
setServerToDelete(null);
|
||||
setShowDeleteDialog(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDeleteServer = () => {
|
||||
setServerToDelete(null);
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
try {
|
||||
resetToDefault();
|
||||
showToast('已恢复默认配置', 'success');
|
||||
} catch (error) {
|
||||
showToast('恢复默认配置失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载设置中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 px-4 sm:px-0">
|
||||
{/* 头部 */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="p-2 sm:p-3 bg-blue-100 rounded-xl">
|
||||
<Settings className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">WebRTC 设置</h2>
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
配置STUN/TURN服务器以优化网络连接性能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 隐私提示 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1 text-sm sm:text-base">隐私保护</h3>
|
||||
<p className="text-blue-800 text-xs sm:text-sm">
|
||||
所有配置数据仅存储在您的浏览器本地,不会同步到服务器
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接状态警告 */}
|
||||
{hasActiveConnection && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Wifi className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900 mb-1 text-sm sm:text-base">连接状态提醒</h3>
|
||||
<div className="text-amber-800 text-xs sm:text-sm">
|
||||
<p>检测到当前有活跃的WebRTC连接</p>
|
||||
{currentRoom && (
|
||||
<p className="mt-1">
|
||||
房间: <span className="font-mono text-xs">{currentRoom.code}</span>
|
||||
({currentRoom.role === 'sender' ? '发送方' : '接收方'})
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs">
|
||||
修改ICE服务器配置不会影响现有连接,新配置将在下次建立连接时生效
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ICE服务器列表 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">ICE 服务器配置</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-600">
|
||||
共 {iceServers.length} 个服务器配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetToDefault}
|
||||
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">恢复默认</span>
|
||||
<span className="sm:hidden">恢复</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">添加服务器</span>
|
||||
<span className="sm:hidden">添加</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 sm:p-4">
|
||||
{/* 服务器列表 */}
|
||||
<div className="space-y-3">
|
||||
{iceServers.map((server) => (
|
||||
<ServerItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onRemove={handleRemoveServer}
|
||||
canRemove={iceServers.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 空状态 */}
|
||||
{iceServers.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">暂无ICE服务器配置</p>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="min-h-[44px] px-6"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加第一个服务器
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置说明 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2 text-sm sm:text-base">配置说明</h3>
|
||||
|
||||
<div className="space-y-3 text-xs sm:text-sm text-gray-700">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">STUN:</h4>
|
||||
<p className="text-gray-600">
|
||||
用于检测公网IP地址和端口,帮助建立P2P连接。
|
||||
</p>
|
||||
<p className="text-gray-600 mt-1">
|
||||
格式:<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">stun:服务器地址:端口</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">TURN:</h4>
|
||||
<p className="text-gray-600">
|
||||
当P2P连接失败时,通过中继服务器转发数据,需要用户名和密码认证。
|
||||
</p>
|
||||
<p className="text-gray-600 mt-1">
|
||||
格式:<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">turn:服务器地址:端口</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加服务器弹窗 */}
|
||||
<AddServerModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={handleAddServer}
|
||||
validateServer={validateServer}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={cancelDeleteServer}
|
||||
onConfirm={confirmDeleteServer}
|
||||
title="删除ICE服务器"
|
||||
message={`确定要删除这个ICE服务器吗?删除后将无法恢复。${iceServers.length <= 1 ? '\n\n注意:这是最后一个服务器,删除后将无法建立WebRTC连接。' : ''}`}
|
||||
confirmText="删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,12 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (result.is_room_full) {
|
||||
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
|
||||
@@ -172,6 +178,12 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (result.is_room_full) {
|
||||
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
|
||||
@@ -221,6 +233,7 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
autoJoin();
|
||||
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting, isJoiningRoom]); // 添加isJoiningRoom依赖
|
||||
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
@@ -328,8 +341,8 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
|
||||
{/* 桌面显示区域 */}
|
||||
{desktopShare.remoteStream ? (
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
isConnected={desktopShare.isViewing}
|
||||
connectionCode={inputCode}
|
||||
onDisconnect={handleStopViewing}
|
||||
@@ -344,7 +357,7 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">等待桌面流...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,25 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 监听连接状态变化,当P2P连接断开时重置共享状态
|
||||
useEffect(() => {
|
||||
// 如果正在共享但P2P连接断开,自动重置共享状态
|
||||
if (desktopShare.isSharing && !desktopShare.isPeerConnected && desktopShare.connectionCode) {
|
||||
console.log('[DesktopShareSender] 检测到P2P连接断开,自动重置共享状态');
|
||||
|
||||
const resetState = async () => {
|
||||
try {
|
||||
await desktopShare.resetSharing();
|
||||
console.log('[DesktopShareSender] 已自动重置共享状态');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 自动重置共享状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
resetState();
|
||||
}
|
||||
}, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode, desktopShare.resetSharing]);
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
@@ -71,6 +90,14 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
console.error('[DesktopShareSender] 开始桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
|
||||
// 分享失败时重置状态,让用户重新选择桌面
|
||||
try {
|
||||
await desktopShare.resetSharing();
|
||||
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
|
||||
} catch (resetError) {
|
||||
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -90,6 +117,14 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
console.error('[DesktopShareSender] 切换桌面失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
|
||||
showToast(errorMessage, 'error');
|
||||
|
||||
// 切换桌面失败时重置状态,让用户重新选择桌面
|
||||
try {
|
||||
await desktopShare.resetSharing();
|
||||
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
|
||||
} catch (resetError) {
|
||||
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,16 @@ export function WebRTCFileReceive({
|
||||
console.log('验证响应:', { status: response.status, data });
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
const errorMessage = data.message || '取件码验证失败';
|
||||
let errorMessage = data.message || '取件码验证失败';
|
||||
|
||||
// 特殊处理房间人数已满的情况
|
||||
if (data.message?.includes('房间人数已满') || data.message?.includes('正在传输中无法加入')) {
|
||||
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
} else if (data.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (data.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
|
||||
// 显示toast错误提示
|
||||
showToast(errorMessage, 'error');
|
||||
@@ -85,6 +94,14 @@ export function WebRTCFileReceive({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (data.is_room_full) {
|
||||
const errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
showToast(errorMessage, 'error');
|
||||
console.log('房间已满:', errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('取件码验证成功:', data.room);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -141,7 +141,23 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
const roomData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(roomData.error || '房间不存在或已过期');
|
||||
let errorMessage = roomData.error || '房间不存在或已过期';
|
||||
|
||||
// 特殊处理房间人数已满的情况
|
||||
if (roomData.message?.includes('房间人数已满') || roomData.message?.includes('正在传输中无法加入')) {
|
||||
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
} else if (roomData.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (roomData.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (roomData.is_room_full) {
|
||||
throw new Error('当前房间人数已满,正在传输中无法加入,请稍后再试');
|
||||
}
|
||||
|
||||
console.log('=== 房间验证成功 ===', roomData);
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
export { useRoomConnection } from './useRoomConnection';
|
||||
export { useSharedWebRTCManager } from './useSharedWebRTCManager';
|
||||
export { useWebRTCManager } from './useWebRTCManager';
|
||||
export { useWebRTCSupport } from './useWebRTCSupport';
|
||||
|
||||
@@ -38,6 +38,11 @@ export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoo
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (result.is_room_full) {
|
||||
throw new Error('当前房间人数已满,正在传输中无法加入');
|
||||
}
|
||||
|
||||
if (!result.sender_online) {
|
||||
throw new Error('发送方不在线,请确认取件码是否正确或联系发送方');
|
||||
}
|
||||
@@ -54,6 +59,8 @@ export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoo
|
||||
return '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
return '服务器错误,请稍后重试';
|
||||
} else if (error.message.includes('房间人数已满') || error.message.includes('正在传输中无法加入')) {
|
||||
return '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
} else {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
import { useWebRTCStore } from '../ui/webRTCStore';
|
||||
|
||||
// 基础连接状态
|
||||
interface WebRTCState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean; // 新增:P2P连接状态
|
||||
error: string | null;
|
||||
canRetry: boolean; // 新增:是否可以重试
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
interface WebRTCMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
channel?: string;
|
||||
}
|
||||
import { useCallback } from 'react';
|
||||
import { useWebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { useWebRTCDataChannelManager, WebRTCMessage } from './useWebRTCDataChannelManager';
|
||||
import { useWebRTCTrackManager } from './useWebRTCTrackManager';
|
||||
import { useWebRTCConnectionCore } from './useWebRTCConnectionCore';
|
||||
|
||||
// 消息和数据处理器类型
|
||||
type MessageHandler = (message: WebRTCMessage) => void;
|
||||
type DataHandler = (data: ArrayBuffer) => void;
|
||||
export type MessageHandler = (message: WebRTCMessage) => void;
|
||||
export type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
// WebRTC 连接接口
|
||||
export interface WebRTCConnection {
|
||||
@@ -29,14 +14,14 @@ export interface WebRTCConnection {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean; // 新增:P2P连接状态
|
||||
isPeerConnected: boolean;
|
||||
error: string | null;
|
||||
canRetry: boolean; // 新增:是否可以重试
|
||||
canRetry: boolean;
|
||||
|
||||
// 操作方法
|
||||
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
|
||||
disconnect: () => void;
|
||||
retry: () => Promise<void>; // 新增:重试连接方法
|
||||
retry: () => Promise<void>;
|
||||
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
|
||||
sendData: (data: ArrayBuffer) => boolean;
|
||||
|
||||
@@ -62,797 +47,72 @@ export interface WebRTCConnection {
|
||||
/**
|
||||
* 共享 WebRTC 连接管理器
|
||||
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
|
||||
* 整合所有模块,提供统一的接口
|
||||
*/
|
||||
export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
// 使用全局状态 store
|
||||
const webrtcStore = useWebRTCStore();
|
||||
// 创建各个管理器实例
|
||||
const stateManager = useWebRTCStateManager();
|
||||
const dataChannelManager = useWebRTCDataChannelManager(stateManager);
|
||||
const trackManager = useWebRTCTrackManager(stateManager);
|
||||
const connectionCore = useWebRTCConnectionCore(
|
||||
stateManager,
|
||||
dataChannelManager,
|
||||
trackManager
|
||||
);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// 获取当前状态
|
||||
const state = stateManager.getState();
|
||||
|
||||
// 当前连接的房间信息
|
||||
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
|
||||
|
||||
// 用于跟踪是否是用户主动断开连接
|
||||
const isUserDisconnecting = useRef<boolean>(false);
|
||||
|
||||
// 多通道消息处理器
|
||||
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
|
||||
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
|
||||
|
||||
// STUN 服务器配置 - 使用更稳定的服务器
|
||||
const STUN_SERVERS = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:global.stun.twilio.com:3478' },
|
||||
];
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCState>) => {
|
||||
webrtcStore.updateState(updates);
|
||||
}, [webrtcStore]);
|
||||
|
||||
// 清理连接
|
||||
const cleanup = useCallback(() => {
|
||||
console.log('[SharedWebRTC] 清理连接');
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
currentRoom.current = null;
|
||||
isUserDisconnecting.current = false; // 重置主动断开标志
|
||||
}, []);
|
||||
|
||||
// 创建 Offer
|
||||
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
try {
|
||||
console.log('[SharedWebRTC] 🎬 开始创建offer,当前轨道数量:', pc.getSenders().length);
|
||||
|
||||
const offer = await pc.createOffer({
|
||||
offerToReceiveAudio: true, // 改为true以支持音频接收
|
||||
offerToReceiveVideo: true, // 改为true以支持视频接收
|
||||
});
|
||||
|
||||
console.log('[SharedWebRTC] 📝 Offer创建成功,设置本地描述...');
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const iceTimeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[SharedWebRTC] 📤 发送 offer (超时发送)');
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
} else {
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 创建 offer 失败:', error);
|
||||
updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理数据通道消息
|
||||
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebRTCMessage;
|
||||
console.log('[SharedWebRTC] 收到消息:', message.type, message.channel || 'default');
|
||||
|
||||
// 根据通道分发消息
|
||||
if (message.channel) {
|
||||
const handler = messageHandlers.current.get(message.channel);
|
||||
if (handler) {
|
||||
handler(message);
|
||||
}
|
||||
} else {
|
||||
// 兼容旧版本,广播给所有处理器
|
||||
messageHandlers.current.forEach(handler => handler(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 解析消息失败:', error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log('[SharedWebRTC] 收到数据:', event.data.byteLength, 'bytes');
|
||||
|
||||
// 数据优先发给文件传输处理器
|
||||
const fileHandler = dataHandlers.current.get('file-transfer');
|
||||
if (fileHandler) {
|
||||
fileHandler(event.data);
|
||||
} else {
|
||||
// 如果没有文件处理器,发给第一个处理器
|
||||
const firstHandler = dataHandlers.current.values().next().value;
|
||||
if (firstHandler) {
|
||||
firstHandler(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 连接到房间
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
|
||||
|
||||
// 如果正在连接中,避免重复连接
|
||||
if (webrtcStore.isConnecting) {
|
||||
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的连接
|
||||
cleanup();
|
||||
currentRoom.current = { code: roomCode, role };
|
||||
webrtcStore.setCurrentRoom({ code: roomCode, role });
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
// 重置主动断开标志
|
||||
isUserDisconnecting.current = false;
|
||||
|
||||
// 注意:不在这里设置超时,因为WebSocket连接很快,
|
||||
// WebRTC连接的建立是在后续添加轨道时进行的
|
||||
|
||||
try {
|
||||
console.log('[SharedWebRTC] 🔧 创建PeerConnection...');
|
||||
// 创建 PeerConnection
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: STUN_SERVERS,
|
||||
iceCandidatePoolSize: 10,
|
||||
});
|
||||
pcRef.current = pc;
|
||||
|
||||
// 连接 WebSocket - 使用动态URL
|
||||
const baseWsUrl = getWsUrl();
|
||||
if (!baseWsUrl) {
|
||||
throw new Error('WebSocket URL未配置');
|
||||
}
|
||||
|
||||
// 构建完整的WebSocket URL
|
||||
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
|
||||
console.log('[SharedWebRTC] 🌐 连接WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
// WebSocket 事件处理
|
||||
ws.onopen = () => {
|
||||
console.log('[SharedWebRTC] ✅ WebSocket 连接已建立,房间准备就绪');
|
||||
updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnecting: false, // WebSocket连接成功即表示初始连接完成
|
||||
isConnected: true // 可以开始后续操作
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('[SharedWebRTC] 📨 收到信令消息:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'peer-joined':
|
||||
// 对方加入房间的通知
|
||||
console.log('[SharedWebRTC] 👥 对方已加入房间,角色:', message.payload?.role);
|
||||
if (role === 'sender' && message.payload?.role === 'receiver') {
|
||||
console.log('[SharedWebRTC] 🚀 接收方已连接,发送方自动建立P2P连接');
|
||||
updateState({ isPeerConnected: true }); // 标记对方已加入,可以开始P2P
|
||||
|
||||
// 发送方自动创建offer建立基础P2P连接
|
||||
try {
|
||||
console.log('[SharedWebRTC] 📡 自动创建基础P2P连接offer');
|
||||
await createOffer(pc, ws);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 自动创建基础P2P连接失败:', error);
|
||||
}
|
||||
} else if (role === 'receiver' && message.payload?.role === 'sender') {
|
||||
console.log('[SharedWebRTC] 🚀 发送方已连接,接收方准备接收P2P连接');
|
||||
updateState({ isPeerConnected: true }); // 标记对方已加入
|
||||
}
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
console.log('[SharedWebRTC] 📬 处理offer...');
|
||||
if (pc.signalingState === 'stable') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ 设置远程描述完成');
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log('[SharedWebRTC] ✅ 创建并设置answer完成');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
|
||||
console.log('[SharedWebRTC] 📤 发送 answer');
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是stable:', pc.signalingState);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
console.log('[SharedWebRTC] 📬 处理answer...');
|
||||
try {
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是have-local-offer:', pc.signalingState);
|
||||
// 如果状态不对,尝试重新创建 offer
|
||||
if (pc.connectionState === 'connected' || pc.connectionState === 'connecting') {
|
||||
console.log('[SharedWebRTC] 🔄 连接状态正常但信令状态异常,尝试重新创建offer');
|
||||
// 这里不直接处理,让连接自然建立
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 处理answer失败:', error);
|
||||
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
|
||||
console.warn('[SharedWebRTC] ⚠️ Answer处理失败,可能是连接状态变化导致的');
|
||||
// 清理连接状态,让客户端重新连接
|
||||
updateState({ error: 'WebRTC连接状态异常,请重新连接', isPeerConnected: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
if (message.payload && pc.remoteDescription) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ 添加 ICE 候选成功');
|
||||
} catch (err) {
|
||||
console.warn('[SharedWebRTC] ⚠️ 添加 ICE 候选失败:', err);
|
||||
}
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ ICE候选无效或远程描述未设置');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[SharedWebRTC] ❌ 信令服务器错误:', message.error);
|
||||
updateState({ error: message.error, isConnecting: false, canRetry: true });
|
||||
break;
|
||||
|
||||
case 'disconnection':
|
||||
console.log('[SharedWebRTC] 🔌 对方主动断开连接');
|
||||
// 对方断开连接的处理
|
||||
updateState({
|
||||
isPeerConnected: false,
|
||||
isConnected: false, // 添加这个状态
|
||||
error: '对方已离开房间',
|
||||
canRetry: true
|
||||
});
|
||||
// 清理P2P连接但保持WebSocket连接,允许重新连接
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[SharedWebRTC] ⚠️ 未知消息类型:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 处理信令消息失败:', error);
|
||||
updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] ❌ WebSocket 错误:', error);
|
||||
updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
updateState({ isWebSocketConnected: false });
|
||||
|
||||
// 检查是否是用户主动断开
|
||||
if (isUserDisconnecting.current) {
|
||||
console.log('[SharedWebRTC] ✅ 用户主动断开,正常关闭');
|
||||
// 用户主动断开时不显示错误消息
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有在非正常关闭且不是用户主动断开时才显示错误
|
||||
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
|
||||
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
// PeerConnection 事件处理
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
console.log('[SharedWebRTC] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
|
||||
} else if (!event.candidate) {
|
||||
console.log('[SharedWebRTC] 🏁 ICE 收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.log('[SharedWebRTC] 🧊 ICE连接状态变化:', pc.iceConnectionState);
|
||||
switch (pc.iceConnectionState) {
|
||||
case 'checking':
|
||||
console.log('[SharedWebRTC] 🔍 正在检查ICE连接...');
|
||||
break;
|
||||
case 'connected':
|
||||
case 'completed':
|
||||
console.log('[SharedWebRTC] ✅ ICE连接成功');
|
||||
break;
|
||||
case 'failed':
|
||||
console.error('[SharedWebRTC] ❌ ICE连接失败');
|
||||
updateState({ error: 'ICE连接失败,可能是网络防火墙阻止了连接', isConnecting: false, canRetry: true });
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[SharedWebRTC] 🔌 ICE连接断开');
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[SharedWebRTC] 🚫 ICE连接已关闭');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log('[SharedWebRTC] 🔗 WebRTC连接状态变化:', pc.connectionState);
|
||||
switch (pc.connectionState) {
|
||||
case 'connecting':
|
||||
console.log('[SharedWebRTC] 🔄 WebRTC正在连接中...');
|
||||
updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立,可以进行媒体传输');
|
||||
updateState({ isPeerConnected: true, error: null, canRetry: false });
|
||||
break;
|
||||
case 'failed':
|
||||
// 只有在数据通道也未打开的情况下才认为连接真正失败
|
||||
const currentDc = dcRef.current;
|
||||
if (!currentDc || currentDc.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] ❌ WebRTC连接失败,数据通道未建立');
|
||||
updateState({ error: 'WebRTC连接失败,请检查网络设置或重试', isPeerConnected: false, canRetry: true });
|
||||
} else {
|
||||
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed,但数据通道正常,忽略此状态');
|
||||
}
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[SharedWebRTC] 🔌 WebRTC连接已断开');
|
||||
updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[SharedWebRTC] 🚫 WebRTC连接已关闭');
|
||||
updateState({ isPeerConnected: false });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 数据通道处理
|
||||
if (role === 'sender') {
|
||||
const dataChannel = pc.createDataChannel('shared-channel', {
|
||||
ordered: true,
|
||||
maxRetransmits: 3
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] 数据通道错误:', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
const pc = pcRef.current;
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[SharedWebRTC] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
} else {
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] 数据通道错误 (接收方):', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
const pc = pcRef.current;
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[SharedWebRTC] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 设置轨道接收处理(对于接收方)
|
||||
pc.ontrack = (event) => {
|
||||
console.log('[SharedWebRTC] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id);
|
||||
console.log('[SharedWebRTC] 关联的流数量:', event.streams.length);
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
console.log('[SharedWebRTC] 🎬 轨道关联到流:', event.streams[0].id);
|
||||
}
|
||||
|
||||
// 这里不处理,让具体的业务逻辑处理
|
||||
// onTrack会被业务逻辑重新设置
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 连接失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}, [updateState, cleanup, createOffer, handleDataChannelMessage, webrtcStore.isConnecting, webrtcStore.isConnected]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('[SharedWebRTC] 主动断开连接');
|
||||
|
||||
// 设置主动断开标志
|
||||
isUserDisconnecting.current = true;
|
||||
|
||||
// 在断开之前通知对方
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'disconnection',
|
||||
payload: { reason: '用户主动断开' }
|
||||
}));
|
||||
console.log('[SharedWebRTC] 📤 已通知对方断开连接');
|
||||
} catch (error) {
|
||||
console.warn('[SharedWebRTC] 发送断开通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理连接
|
||||
cleanup();
|
||||
|
||||
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
|
||||
webrtcStore.resetToInitial();
|
||||
}, [cleanup, webrtcStore]);
|
||||
|
||||
// 重试连接
|
||||
const retry = useCallback(async () => {
|
||||
const room = currentRoom.current;
|
||||
if (!room) {
|
||||
console.warn('[SharedWebRTC] 没有当前房间信息,无法重试');
|
||||
updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SharedWebRTC] 🔄 重试连接到房间:', room.code, room.role);
|
||||
|
||||
// 清理当前连接
|
||||
cleanup();
|
||||
|
||||
// 重新连接
|
||||
await connect(room.code, room.role);
|
||||
}, [cleanup, connect, updateState]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageWithChannel = channel ? { ...message, channel } : message;
|
||||
dataChannel.send(JSON.stringify(messageWithChannel));
|
||||
console.log('[SharedWebRTC] 发送消息:', message.type, channel || 'default');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 发送消息失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送二进制数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dataChannel.send(data);
|
||||
console.log('[SharedWebRTC] 发送数据:', data.byteLength, 'bytes');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 发送数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
|
||||
console.log('[SharedWebRTC] 注册消息处理器:', channel);
|
||||
messageHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[SharedWebRTC] 取消注册消息处理器:', channel);
|
||||
messageHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册数据处理器
|
||||
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
|
||||
console.log('[SharedWebRTC] 注册数据处理器:', channel);
|
||||
dataHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[SharedWebRTC] 取消注册数据处理器:', channel);
|
||||
dataHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
return dcRef.current?.readyState || 'closed';
|
||||
}, []);
|
||||
|
||||
// 检查是否已连接到指定房间
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return currentRoom.current?.code === roomCode &&
|
||||
currentRoom.current?.role === role &&
|
||||
webrtcStore.isConnected;
|
||||
}, [webrtcStore.isConnected]);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.error('[SharedWebRTC] PeerConnection 不可用');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return pc.addTrack(track, stream);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 添加轨道失败:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 移除媒体轨道
|
||||
const removeTrack = useCallback((sender: RTCRtpSender) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.error('[SharedWebRTC] PeerConnection 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pc.removeTrack(sender);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 移除轨道失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置轨道处理器
|
||||
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.warn('[SharedWebRTC] PeerConnection 尚未准备就绪,将在连接建立后设置onTrack');
|
||||
// 检查WebSocket连接状态,只有连接后才尝试设置
|
||||
if (!webrtcStore.isWebSocketConnected) {
|
||||
console.log('[SharedWebRTC] WebSocket未连接,等待连接建立...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟设置,等待PeerConnection准备就绪
|
||||
let retryCount = 0;
|
||||
const maxRetries = 30; // 最多重试30次,即3秒
|
||||
|
||||
const checkAndSetTrackHandler = () => {
|
||||
const currentPc = pcRef.current;
|
||||
if (currentPc) {
|
||||
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪,设置onTrack处理器');
|
||||
currentPc.ontrack = handler;
|
||||
} else {
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
// 只在偶数次重试时输出日志,减少日志数量
|
||||
if (retryCount % 2 === 0) {
|
||||
console.log(`[SharedWebRTC] ⏳ 等待PeerConnection准备就绪... (尝试: ${retryCount}/${maxRetries})`);
|
||||
}
|
||||
setTimeout(checkAndSetTrackHandler, 100);
|
||||
} else {
|
||||
console.error('[SharedWebRTC] ❌ PeerConnection 长时间未准备就绪,停止重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAndSetTrackHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
|
||||
pc.ontrack = handler;
|
||||
}, [webrtcStore.isWebSocketConnected]);
|
||||
|
||||
// 获取PeerConnection实例
|
||||
const getPeerConnection = useCallback(() => {
|
||||
return pcRef.current;
|
||||
}, []);
|
||||
|
||||
// 立即创建offer(用于媒体轨道添加后的重新协商)
|
||||
// 创建 createOfferNow 方法
|
||||
const createOfferNow = useCallback(async () => {
|
||||
const pc = pcRef.current;
|
||||
const ws = wsRef.current;
|
||||
const pc = connectionCore.getPeerConnection();
|
||||
const ws = connectionCore.getWebSocket();
|
||||
if (!pc || !ws) {
|
||||
console.error('[SharedWebRTC] PeerConnection 或 WebSocket 不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await createOffer(pc, ws);
|
||||
return true;
|
||||
return await trackManager.createOfferNow(pc, ws);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 创建 offer 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, [createOffer]);
|
||||
}, [connectionCore, trackManager]);
|
||||
|
||||
// 返回统一的接口,保持与当前 API 一致
|
||||
return {
|
||||
// 状态
|
||||
isConnected: webrtcStore.isConnected,
|
||||
isConnecting: webrtcStore.isConnecting,
|
||||
isWebSocketConnected: webrtcStore.isWebSocketConnected,
|
||||
isPeerConnected: webrtcStore.isPeerConnected,
|
||||
error: webrtcStore.error,
|
||||
canRetry: webrtcStore.canRetry,
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
canRetry: state.canRetry,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect,
|
||||
retry,
|
||||
sendMessage,
|
||||
sendData,
|
||||
connect: connectionCore.connect,
|
||||
disconnect: () => connectionCore.disconnect(true),
|
||||
retry: connectionCore.retry,
|
||||
sendMessage: dataChannelManager.sendMessage,
|
||||
sendData: dataChannelManager.sendData,
|
||||
|
||||
// 处理器注册
|
||||
registerMessageHandler,
|
||||
registerDataHandler,
|
||||
registerMessageHandler: dataChannelManager.registerMessageHandler,
|
||||
registerDataHandler: dataChannelManager.registerDataHandler,
|
||||
|
||||
// 工具方法
|
||||
getChannelState,
|
||||
isConnectedToRoom,
|
||||
getChannelState: dataChannelManager.getChannelState,
|
||||
isConnectedToRoom: stateManager.isConnectedToRoom,
|
||||
|
||||
// 媒体轨道方法
|
||||
addTrack,
|
||||
removeTrack,
|
||||
onTrack,
|
||||
getPeerConnection,
|
||||
addTrack: trackManager.addTrack,
|
||||
removeTrack: trackManager.removeTrack,
|
||||
onTrack: trackManager.onTrack,
|
||||
getPeerConnection: connectionCore.getPeerConnection,
|
||||
createOfferNow,
|
||||
|
||||
// 当前房间信息
|
||||
currentRoom: currentRoom.current,
|
||||
currentRoom: connectionCore.getCurrentRoom(),
|
||||
};
|
||||
}
|
||||
|
||||
570
chuan-next/src/hooks/connection/useWebRTCConnectionCore.ts
Normal file
570
chuan-next/src/hooks/connection/useWebRTCConnectionCore.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
import { getIceServersConfig } from '../settings/useIceServersConfig';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { WebRTCDataChannelManager, WebRTCMessage } from './useWebRTCDataChannelManager';
|
||||
import { WebRTCTrackManager } from './useWebRTCTrackManager';
|
||||
|
||||
/**
|
||||
* WebRTC 核心连接管理器
|
||||
* 负责基础的 WebRTC 连接管理
|
||||
*/
|
||||
export interface WebRTCConnectionCore {
|
||||
// 连接到房间
|
||||
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
|
||||
|
||||
// 断开连接
|
||||
disconnect: (shouldNotifyDisconnect?: boolean) => void;
|
||||
|
||||
// 重试连接
|
||||
retry: () => Promise<void>;
|
||||
|
||||
// 获取 PeerConnection 实例
|
||||
getPeerConnection: () => RTCPeerConnection | null;
|
||||
|
||||
// 获取 WebSocket 实例
|
||||
getWebSocket: () => WebSocket | null;
|
||||
|
||||
// 获取当前房间信息
|
||||
getCurrentRoom: () => { code: string; role: 'sender' | 'receiver' } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 核心连接管理 Hook
|
||||
* 负责基础的 WebRTC 连接管理,包括 WebSocket 连接、PeerConnection 创建和管理
|
||||
*/
|
||||
export function useWebRTCConnectionCore(
|
||||
stateManager: WebRTCStateManager,
|
||||
dataChannelManager: WebRTCDataChannelManager,
|
||||
trackManager: WebRTCTrackManager
|
||||
): WebRTCConnectionCore {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 当前连接的房间信息
|
||||
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
|
||||
|
||||
// 用于跟踪是否是用户主动断开连接
|
||||
const isUserDisconnecting = useRef<boolean>(false);
|
||||
|
||||
// 清理连接
|
||||
const cleanup = useCallback((shouldNotifyDisconnect: boolean = false) => {
|
||||
console.log('[ConnectionCore] 清理连接, 是否发送断开通知:', shouldNotifyDisconnect);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
// 在清理 WebSocket 之前发送断开通知
|
||||
if (shouldNotifyDisconnect && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'disconnection',
|
||||
payload: { reason: '用户主动断开' }
|
||||
}));
|
||||
console.log('[ConnectionCore] 📤 清理时已通知对方断开连接');
|
||||
} catch (error) {
|
||||
console.warn('[ConnectionCore] 清理时发送断开通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
currentRoom.current = null;
|
||||
isUserDisconnecting.current = false; // 重置主动断开标志
|
||||
}, []);
|
||||
|
||||
// 创建 PeerConnection 和相关设置
|
||||
const createPeerConnection = useCallback((ws: WebSocket, role: 'sender' | 'receiver', isReconnect: boolean = false) => {
|
||||
console.log('[ConnectionCore] 🔧 创建PeerConnection...', { role, isReconnect });
|
||||
|
||||
// 如果已经存在PeerConnection,先关闭它
|
||||
if (pcRef.current) {
|
||||
console.log('[ConnectionCore] 🔧 关闭已存在的PeerConnection');
|
||||
pcRef.current.close();
|
||||
}
|
||||
|
||||
// 获取用户配置的ICE服务器
|
||||
const iceServers = getIceServersConfig();
|
||||
console.log('[ConnectionCore] 🧊 使用ICE服务器配置:', iceServers);
|
||||
|
||||
// 创建 PeerConnection
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: iceServers,
|
||||
iceCandidatePoolSize: 10,
|
||||
});
|
||||
pcRef.current = pc;
|
||||
|
||||
// 设置轨道接收处理(对于接收方)
|
||||
pc.ontrack = (event) => {
|
||||
console.log('[ConnectionCore] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState);
|
||||
console.log('[ConnectionCore] 关联的流数量:', event.streams.length);
|
||||
|
||||
// 这里不处理轨道,让业务逻辑的onTrack处理器处理
|
||||
// 业务逻辑会在useEffect中设置自己的处理器
|
||||
// 这样可以确保重新连接时轨道能够被正确处理
|
||||
};
|
||||
|
||||
// PeerConnection 事件处理
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
console.log('[ConnectionCore] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
|
||||
} else if (!event.candidate) {
|
||||
console.log('[ConnectionCore] 🏁 ICE 收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.log('[ConnectionCore] 🧊 ICE连接状态变化:', pc.iceConnectionState);
|
||||
switch (pc.iceConnectionState) {
|
||||
case 'checking':
|
||||
console.log('[ConnectionCore] 🔍 正在检查ICE连接...');
|
||||
break;
|
||||
case 'connected':
|
||||
case 'completed':
|
||||
console.log('[ConnectionCore] ✅ ICE连接成功');
|
||||
break;
|
||||
case 'failed':
|
||||
console.error('[ConnectionCore] ❌ ICE连接失败');
|
||||
stateManager.updateState({ error: 'ICE连接失败,可能是网络防火墙阻止了连接', isConnecting: false, canRetry: true });
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[ConnectionCore] 🔌 ICE连接断开');
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[ConnectionCore] 🚫 ICE连接已关闭');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log('[ConnectionCore] 🔗 WebRTC连接状态变化:', pc.connectionState);
|
||||
switch (pc.connectionState) {
|
||||
case 'connecting':
|
||||
console.log('[ConnectionCore] 🔄 WebRTC正在连接中...');
|
||||
stateManager.updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('[ConnectionCore] 🎉 WebRTC P2P连接已完全建立,可以进行媒体传输');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
canRetry: false
|
||||
});
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[ConnectionCore] 🔄 检测到重新连接,触发数据同步');
|
||||
// 发送同步请求消息
|
||||
setTimeout(() => {
|
||||
const dc = pcRef.current?.createDataChannel('sync-channel');
|
||||
if (dc && dc.readyState === 'open') {
|
||||
dc.send(JSON.stringify({
|
||||
type: 'sync-request',
|
||||
payload: { timestamp: Date.now() }
|
||||
}));
|
||||
console.log('[ConnectionCore] 📤 发送数据同步请求');
|
||||
dc.close();
|
||||
}
|
||||
}, 500); // 等待数据通道完全稳定
|
||||
}
|
||||
break;
|
||||
case 'failed':
|
||||
console.error('[ConnectionCore] ❌ WebRTC连接失败');
|
||||
stateManager.updateState({ error: 'WebRTC连接失败,请检查网络设置或重试', isPeerConnected: false, canRetry: true });
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[ConnectionCore] 🔌 WebRTC连接已断开');
|
||||
stateManager.updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[ConnectionCore] 🚫 WebRTC连接已关闭');
|
||||
stateManager.updateState({ isPeerConnected: false });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建数据通道
|
||||
dataChannelManager.createDataChannel(pc, role, isReconnect);
|
||||
|
||||
console.log('[ConnectionCore] ✅ PeerConnection创建完成,角色:', role, '是否重新连接:', isReconnect);
|
||||
return pc;
|
||||
}, [stateManager, dataChannelManager]);
|
||||
|
||||
// 连接到房间
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('[ConnectionCore] 🚀 开始连接到房间:', roomCode, role);
|
||||
|
||||
// 如果正在连接中,避免重复连接
|
||||
const state = stateManager.getState();
|
||||
if (state.isConnecting) {
|
||||
console.warn('[ConnectionCore] ⚠️ 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是重新连接(页面关闭后重新打开)
|
||||
const isReconnect = currentRoom.current?.code === roomCode && currentRoom.current?.role === role;
|
||||
if (isReconnect) {
|
||||
console.log('[ConnectionCore] 🔄 检测到重新连接,清理旧连接');
|
||||
}
|
||||
|
||||
// 清理之前的连接
|
||||
cleanup();
|
||||
currentRoom.current = { code: roomCode, role };
|
||||
stateManager.setCurrentRoom({ code: roomCode, role });
|
||||
stateManager.updateState({ isConnecting: true, error: null });
|
||||
|
||||
// 重置主动断开标志
|
||||
isUserDisconnecting.current = false;
|
||||
|
||||
try {
|
||||
// 连接 WebSocket - 使用动态URL
|
||||
const baseWsUrl = getWsUrl();
|
||||
if (!baseWsUrl) {
|
||||
throw new Error('WebSocket URL未配置');
|
||||
}
|
||||
|
||||
// 构建完整的WebSocket URL
|
||||
const wsUrl = `${baseWsUrl}/api/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`;
|
||||
console.log('[ConnectionCore] 🌐 连接WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
// 保存重新连接状态,供后续使用
|
||||
const reconnectState = { isReconnect, role };
|
||||
|
||||
// WebSocket 事件处理
|
||||
ws.onopen = () => {
|
||||
console.log('[ConnectionCore] ✅ WebSocket 连接已建立,房间准备就绪');
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnecting: false, // WebSocket连接成功即表示初始连接完成
|
||||
isConnected: true // 可以开始后续操作
|
||||
});
|
||||
|
||||
// 如果是重新连接且是发送方,检查是否有接收方在等待
|
||||
if (reconnectState.isReconnect && reconnectState.role === 'sender') {
|
||||
console.log('[ConnectionCore] 🔄 发送方重新连接,检查是否有接收方在等待');
|
||||
// 这里不需要立即创建PeerConnection,等待接收方加入的通知
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('[ConnectionCore] 📨 收到信令消息:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'peer-joined':
|
||||
// 对方加入房间的通知
|
||||
console.log('[ConnectionCore] 👥 对方已加入房间,角色:', message.payload?.role);
|
||||
if (role === 'sender' && message.payload?.role === 'receiver') {
|
||||
console.log('[ConnectionCore] 🚀 接收方已连接,发送方开始建立P2P连接');
|
||||
// 确保WebSocket连接状态正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true // 标记对方已加入,可以开始P2P
|
||||
});
|
||||
|
||||
// 如果是重新连接,先清理旧的PeerConnection
|
||||
if (reconnectState.isReconnect && pcRef.current) {
|
||||
console.log('[ConnectionCore] 🔄 重新连接:清理旧的PeerConnection');
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
// 对方加入后,创建PeerConnection
|
||||
const pc = createPeerConnection(ws, role, reconnectState.isReconnect);
|
||||
|
||||
// 设置轨道管理器的引用
|
||||
trackManager.setPeerConnection(pc);
|
||||
trackManager.setWebSocket(ws);
|
||||
|
||||
// 发送方创建offer建立基础P2P连接
|
||||
try {
|
||||
console.log('[ConnectionCore] 📡 创建基础P2P连接offer');
|
||||
await trackManager.createOffer(pc, ws);
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] 创建基础P2P连接失败:', error);
|
||||
}
|
||||
} else if (role === 'receiver' && message.payload?.role === 'sender') {
|
||||
console.log('[ConnectionCore] 🚀 发送方已连接,接收方准备接收P2P连接');
|
||||
// 确保WebSocket连接状态正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true // 标记对方已加入
|
||||
});
|
||||
|
||||
// 如果是重新连接,先清理旧的PeerConnection
|
||||
if (reconnectState.isReconnect && pcRef.current) {
|
||||
console.log('[ConnectionCore] 🔄 重新连接:清理旧的PeerConnection');
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
// 对方加入后,立即创建PeerConnection,准备接收offer
|
||||
const pc = createPeerConnection(ws, role, reconnectState.isReconnect);
|
||||
|
||||
// 设置轨道管理器的引用
|
||||
trackManager.setPeerConnection(pc);
|
||||
trackManager.setWebSocket(ws);
|
||||
|
||||
// 等待一小段时间确保PeerConnection完全初始化
|
||||
setTimeout(() => {
|
||||
console.log('[ConnectionCore] ✅ 接收方PeerConnection已准备就绪');
|
||||
}, 100);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
console.log('[ConnectionCore] 📬 处理offer...');
|
||||
// 如果PeerConnection不存在,先创建它
|
||||
let pcOffer = pcRef.current;
|
||||
if (!pcOffer) {
|
||||
console.log('[ConnectionCore] 🔧 PeerConnection不存在,先创建它');
|
||||
pcOffer = createPeerConnection(ws, role, reconnectState.isReconnect);
|
||||
|
||||
// 设置轨道管理器的引用
|
||||
trackManager.setPeerConnection(pcOffer);
|
||||
trackManager.setWebSocket(ws);
|
||||
|
||||
// 等待一小段时间确保PeerConnection完全初始化
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (pcOffer && pcOffer.signalingState === 'stable') {
|
||||
await pcOffer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ 设置远程描述完成');
|
||||
|
||||
const answer = await pcOffer.createAnswer();
|
||||
await pcOffer.setLocalDescription(answer);
|
||||
console.log('[ConnectionCore] ✅ 创建并设置answer完成');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
|
||||
console.log('[ConnectionCore] 📤 发送 answer');
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ PeerConnection状态不是stable或不存在:', pcOffer?.signalingState);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
console.log('[ConnectionCore] 📬 处理answer...');
|
||||
let pcAnswer = pcRef.current;
|
||||
try {
|
||||
// 如果PeerConnection不存在,先创建它
|
||||
if (!pcAnswer) {
|
||||
console.log('[ConnectionCore] 🔧 PeerConnection不存在,先创建它');
|
||||
pcAnswer = createPeerConnection(ws, role, reconnectState.isReconnect);
|
||||
|
||||
// 设置轨道管理器的引用
|
||||
trackManager.setPeerConnection(pcAnswer);
|
||||
trackManager.setWebSocket(ws);
|
||||
|
||||
// 等待一小段时间确保PeerConnection完全初始化
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (pcAnswer) {
|
||||
const signalingState = pcAnswer.signalingState;
|
||||
// 如果状态是stable,可能是因为之前的offer已经完成,需要重新创建offer
|
||||
if (signalingState === 'stable') {
|
||||
console.log('[ConnectionCore] 🔄 PeerConnection状态为stable,重新创建offer');
|
||||
try {
|
||||
await trackManager.createOffer(pcAnswer, ws);
|
||||
// 等待一段时间让ICE候选收集完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 现在状态应该是have-local-offer,可以处理answer
|
||||
if (pcAnswer.signalingState === 'have-local-offer') {
|
||||
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ 重新创建offer后状态仍然不是have-local-offer:', pcAnswer.signalingState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] ❌ 重新创建offer失败:', error);
|
||||
}
|
||||
} else if (signalingState === 'have-local-offer') {
|
||||
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', signalingState);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] ❌ 处理answer失败:', error);
|
||||
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
|
||||
console.warn('[ConnectionCore] ⚠️ Answer处理失败,可能是连接状态变化导致的');
|
||||
// 清理连接状态,让客户端重新连接
|
||||
stateManager.updateState({ error: 'WebRTC连接状态异常,请重新连接', isPeerConnected: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
let pcIce = pcRef.current;
|
||||
if (!pcIce) {
|
||||
console.log('[ConnectionCore] 🔧 PeerConnection不存在,先创建它');
|
||||
pcIce = createPeerConnection(ws, role, reconnectState.isReconnect);
|
||||
|
||||
// 等待一小段时间确保PeerConnection完全初始化
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (pcIce && message.payload) {
|
||||
try {
|
||||
// 即使远程描述未设置,也可以先缓存ICE候选
|
||||
if (pcIce.remoteDescription) {
|
||||
await pcIce.addIceCandidate(new RTCIceCandidate(message.payload));
|
||||
console.log('[ConnectionCore] ✅ 添加 ICE 候选成功');
|
||||
} else {
|
||||
console.log('[ConnectionCore] 📝 远程描述未设置,缓存ICE候选');
|
||||
// 可以在这里实现ICE候选缓存机制,等远程描述设置后再添加
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ConnectionCore] ⚠️ 添加 ICE 候选失败:', err);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ ICE候选无效或PeerConnection不存在');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[ConnectionCore] ❌ 信令服务器错误:', message.error);
|
||||
stateManager.updateState({ error: message.error, isConnecting: false, canRetry: true });
|
||||
break;
|
||||
|
||||
case 'disconnection':
|
||||
console.log('[ConnectionCore] 🔌 对方主动断开连接');
|
||||
// 对方断开连接的处理
|
||||
stateManager.updateState({
|
||||
isPeerConnected: false,
|
||||
isConnected: false, // 添加这个状态
|
||||
error: '对方已离开房间',
|
||||
canRetry: true
|
||||
});
|
||||
// 清理P2P连接但保持WebSocket连接,允许重新连接
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[ConnectionCore] ⚠️ 未知消息类型:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] ❌ 处理信令消息失败:', error);
|
||||
stateManager.updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[ConnectionCore] ❌ WebSocket 错误:', error);
|
||||
stateManager.updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('[ConnectionCore] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
stateManager.updateState({ isWebSocketConnected: false });
|
||||
|
||||
// 检查是否是用户主动断开
|
||||
if (isUserDisconnecting.current) {
|
||||
console.log('[ConnectionCore] ✅ 用户主动断开,正常关闭');
|
||||
// 用户主动断开时不显示错误消息
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有在非正常关闭且不是用户主动断开时才显示错误
|
||||
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
|
||||
stateManager.updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] 连接失败:', error);
|
||||
stateManager.updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}, [stateManager, cleanup, createPeerConnection]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback((shouldNotifyDisconnect: boolean = false) => {
|
||||
console.log('[ConnectionCore] 主动断开连接');
|
||||
|
||||
// 设置主动断开标志
|
||||
isUserDisconnecting.current = true;
|
||||
|
||||
// 清理连接并发送断开通知
|
||||
cleanup(shouldNotifyDisconnect);
|
||||
|
||||
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
|
||||
stateManager.resetToInitial();
|
||||
console.log('[ConnectionCore] ✅ 连接已断开并清理完成');
|
||||
}, [cleanup, stateManager]);
|
||||
|
||||
// 重试连接
|
||||
const retry = useCallback(async () => {
|
||||
const room = currentRoom.current;
|
||||
if (!room) {
|
||||
console.warn('[ConnectionCore] 没有当前房间信息,无法重试');
|
||||
stateManager.updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ConnectionCore] 🔄 重试连接到房间:', room.code, room.role);
|
||||
|
||||
// 清理当前连接
|
||||
cleanup();
|
||||
|
||||
// 重新连接
|
||||
await connect(room.code, room.role);
|
||||
}, [cleanup, connect, stateManager]);
|
||||
|
||||
// 获取 PeerConnection 实例
|
||||
const getPeerConnection = useCallback(() => {
|
||||
return pcRef.current;
|
||||
}, []);
|
||||
|
||||
// 获取 WebSocket 实例
|
||||
const getWebSocket = useCallback(() => {
|
||||
return wsRef.current;
|
||||
}, []);
|
||||
|
||||
// 获取当前房间信息
|
||||
const getCurrentRoom = useCallback(() => {
|
||||
return currentRoom.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
retry,
|
||||
getPeerConnection,
|
||||
getWebSocket,
|
||||
getCurrentRoom,
|
||||
};
|
||||
}
|
||||
356
chuan-next/src/hooks/connection/useWebRTCDataChannelManager.ts
Normal file
356
chuan-next/src/hooks/connection/useWebRTCDataChannelManager.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
|
||||
// 消息类型
|
||||
export interface WebRTCMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
// 消息和数据处理器类型
|
||||
export type MessageHandler = (message: WebRTCMessage) => void;
|
||||
export type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
/**
|
||||
* WebRTC 数据通道管理器
|
||||
* 负责数据通道的创建和管理
|
||||
*/
|
||||
export interface WebRTCDataChannelManager {
|
||||
// 创建数据通道
|
||||
createDataChannel: (pc: RTCPeerConnection, role: 'sender' | 'receiver', isReconnect?: boolean) => void;
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
|
||||
|
||||
// 发送二进制数据
|
||||
sendData: (data: ArrayBuffer) => boolean;
|
||||
|
||||
// 注册消息处理器
|
||||
registerMessageHandler: (channel: string, handler: MessageHandler) => () => void;
|
||||
|
||||
// 注册数据处理器
|
||||
registerDataHandler: (channel: string, handler: DataHandler) => () => void;
|
||||
|
||||
// 获取数据通道状态
|
||||
getChannelState: () => RTCDataChannelState;
|
||||
|
||||
// 处理数据通道消息
|
||||
handleDataChannelMessage: (event: MessageEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 数据通道管理 Hook
|
||||
* 负责数据通道的创建和管理,处理数据通道消息的发送和接收
|
||||
*/
|
||||
export function useWebRTCDataChannelManager(
|
||||
stateManager: WebRTCStateManager
|
||||
): WebRTCDataChannelManager {
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
|
||||
// 多通道消息处理器
|
||||
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
|
||||
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
|
||||
|
||||
// 创建数据通道
|
||||
const createDataChannel = useCallback((
|
||||
pc: RTCPeerConnection,
|
||||
role: 'sender' | 'receiver',
|
||||
isReconnect: boolean = false
|
||||
) => {
|
||||
console.log('[DataChannelManager] 创建数据通道...', { role, isReconnect });
|
||||
|
||||
// 如果已经存在数据通道,先关闭它
|
||||
if (dcRef.current) {
|
||||
console.log('[DataChannelManager] 关闭已存在的数据通道');
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
// 数据通道处理
|
||||
if (role === 'sender') {
|
||||
const dataChannel = pc.createDataChannel('shared-channel', {
|
||||
ordered: true,
|
||||
maxRetransmits: 3
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[DataChannelManager] 数据通道已打开 (发送方)');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
canRetry: false
|
||||
});
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[DataChannelManager] 发送方重新连接,数据通道已打开,准备同步数据');
|
||||
// 发送同步请求消息
|
||||
setTimeout(() => {
|
||||
if (dataChannel.readyState === 'open') {
|
||||
dataChannel.send(JSON.stringify({
|
||||
type: 'sync-request',
|
||||
payload: { timestamp: Date.now() }
|
||||
}));
|
||||
console.log('[DataChannelManager] 发送方发送数据同步请求');
|
||||
}
|
||||
}, 300); // 等待数据通道完全稳定
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[DataChannelManager] 数据通道错误:', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[DataChannelManager] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
stateManager.updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
} else {
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[DataChannelManager] 数据通道已打开 (接收方)');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
canRetry: false
|
||||
});
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[DataChannelManager] 接收方重新连接,数据通道已打开,准备同步数据');
|
||||
// 发送同步请求消息
|
||||
setTimeout(() => {
|
||||
if (dataChannel.readyState === 'open') {
|
||||
dataChannel.send(JSON.stringify({
|
||||
type: 'sync-request',
|
||||
payload: { timestamp: Date.now() }
|
||||
}));
|
||||
console.log('[DataChannelManager] 接收方发送数据同步请求');
|
||||
}
|
||||
}, 300); // 等待数据通道完全稳定
|
||||
}
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[DataChannelManager] 数据通道错误 (接收方):', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[DataChannelManager] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
stateManager.updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[DataChannelManager] 数据通道创建完成,角色:', role, '是否重新连接:', isReconnect);
|
||||
}, [stateManager]);
|
||||
|
||||
// 处理数据通道消息
|
||||
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebRTCMessage;
|
||||
console.log('[DataChannelManager] 收到消息:', message.type, message.channel || 'default');
|
||||
|
||||
// 根据通道分发消息
|
||||
if (message.channel) {
|
||||
const handler = messageHandlers.current.get(message.channel);
|
||||
if (handler) {
|
||||
handler(message);
|
||||
}
|
||||
} else {
|
||||
// 兼容旧版本,广播给所有处理器
|
||||
messageHandlers.current.forEach(handler => handler(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DataChannelManager] 解析消息失败:', error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log('[DataChannelManager] 收到数据:', event.data.byteLength, 'bytes');
|
||||
|
||||
// 数据优先发给文件传输处理器
|
||||
const fileHandler = dataHandlers.current.get('file-transfer');
|
||||
if (fileHandler) {
|
||||
fileHandler(event.data);
|
||||
} else {
|
||||
// 如果没有文件处理器,发给第一个处理器
|
||||
const firstHandler = dataHandlers.current.values().next().value;
|
||||
if (firstHandler) {
|
||||
firstHandler(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[DataChannelManager] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageWithChannel = channel ? { ...message, channel } : message;
|
||||
dataChannel.send(JSON.stringify(messageWithChannel));
|
||||
console.log('[DataChannelManager] 发送消息:', message.type, channel || 'default');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannelManager] 发送消息失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送二进制数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[DataChannelManager] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dataChannel.send(data);
|
||||
console.log('[DataChannelManager] 发送数据:', data.byteLength, 'bytes');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannelManager] 发送数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
|
||||
console.log('[DataChannelManager] 注册消息处理器:', channel);
|
||||
messageHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[DataChannelManager] 取消注册消息处理器:', channel);
|
||||
messageHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册数据处理器
|
||||
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
|
||||
console.log('[DataChannelManager] 注册数据处理器:', channel);
|
||||
dataHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[DataChannelManager] 取消注册数据处理器:', channel);
|
||||
dataHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
return dcRef.current?.readyState || 'closed';
|
||||
}, []);
|
||||
|
||||
return {
|
||||
createDataChannel,
|
||||
sendMessage,
|
||||
sendData,
|
||||
registerMessageHandler,
|
||||
registerDataHandler,
|
||||
getChannelState,
|
||||
handleDataChannelMessage,
|
||||
};
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { WebRTCManager } from '../webrtc/WebRTCManager';
|
||||
import { WebRTCConnectionState, WebRTCMessage, MessageHandler, DataHandler } from '../webrtc/types';
|
||||
import { WebRTCConnection } from './useSharedWebRTCManager';
|
||||
|
||||
interface WebRTCManagerConfig {
|
||||
dataChannelName?: string;
|
||||
enableLogging?: boolean;
|
||||
iceServers?: RTCIceServer[];
|
||||
iceCandidatePoolSize?: number;
|
||||
chunkSize?: number;
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
ackTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新的 WebRTC 管理器 Hook
|
||||
* 替代原有的 useSharedWebRTCManager,提供更好的架构和错误处理
|
||||
*/
|
||||
export function useWebRTCManager(config: WebRTCManagerConfig = {}): WebRTCConnection {
|
||||
const managerRef = useRef<WebRTCManager | null>(null);
|
||||
const [state, setState] = useState<WebRTCConnectionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false,
|
||||
currentRoom: null,
|
||||
});
|
||||
|
||||
// 初始化管理器
|
||||
useEffect(() => {
|
||||
if (!managerRef.current) {
|
||||
managerRef.current = new WebRTCManager(config);
|
||||
|
||||
// 监听状态变化
|
||||
managerRef.current.on('state-change', (event: any) => {
|
||||
setState(prev => ({ ...prev, ...event.state }));
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (managerRef.current) {
|
||||
managerRef.current.disconnect();
|
||||
managerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// 连接
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
if (!managerRef.current) {
|
||||
throw new Error('WebRTC 管理器未初始化');
|
||||
}
|
||||
return managerRef.current.connect(roomCode, role);
|
||||
}, []);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
if (!managerRef.current) return;
|
||||
managerRef.current.disconnect();
|
||||
}, []);
|
||||
|
||||
// 重试连接
|
||||
const retry = useCallback(async () => {
|
||||
if (!managerRef.current) {
|
||||
throw new Error('WebRTC 管理器未初始化');
|
||||
}
|
||||
return managerRef.current.retry();
|
||||
}, []);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.sendMessage(message, channel);
|
||||
}, []);
|
||||
|
||||
// 发送数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.sendData(data);
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
|
||||
if (!managerRef.current) return () => {};
|
||||
return managerRef.current.registerMessageHandler(channel, handler);
|
||||
}, []);
|
||||
|
||||
// 注册数据处理器
|
||||
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
|
||||
if (!managerRef.current) return () => {};
|
||||
return managerRef.current.registerDataHandler(channel, handler);
|
||||
}, []);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
if (!managerRef.current) return null;
|
||||
return managerRef.current.addTrack(track, stream);
|
||||
}, []);
|
||||
|
||||
// 移除媒体轨道
|
||||
const removeTrack = useCallback((sender: RTCRtpSender) => {
|
||||
if (!managerRef.current) return;
|
||||
managerRef.current.removeTrack(sender);
|
||||
}, []);
|
||||
|
||||
// 设置轨道处理器
|
||||
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
|
||||
if (!managerRef.current) return;
|
||||
managerRef.current.onTrack(handler);
|
||||
}, []);
|
||||
|
||||
// 获取 PeerConnection
|
||||
const getPeerConnection = useCallback(() => {
|
||||
if (!managerRef.current) return null;
|
||||
return managerRef.current.getPeerConnection();
|
||||
}, []);
|
||||
|
||||
// 立即创建 offer
|
||||
const createOfferNow = useCallback(async () => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.createOfferNow();
|
||||
}, []);
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
if (!managerRef.current) return 'closed';
|
||||
return managerRef.current.getChannelState();
|
||||
}, []);
|
||||
|
||||
// 检查是否已连接到指定房间
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
if (!managerRef.current) return false;
|
||||
return managerRef.current.isConnectedToRoom(roomCode, role);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
canRetry: state.canRetry,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect,
|
||||
retry,
|
||||
sendMessage,
|
||||
sendData,
|
||||
|
||||
// 处理器注册
|
||||
registerMessageHandler,
|
||||
registerDataHandler,
|
||||
|
||||
// 工具方法
|
||||
getChannelState,
|
||||
isConnectedToRoom,
|
||||
|
||||
// 媒体轨道方法
|
||||
addTrack,
|
||||
removeTrack,
|
||||
onTrack,
|
||||
getPeerConnection,
|
||||
createOfferNow,
|
||||
|
||||
// 当前房间信息
|
||||
currentRoom: state.currentRoom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移辅助 Hook - 提供向后兼容性
|
||||
* 可以逐步将现有代码迁移到新的架构
|
||||
*/
|
||||
export function useWebRTCMigration() {
|
||||
const newManager = useWebRTCManager();
|
||||
// 注意:这里需要先创建一个包装器来兼容旧的接口
|
||||
// 暂时注释掉,避免循环依赖
|
||||
// const oldManager = useSharedWebRTCManager();
|
||||
|
||||
return {
|
||||
newManager,
|
||||
// oldManager, // 暂时禁用
|
||||
// 可以添加迁移工具函数
|
||||
migrateState: () => {
|
||||
// 将旧状态迁移到新状态
|
||||
console.log('状态迁移功能待实现');
|
||||
},
|
||||
};
|
||||
}
|
||||
78
chuan-next/src/hooks/connection/useWebRTCStateManager.ts
Normal file
78
chuan-next/src/hooks/connection/useWebRTCStateManager.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
230
chuan-next/src/hooks/connection/useWebRTCTrackManager.ts
Normal file
230
chuan-next/src/hooks/connection/useWebRTCTrackManager.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
|
||||
/**
|
||||
* WebRTC 媒体轨道管理器
|
||||
* 负责媒体轨道的添加和移除
|
||||
*/
|
||||
export interface WebRTCTrackManager {
|
||||
// 添加媒体轨道
|
||||
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
|
||||
|
||||
// 移除媒体轨道
|
||||
removeTrack: (sender: RTCRtpSender) => void;
|
||||
|
||||
// 设置轨道处理器
|
||||
onTrack: (handler: (event: RTCTrackEvent) => void) => void;
|
||||
|
||||
// 创建 Offer
|
||||
createOffer: (pc: RTCPeerConnection, ws: WebSocket) => Promise<void>;
|
||||
|
||||
// 立即创建offer(用于媒体轨道添加后的重新协商)
|
||||
createOfferNow: (pc: RTCPeerConnection, ws: WebSocket) => Promise<boolean>;
|
||||
|
||||
// 内部方法,供核心连接管理器调用
|
||||
setPeerConnection: (pc: RTCPeerConnection | null) => void;
|
||||
setWebSocket: (ws: WebSocket | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 媒体轨道管理 Hook
|
||||
* 负责媒体轨道的添加和移除,处理轨道事件,提供 createOffer 功能
|
||||
*/
|
||||
export function useWebRTCTrackManager(
|
||||
stateManager: WebRTCStateManager
|
||||
): WebRTCTrackManager {
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// 创建 Offer
|
||||
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
try {
|
||||
console.log('[TrackManager] 🎬 开始创建offer,当前轨道数量:', pc.getSenders().length);
|
||||
|
||||
// 确保连接状态稳定
|
||||
if (pc.connectionState !== 'connecting' && pc.connectionState !== 'new') {
|
||||
console.warn('[TrackManager] ⚠️ PeerConnection状态异常:', pc.connectionState);
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer({
|
||||
offerToReceiveAudio: true, // 改为true以支持音频接收
|
||||
offerToReceiveVideo: true, // 改为true以支持视频接收
|
||||
});
|
||||
|
||||
console.log('[TrackManager] 📝 Offer创建成功,设置本地描述...');
|
||||
await pc.setLocalDescription(offer);
|
||||
console.log('[TrackManager] ✅ 本地描述设置完成');
|
||||
|
||||
// 增加超时时间到5秒,给ICE候选收集更多时间
|
||||
const iceTimeout = setTimeout(() => {
|
||||
console.log('[TrackManager] ⏱️ ICE收集超时,发送当前offer');
|
||||
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[TrackManager] 📤 发送 offer (超时发送)');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 如果ICE收集已经完成,立即发送
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[TrackManager] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
} else {
|
||||
console.log('[TrackManager] 🧊 等待ICE候选收集...');
|
||||
// 监听ICE收集状态变化
|
||||
pc.onicegatheringstatechange = () => {
|
||||
console.log('[TrackManager] 🧊 ICE收集状态变化:', pc.iceGatheringState);
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[TrackManager] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 同时监听ICE候选事件,用于调试
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('[TrackManager] 🧊 收到ICE候选:', event.candidate.candidate.substring(0, 50) + '...');
|
||||
} else {
|
||||
console.log('[TrackManager] 🏁 ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] ❌ 创建 offer 失败:', error);
|
||||
stateManager.updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
|
||||
}
|
||||
}, [stateManager]);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.error('[TrackManager] PeerConnection 不可用');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return pc.addTrack(track, stream);
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] 添加轨道失败:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 移除媒体轨道
|
||||
const removeTrack = useCallback((sender: RTCRtpSender) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.error('[TrackManager] PeerConnection 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pc.removeTrack(sender);
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] 移除轨道失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置轨道处理器
|
||||
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.warn('[TrackManager] PeerConnection 尚未准备就绪,将在连接建立后设置onTrack');
|
||||
// 检查WebSocket连接状态,只有连接后才尝试设置
|
||||
const state = stateManager.getState();
|
||||
if (!state.isWebSocketConnected) {
|
||||
console.log('[TrackManager] WebSocket未连接,等待连接建立...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟设置,等待PeerConnection准备就绪
|
||||
let retryCount = 0;
|
||||
const maxRetries = 50; // 增加重试次数到50次,即5秒
|
||||
|
||||
const checkAndSetTrackHandler = () => {
|
||||
const currentPc = pcRef.current;
|
||||
if (currentPc) {
|
||||
console.log('[TrackManager] ✅ PeerConnection 已准备就绪,设置onTrack处理器');
|
||||
currentPc.ontrack = handler;
|
||||
|
||||
// 如果已经有远程轨道,立即触发处理
|
||||
const receivers = currentPc.getReceivers();
|
||||
console.log(`[TrackManager] 📡 当前有 ${receivers.length} 个接收器`);
|
||||
receivers.forEach(receiver => {
|
||||
if (receiver.track) {
|
||||
console.log(`[TrackManager] 🎥 发现现有轨道: ${receiver.track.kind}, ${receiver.track.id}, 状态: ${receiver.track.readyState}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
// 每5次重试输出一次日志,减少日志数量
|
||||
if (retryCount % 5 === 0) {
|
||||
console.log(`[TrackManager] ⏳ 等待PeerConnection准备就绪... (尝试: ${retryCount}/${maxRetries})`);
|
||||
}
|
||||
setTimeout(checkAndSetTrackHandler, 100);
|
||||
} else {
|
||||
console.error('[TrackManager] ❌ PeerConnection 长时间未准备就绪,停止重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAndSetTrackHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TrackManager] ✅ 立即设置onTrack处理器');
|
||||
pc.ontrack = handler;
|
||||
|
||||
// 检查是否已有轨道
|
||||
const receivers = pc.getReceivers();
|
||||
console.log(`[TrackManager] 📡 当前有 ${receivers.length} 个接收器`);
|
||||
receivers.forEach(receiver => {
|
||||
if (receiver.track) {
|
||||
console.log(`[TrackManager] 🎥 发现现有轨道: ${receiver.track.kind}, ${receiver.track.id}, 状态: ${receiver.track.readyState}`);
|
||||
}
|
||||
});
|
||||
}, [stateManager]);
|
||||
|
||||
// 立即创建offer(用于媒体轨道添加后的重新协商)
|
||||
const createOfferNow = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
if (!pc || !ws) {
|
||||
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await createOffer(pc, ws);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] 创建 offer 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, [createOffer]);
|
||||
|
||||
// 设置 PeerConnection 引用
|
||||
const setPeerConnection = useCallback((pc: RTCPeerConnection | null) => {
|
||||
pcRef.current = pc;
|
||||
}, []);
|
||||
|
||||
// 设置 WebSocket 引用
|
||||
const setWebSocket = useCallback((ws: WebSocket | null) => {
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
addTrack,
|
||||
removeTrack,
|
||||
onTrack,
|
||||
createOffer,
|
||||
createOfferNow,
|
||||
// 内部方法,供核心连接管理器调用
|
||||
setPeerConnection,
|
||||
setWebSocket,
|
||||
};
|
||||
}
|
||||
@@ -44,18 +44,44 @@ export function useDesktopShareBusiness() {
|
||||
useEffect(() => {
|
||||
console.log('[DesktopShare] 🎧 设置远程轨道处理器');
|
||||
webRTC.onTrack((event: RTCTrackEvent) => {
|
||||
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
|
||||
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState);
|
||||
console.log('[DesktopShare] 远程流数量:', event.streams.length);
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
const remoteStream = event.streams[0];
|
||||
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
|
||||
remoteStream.getTracks().forEach(track => {
|
||||
console.log('[DesktopShare] 远程轨道:', track.kind, track.id, track.enabled, track.readyState);
|
||||
console.log('[DesktopShare] 远程轨道:', track.kind, track.id, '启用:', track.enabled, '状态:', track.readyState);
|
||||
});
|
||||
|
||||
// 确保轨道已启用
|
||||
remoteStream.getTracks().forEach(track => {
|
||||
if (!track.enabled) {
|
||||
console.log('[DesktopShare] 🔓 启用远程轨道:', track.id);
|
||||
track.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
handleRemoteStream(remoteStream);
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
|
||||
// 尝试从轨道创建流
|
||||
try {
|
||||
const newStream = new MediaStream([event.track]);
|
||||
console.log('[DesktopShare] 🔄 从轨道创建新流:', newStream.id);
|
||||
|
||||
// 确保轨道已启用
|
||||
newStream.getTracks().forEach(track => {
|
||||
if (!track.enabled) {
|
||||
console.log('[DesktopShare] 🔓 启用新流中的轨道:', track.id);
|
||||
track.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
handleRemoteStream(newStream);
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] ❌ 从轨道创建流失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [webRTC, handleRemoteStream]);
|
||||
@@ -87,6 +113,19 @@ export function useDesktopShareBusiness() {
|
||||
const setupVideoSending = useCallback(async (stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
|
||||
|
||||
// 检查P2P连接状态
|
||||
if (!webRTC.isPeerConnected) {
|
||||
console.warn('[DesktopShare] ⚠️ P2P连接尚未完全建立,等待连接稳定...');
|
||||
// 等待连接稳定
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 再次检查
|
||||
if (!webRTC.isPeerConnected) {
|
||||
console.error('[DesktopShare] ❌ P2P连接仍未建立,无法开始媒体传输');
|
||||
throw new Error('P2P连接尚未建立');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除之前的轨道(如果存在)
|
||||
if (currentSenderRef.current) {
|
||||
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
|
||||
@@ -131,17 +170,38 @@ export function useDesktopShareBusiness() {
|
||||
// 轨道添加完成,现在需要重新协商以包含媒体轨道
|
||||
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
|
||||
|
||||
// 检查P2P连接是否已建立
|
||||
if (!webRTC.isPeerConnected) {
|
||||
console.error('[DesktopShare] ❌ P2P连接尚未建立,无法开始媒体传输');
|
||||
throw new Error('P2P连接尚未建立');
|
||||
// 获取PeerConnection实例以便调试
|
||||
const pc = webRTC.getPeerConnection();
|
||||
if (pc) {
|
||||
console.log('[DesktopShare] 🔍 当前连接状态:', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState,
|
||||
senders: pc.getSenders().length
|
||||
});
|
||||
}
|
||||
|
||||
// 等待一小段时间确保轨道完全添加
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 创建新的offer包含媒体轨道
|
||||
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
|
||||
const success = await webRTC.createOfferNow();
|
||||
if (success) {
|
||||
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
|
||||
|
||||
// 等待重新协商完成
|
||||
console.log('[DesktopShare] ⏳ 等待重新协商完成...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 检查连接状态
|
||||
if (pc) {
|
||||
console.log('[DesktopShare] 🔍 重新协商后连接状态:', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
|
||||
throw new Error('媒体轨道重新协商失败');
|
||||
@@ -213,9 +273,9 @@ export function useDesktopShareBusiness() {
|
||||
// 开始桌面共享(在接收方加入后)
|
||||
const startSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// 检查WebSocket连接状态
|
||||
if (!webRTC.isWebSocketConnected) {
|
||||
throw new Error('WebSocket连接未建立,请先创建房间');
|
||||
// 检查P2P连接状态(与switchDesktop保持一致)
|
||||
if (!webRTC.isPeerConnected) {
|
||||
throw new Error('P2P连接未建立');
|
||||
}
|
||||
|
||||
updateState({ error: null });
|
||||
@@ -223,13 +283,18 @@ export function useDesktopShareBusiness() {
|
||||
|
||||
// 获取桌面流
|
||||
const stream = await getDesktopStream();
|
||||
|
||||
// 停止之前的流(如果有)- 与switchDesktop保持一致
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
localStreamRef.current = stream;
|
||||
console.log('[DesktopShare] ✅ 桌面流获取成功');
|
||||
|
||||
// 设置视频发送(这会添加轨道并创建offer,启动P2P连接)
|
||||
console.log('[DesktopShare] 📤 正在设置视频轨道推送并建立P2P连接...');
|
||||
// 设置新的视频发送 - 与switchDesktop保持一致
|
||||
await setupVideoSending(stream);
|
||||
console.log('[DesktopShare] ✅ 视频轨道推送设置完成');
|
||||
console.log('[DesktopShare] ✅ 桌面共享开始完成');
|
||||
|
||||
updateState({
|
||||
isSharing: true,
|
||||
@@ -327,6 +392,41 @@ export function useDesktopShareBusiness() {
|
||||
}
|
||||
}, [webRTC, updateState]);
|
||||
|
||||
// 重置桌面共享到初始状态(让用户重新选择桌面)
|
||||
const resetSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 重置桌面共享到初始状态');
|
||||
|
||||
// 停止本地流
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
console.log('[DesktopShare] 停止轨道:', track.kind);
|
||||
});
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
|
||||
// 移除发送器
|
||||
if (currentSenderRef.current) {
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 保留WebSocket连接和房间代码,但重置共享状态
|
||||
updateState({
|
||||
isSharing: false,
|
||||
error: null,
|
||||
isWaitingForPeer: false,
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 桌面共享已重置到初始状态');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '重置桌面共享失败';
|
||||
console.error('[DesktopShare] 重置共享失败:', error);
|
||||
updateState({ error: errorMessage });
|
||||
}
|
||||
}, [webRTC, updateState]);
|
||||
|
||||
// 加入桌面共享观看
|
||||
const joinSharing = useCallback(async (code: string): Promise<void> => {
|
||||
try {
|
||||
@@ -342,15 +442,33 @@ export function useDesktopShareBusiness() {
|
||||
console.log('[DesktopShare] ⏳ 等待连接稳定...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 检查连接状态
|
||||
const pc = webRTC.getPeerConnection();
|
||||
if (pc) {
|
||||
console.log('[DesktopShare] 🔍 连接状态:', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState
|
||||
});
|
||||
}
|
||||
|
||||
updateState({ isViewing: true });
|
||||
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
|
||||
|
||||
// 设置一个超时检查,如果长时间没有收到流,输出警告
|
||||
setTimeout(() => {
|
||||
if (!state.remoteStream) {
|
||||
console.warn('[DesktopShare] ⚠️ 长时间未收到远程流,可能存在连接问题');
|
||||
// 可以在这里添加一些恢复逻辑,比如尝试重新连接
|
||||
}
|
||||
}, 10000); // 10秒后检查
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
|
||||
console.error('[DesktopShare] ❌ 加入观看失败:', error);
|
||||
updateState({ error: errorMessage, isViewing: false });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, updateState]);
|
||||
}, [webRTC, updateState, state.remoteStream]);
|
||||
|
||||
// 停止观看桌面共享
|
||||
const stopViewing = useCallback(async (): Promise<void> => {
|
||||
@@ -411,6 +529,7 @@ export function useDesktopShareBusiness() {
|
||||
startSharing, // 选择桌面并建立P2P连接
|
||||
switchDesktop, // 新增:切换桌面
|
||||
stopSharing,
|
||||
resetSharing, // 重置到初始状态,保留房间连接
|
||||
joinSharing,
|
||||
stopViewing,
|
||||
setRemoteVideoRef,
|
||||
|
||||
@@ -115,40 +115,45 @@ export const useFileStateManager = ({
|
||||
|
||||
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)
|
||||
// 使用函数式更新获取当前fileList,避免依赖fileList
|
||||
setFileList(currentFileList => {
|
||||
// 根据selectedFiles创建新的文件信息列表
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
|
||||
// 尝试在当前fileList中找到现有的文件信息,保持已有的状态
|
||||
const existingFileInfo = currentFileList.find(info => info.name === file.name && info.size === file.size);
|
||||
return existingFileInfo || {
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready' as const,
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
setFileList(newFileInfos);
|
||||
}
|
||||
}, [selectedFiles, mode, pickupCode, fileList, generateFileId]);
|
||||
|
||||
// 检查文件列表是否真正发生变化
|
||||
const fileListChanged =
|
||||
newFileInfos.length !== currentFileList.length ||
|
||||
newFileInfos.some(newFile =>
|
||||
!currentFileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
|
||||
);
|
||||
|
||||
if (fileListChanged) {
|
||||
console.log('文件列表发生变化,更新:', {
|
||||
before: currentFileList.map(f => f.name),
|
||||
after: newFileInfos.map(f => f.name)
|
||||
});
|
||||
|
||||
return newFileInfos;
|
||||
}
|
||||
|
||||
// 如果没有变化,返回当前的fileList
|
||||
return currentFileList;
|
||||
});
|
||||
}, [selectedFiles, mode, pickupCode, generateFileId]); // 移除fileList依赖,避免无限循环
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
|
||||
@@ -15,5 +15,3 @@ export * from './text-transfer';
|
||||
// UI状态管理相关
|
||||
export * from './ui';
|
||||
|
||||
// 核心WebRTC功能
|
||||
export * from './webrtc';
|
||||
|
||||
3
chuan-next/src/hooks/settings/index.ts
Normal file
3
chuan-next/src/hooks/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './useIceServersConfig';
|
||||
export * from './useWebRTCConfigSync';
|
||||
export * from './useWebRTCConfigSync';
|
||||
231
chuan-next/src/hooks/settings/useIceServersConfig.ts
Normal file
231
chuan-next/src/hooks/settings/useIceServersConfig.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface IceServerConfig {
|
||||
id: string;
|
||||
urls: string;
|
||||
username?: string;
|
||||
credential?: string;
|
||||
type: 'stun' | 'turn';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ICE_SERVERS: IceServerConfig[] = [
|
||||
{
|
||||
id: 'google-stun-1',
|
||||
urls: 'stun:stun.l.google.com:19302',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'google-stun-2',
|
||||
urls: 'stun:stun1.l.google.com:19302',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'google-stun-3',
|
||||
urls: 'stun:stun2.l.google.com:19302',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'twilio-stun',
|
||||
urls: 'stun:global.stun.twilio.com:3478',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'webrtc-ice-servers-config';
|
||||
|
||||
export function useIceServersConfig() {
|
||||
const [iceServers, setIceServers] = useState<IceServerConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = useCallback(() => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedConfig) {
|
||||
const parsed = JSON.parse(savedConfig);
|
||||
setIceServers(parsed);
|
||||
} else {
|
||||
setIceServers(DEFAULT_ICE_SERVERS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载ICE服务器配置失败:', error);
|
||||
setIceServers(DEFAULT_ICE_SERVERS);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = useCallback((servers: IceServerConfig[]) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
|
||||
setIceServers(servers);
|
||||
} catch (error) {
|
||||
console.error('保存ICE服务器配置失败:', error);
|
||||
throw new Error('保存配置失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 添加服务器
|
||||
const addIceServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
|
||||
const newServer: IceServerConfig = {
|
||||
...config,
|
||||
id: `custom-${Date.now()}`,
|
||||
};
|
||||
const updatedServers = [...iceServers, newServer];
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
// 更新服务器
|
||||
const updateIceServer = useCallback((id: string, updates: Partial<IceServerConfig>) => {
|
||||
const updatedServers = iceServers.map(server =>
|
||||
server.id === id ? { ...server, ...updates } : server
|
||||
);
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
// 删除服务器
|
||||
const removeIceServer = useCallback((id: string) => {
|
||||
// 确保至少保留一个服务器
|
||||
if (iceServers.length <= 1) {
|
||||
throw new Error('至少需要保留一个ICE服务器');
|
||||
}
|
||||
const updatedServers = iceServers.filter(server => server.id !== id);
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
// 恢复默认配置
|
||||
const resetToDefault = useCallback(() => {
|
||||
saveConfig(DEFAULT_ICE_SERVERS);
|
||||
}, [saveConfig]);
|
||||
|
||||
// 获取WebRTC格式的配置
|
||||
const getWebRTCConfig = useCallback((): RTCIceServer[] => {
|
||||
return iceServers
|
||||
.filter(server => server.enabled)
|
||||
.map(server => {
|
||||
const rtcServer: RTCIceServer = {
|
||||
urls: server.urls,
|
||||
};
|
||||
|
||||
if (server.username) {
|
||||
rtcServer.username = server.username;
|
||||
}
|
||||
|
||||
if (server.credential) {
|
||||
rtcServer.credential = server.credential;
|
||||
}
|
||||
|
||||
return rtcServer;
|
||||
});
|
||||
}, [iceServers]);
|
||||
|
||||
// 验证服务器配置
|
||||
const validateServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.urls.trim()) {
|
||||
errors.push('服务器地址不能为空');
|
||||
} else {
|
||||
// 基本URL格式验证
|
||||
const urlPattern = /^(stun|turn|turns):.+/i;
|
||||
if (!urlPattern.test(config.urls)) {
|
||||
errors.push('服务器地址格式不正确(应以 stun: 或 turn: 开头)');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'turn') {
|
||||
if (!config.username?.trim()) {
|
||||
errors.push('TURN服务器需要用户名');
|
||||
}
|
||||
if (!config.credential?.trim()) {
|
||||
errors.push('TURN服务器需要密码');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, []);
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
return {
|
||||
iceServers,
|
||||
isLoading,
|
||||
addIceServer,
|
||||
updateIceServer,
|
||||
removeIceServer,
|
||||
resetToDefault,
|
||||
getWebRTCConfig,
|
||||
validateServer,
|
||||
saveConfig,
|
||||
};
|
||||
}
|
||||
|
||||
// 独立的函数,用于在非React组件中获取ICE服务器配置
|
||||
export function getIceServersConfig(): RTCIceServer[] {
|
||||
if (typeof window === 'undefined') {
|
||||
// 服务器端默认配置
|
||||
return [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) {
|
||||
// 返回默认配置的WebRTC格式
|
||||
return DEFAULT_ICE_SERVERS
|
||||
.filter(server => server.enabled)
|
||||
.map(server => {
|
||||
const rtcServer: RTCIceServer = {
|
||||
urls: server.urls,
|
||||
};
|
||||
|
||||
if (server.username) {
|
||||
rtcServer.username = server.username;
|
||||
}
|
||||
|
||||
if (server.credential) {
|
||||
rtcServer.credential = server.credential;
|
||||
}
|
||||
|
||||
return rtcServer;
|
||||
});
|
||||
}
|
||||
|
||||
const iceServers: IceServerConfig[] = JSON.parse(saved);
|
||||
return iceServers
|
||||
.filter(server => server.enabled)
|
||||
.map(server => {
|
||||
const rtcServer: RTCIceServer = {
|
||||
urls: server.urls,
|
||||
};
|
||||
|
||||
if (server.username) {
|
||||
rtcServer.username = server.username;
|
||||
}
|
||||
|
||||
if (server.credential) {
|
||||
rtcServer.credential = server.credential;
|
||||
}
|
||||
|
||||
return rtcServer;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取ICE服务器配置失败:', error);
|
||||
// 发生错误时返回默认配置
|
||||
return [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
}
|
||||
}
|
||||
29
chuan-next/src/hooks/settings/useWebRTCConfigSync.ts
Normal file
29
chuan-next/src/hooks/settings/useWebRTCConfigSync.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
export function useWebRTCConfigSync() {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 监听存储变化事件
|
||||
const handleStorageChange = useCallback((event: StorageEvent) => {
|
||||
if (event.key === 'webrtc-ice-servers-config') {
|
||||
showToast(
|
||||
'检测到WebRTC配置更改,请重新建立连接以应用新配置',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听localStorage变化
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [handleStorageChange]);
|
||||
|
||||
return {
|
||||
// 可以在这里添加其他配置同步相关的方法
|
||||
};
|
||||
}
|
||||
@@ -3,16 +3,18 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { useURLHandler, FeatureType } from './useURLHandler';
|
||||
import { useWebRTCStore } from './webRTCStore';
|
||||
import { useConfirmDialog } from './useConfirmDialog';
|
||||
import { useSharedWebRTCManager } from '../connection/useSharedWebRTCManager';
|
||||
|
||||
// Tab类型定义(包括非WebRTC功能)
|
||||
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat';
|
||||
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat' | 'settings';
|
||||
|
||||
// Tab显示名称
|
||||
const TAB_NAMES: Record<TabType, string> = {
|
||||
webrtc: '文件传输',
|
||||
message: '文字传输',
|
||||
desktop: '桌面共享',
|
||||
wechat: '微信群'
|
||||
wechat: '微信群',
|
||||
settings: '设置'
|
||||
};
|
||||
|
||||
// WebRTC功能的映射
|
||||
@@ -34,9 +36,11 @@ export const useTabNavigation = () => {
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom,
|
||||
reset: resetWebRTCState
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 获取WebRTC连接管理器
|
||||
const { disconnect: disconnectWebRTC } = useSharedWebRTCManager();
|
||||
|
||||
// 创建一个通用的URL处理器(用于断开连接)
|
||||
const { hasActiveConnection } = useURLHandler({
|
||||
featureType: 'webrtc', // 默认值,实际使用时会被覆盖
|
||||
@@ -76,28 +80,36 @@ export const useTabNavigation = () => {
|
||||
console.log('=== Tab切换 ===');
|
||||
console.log('当前tab:', activeTab, '目标tab:', newTab);
|
||||
|
||||
// 如果切换到wechat tab(非WebRTC功能),可以直接切换
|
||||
if (newTab === 'wechat') {
|
||||
// 如果有活跃连接,需要确认
|
||||
if (hasActiveConnection()) {
|
||||
const currentTabName = TAB_NAMES[activeTab];
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到微信群功能需要关闭当前的${currentTabName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 断开连接并清除状态
|
||||
resetWebRTCState();
|
||||
console.log('已清除WebRTC连接状态,切换到微信群');
|
||||
// 对于任何非WebRTC功能的tab(wechat、settings),如果有活跃连接需要确认
|
||||
if ((newTab === 'wechat' || newTab === 'settings') && hasActiveConnection()) {
|
||||
const currentTabName = TAB_NAMES[activeTab];
|
||||
const targetTabName = TAB_NAMES[newTab];
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到${targetTabName}需要断开当前的${currentTabName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 断开连接并清除状态
|
||||
disconnectWebRTC();
|
||||
console.log(`已清除WebRTC连接状态,切换到${targetTabName}`);
|
||||
|
||||
setActiveTab(newTab);
|
||||
// 清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果切换到非活跃连接的wechat或settings tab,直接切换
|
||||
if (newTab === 'wechat' || newTab === 'settings') {
|
||||
setActiveTab(newTab);
|
||||
// 清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
@@ -124,7 +136,7 @@ export const useTabNavigation = () => {
|
||||
}
|
||||
|
||||
// 用户确认后,重置WebRTC状态
|
||||
resetWebRTCState();
|
||||
disconnectWebRTC();
|
||||
console.log(`已断开${currentTabName}连接,切换到${targetTabName}`);
|
||||
}
|
||||
|
||||
@@ -146,7 +158,7 @@ export const useTabNavigation = () => {
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeTab, hasActiveConnection, resetWebRTCState]);
|
||||
}, [activeTab, hasActiveConnection, disconnectWebRTC]);
|
||||
|
||||
// 获取连接状态信息
|
||||
const getConnectionInfo = useCallback(() => {
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler, MessageHandler, DataHandler } from './types';
|
||||
|
||||
interface DataChannelManagerConfig {
|
||||
channelName: string;
|
||||
onMessage?: MessageHandler;
|
||||
onData?: DataHandler;
|
||||
ordered?: boolean;
|
||||
maxRetransmits?: number;
|
||||
}
|
||||
|
||||
export class DataChannelManager extends EventEmitter {
|
||||
private dataChannel: RTCDataChannel | null = null;
|
||||
private config: DataChannelManagerConfig;
|
||||
private messageQueue: WebRTCMessage[] = [];
|
||||
private dataQueue: ArrayBuffer[] = [];
|
||||
private isReady = false;
|
||||
|
||||
constructor(config: DataChannelManagerConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
ordered: true,
|
||||
maxRetransmits: 3,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
initializeDataChannel(dataChannel: RTCDataChannel): void {
|
||||
this.dataChannel = dataChannel;
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
if (this.dataChannel) {
|
||||
throw new WebRTCError('DC_ALREADY_EXISTS', '数据通道已存在', false);
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataChannel = pc.createDataChannel(this.config.channelName, {
|
||||
ordered: this.config.ordered,
|
||||
maxRetransmits: this.config.maxRetransmits,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
return this.dataChannel;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('DC_CREATE_FAILED', '创建数据通道失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.dataChannel) return;
|
||||
|
||||
this.dataChannel.onopen = () => {
|
||||
console.log(`[DataChannel] 数据通道已打开: ${this.config.channelName}`);
|
||||
this.isReady = true;
|
||||
this.flushQueues();
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
|
||||
};
|
||||
|
||||
this.dataChannel.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebRTCMessage;
|
||||
console.log(`[DataChannel] 收到消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
|
||||
|
||||
if (this.config.onMessage) {
|
||||
this.config.onMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DataChannel] 解析消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
|
||||
});
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log(`[DataChannel] 收到数据: ${event.data.byteLength} bytes`);
|
||||
|
||||
if (this.config.onData) {
|
||||
this.config.onData(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.dataChannel.onerror = (error) => {
|
||||
console.error(`[DataChannel] 数据通道错误: ${this.config.channelName}`, error);
|
||||
|
||||
const errorMessage = this.getDetailedErrorMessage();
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_ERROR', errorMessage, true)
|
||||
});
|
||||
};
|
||||
|
||||
this.dataChannel.onclose = () => {
|
||||
console.log(`[DataChannel] 数据通道已关闭: ${this.config.channelName}`);
|
||||
this.isReady = false;
|
||||
this.emit('disconnected', {
|
||||
type: 'disconnected',
|
||||
reason: `数据通道关闭: ${this.config.channelName}`
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private getDetailedErrorMessage(): string {
|
||||
if (!this.dataChannel) return '数据通道不可用';
|
||||
|
||||
switch (this.dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
return '数据通道正在连接中,请稍候...';
|
||||
case 'closing':
|
||||
return '数据通道正在关闭,连接即将断开';
|
||||
case 'closed':
|
||||
return '数据通道已关闭,P2P连接失败';
|
||||
default:
|
||||
return '数据通道连接失败,可能是网络环境受限';
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(message: WebRTCMessage): boolean {
|
||||
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
|
||||
this.messageQueue.push(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataChannel.send(JSON.stringify(message));
|
||||
console.log(`[DataChannel] 发送消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannel] 发送消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_SEND_ERROR', '发送消息失败', true, error as Error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sendData(data: ArrayBuffer): boolean {
|
||||
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
|
||||
this.dataQueue.push(data);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataChannel.send(data);
|
||||
console.log(`[DataChannel] 发送数据: ${data.byteLength} bytes`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannel] 发送数据失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('DC_SEND_DATA_ERROR', '发送数据失败', true, error as Error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueues(): void {
|
||||
// 发送排队的消息
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送排队的数据
|
||||
while (this.dataQueue.length > 0) {
|
||||
const data = this.dataQueue.shift();
|
||||
if (data) {
|
||||
this.sendData(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getState(): RTCDataChannelState {
|
||||
return this.dataChannel?.readyState || 'closed';
|
||||
}
|
||||
|
||||
isChannelReady(): boolean {
|
||||
return this.isReady && this.dataChannel?.readyState === 'open';
|
||||
}
|
||||
|
||||
getBufferedAmount(): number {
|
||||
return this.dataChannel?.bufferedAmount || 0;
|
||||
}
|
||||
|
||||
getBufferedAmountLowThreshold(): number {
|
||||
return this.dataChannel?.bufferedAmountLowThreshold || 0;
|
||||
}
|
||||
|
||||
setBufferedAmountLowThreshold(threshold: number): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.bufferedAmountLowThreshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
onBufferedAmountLow(handler: () => void): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.onbufferedamountlow = handler;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.close();
|
||||
this.dataChannel = null;
|
||||
}
|
||||
|
||||
this.isReady = false;
|
||||
this.messageQueue = [];
|
||||
this.dataQueue = [];
|
||||
|
||||
this.emit('disconnected', { type: 'disconnected', reason: '数据通道已关闭' });
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler<ConnectionEvent>): this {
|
||||
return super.on(event, handler);
|
||||
}
|
||||
|
||||
emit(eventName: string, event?: ConnectionEvent): boolean {
|
||||
return super.emit(eventName, event);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { WebRTCMessage, MessageHandler, DataHandler } from './types';
|
||||
|
||||
interface ChannelHandlers {
|
||||
messageHandlers: Set<MessageHandler>;
|
||||
dataHandlers: Set<DataHandler>;
|
||||
}
|
||||
|
||||
export class MessageRouter {
|
||||
private channels = new Map<string, ChannelHandlers>();
|
||||
private defaultChannelHandlers: ChannelHandlers | null = null;
|
||||
|
||||
constructor() {
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
private createDefaultChannel(): void {
|
||||
this.defaultChannelHandlers = {
|
||||
messageHandlers: new Set(),
|
||||
dataHandlers: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
|
||||
let channelHandlers = this.channels.get(channel);
|
||||
|
||||
if (!channelHandlers) {
|
||||
channelHandlers = {
|
||||
messageHandlers: new Set(),
|
||||
dataHandlers: new Set(),
|
||||
};
|
||||
this.channels.set(channel, channelHandlers);
|
||||
}
|
||||
|
||||
channelHandlers.messageHandlers.add(handler);
|
||||
|
||||
// 返回取消注册函数
|
||||
return () => {
|
||||
channelHandlers!.messageHandlers.delete(handler);
|
||||
|
||||
// 如果通道没有处理器了,删除通道
|
||||
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerDataHandler(channel: string, handler: DataHandler): () => void {
|
||||
let channelHandlers = this.channels.get(channel);
|
||||
|
||||
if (!channelHandlers) {
|
||||
channelHandlers = {
|
||||
messageHandlers: new Set(),
|
||||
dataHandlers: new Set(),
|
||||
};
|
||||
this.channels.set(channel, channelHandlers);
|
||||
}
|
||||
|
||||
channelHandlers.dataHandlers.add(handler);
|
||||
|
||||
// 返回取消注册函数
|
||||
return () => {
|
||||
channelHandlers!.dataHandlers.delete(handler);
|
||||
|
||||
// 如果通道没有处理器了,删除通道
|
||||
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerDefaultMessageHandler(handler: MessageHandler): () => void {
|
||||
if (!this.defaultChannelHandlers) {
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
this.defaultChannelHandlers?.messageHandlers.add(handler);
|
||||
|
||||
return () => {
|
||||
this.defaultChannelHandlers?.messageHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
registerDefaultDataHandler(handler: DataHandler): () => void {
|
||||
if (!this.defaultChannelHandlers) {
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
this.defaultChannelHandlers?.dataHandlers.add(handler);
|
||||
|
||||
return () => {
|
||||
this.defaultChannelHandlers?.dataHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
routeMessage(message: WebRTCMessage): void {
|
||||
const channel = message.channel;
|
||||
|
||||
if (channel) {
|
||||
// 路由到特定通道
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
if (channelHandlers && channelHandlers.messageHandlers.size > 0) {
|
||||
channelHandlers.messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error(`消息处理器错误 (通道: ${channel}):`, error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到默认处理器
|
||||
if (this.defaultChannelHandlers?.messageHandlers.size) {
|
||||
this.defaultChannelHandlers.messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error('默认消息处理器错误:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('没有找到消息处理器:', message.type, channel || 'default');
|
||||
}
|
||||
}
|
||||
|
||||
routeData(data: ArrayBuffer, channel?: string): void {
|
||||
if (channel) {
|
||||
// 路由到特定通道
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
if (channelHandlers && channelHandlers.dataHandlers.size > 0) {
|
||||
channelHandlers.dataHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`数据处理器错误 (通道: ${channel}):`, error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到默认处理器
|
||||
if (this.defaultChannelHandlers?.dataHandlers.size) {
|
||||
this.defaultChannelHandlers.dataHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error('默认数据处理器错误:', error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('没有找到数据处理器,数据大小:', data.byteLength, 'bytes');
|
||||
}
|
||||
}
|
||||
|
||||
hasHandlers(channel?: string): boolean {
|
||||
if (channel) {
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
return channelHandlers ?
|
||||
(channelHandlers.messageHandlers.size > 0 || channelHandlers.dataHandlers.size > 0) :
|
||||
false;
|
||||
}
|
||||
|
||||
return this.defaultChannelHandlers ?
|
||||
(this.defaultChannelHandlers.messageHandlers.size > 0 || this.defaultChannelHandlers.dataHandlers.size > 0) :
|
||||
false;
|
||||
}
|
||||
|
||||
getChannelList(): string[] {
|
||||
return Array.from(this.channels.keys());
|
||||
}
|
||||
|
||||
getHandlerCount(channel?: string): { message: number; data: number } {
|
||||
if (channel) {
|
||||
const channelHandlers = this.channels.get(channel);
|
||||
return channelHandlers ? {
|
||||
message: channelHandlers.messageHandlers.size,
|
||||
data: channelHandlers.dataHandlers.size,
|
||||
} : { message: 0, data: 0 };
|
||||
}
|
||||
|
||||
return this.defaultChannelHandlers ? {
|
||||
message: this.defaultChannelHandlers.messageHandlers.size,
|
||||
data: this.defaultChannelHandlers.dataHandlers.size,
|
||||
} : { message: 0, data: 0 };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.channels.clear();
|
||||
this.createDefaultChannel();
|
||||
}
|
||||
|
||||
clearChannel(channel: string): void {
|
||||
this.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebRTCError, WebRTCConfig, ConnectionEvent, EventHandler } from './types';
|
||||
|
||||
interface PeerConnectionManagerConfig extends WebRTCConfig {
|
||||
onSignalingMessage: (message: any) => void;
|
||||
onTrack?: (event: RTCTrackEvent) => void;
|
||||
}
|
||||
|
||||
interface NegotiationOptions {
|
||||
offerToReceiveAudio?: boolean;
|
||||
offerToReceiveVideo?: boolean;
|
||||
}
|
||||
|
||||
export class PeerConnectionManager extends EventEmitter {
|
||||
private pc: RTCPeerConnection | null = null;
|
||||
private config: PeerConnectionManagerConfig;
|
||||
private isNegotiating = false;
|
||||
private negotiationQueue: Array<() => Promise<void>> = [];
|
||||
private localCandidates: RTCIceCandidate[] = [];
|
||||
private remoteCandidates: RTCIceCandidate[] = [];
|
||||
|
||||
constructor(config: PeerConnectionManagerConfig) {
|
||||
super();
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async createPeerConnection(): Promise<RTCPeerConnection> {
|
||||
if (this.pc) {
|
||||
this.destroyPeerConnection();
|
||||
}
|
||||
|
||||
try {
|
||||
this.pc = new RTCPeerConnection({
|
||||
iceServers: this.config.iceServers,
|
||||
iceCandidatePoolSize: this.config.iceCandidatePoolSize,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
|
||||
return this.pc;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('PC_CREATE_FAILED', '创建PeerConnection失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.pc) return;
|
||||
|
||||
this.pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.localCandidates.push(event.candidate);
|
||||
this.config.onSignalingMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
} else {
|
||||
console.log('[PeerConnection] ICE收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.oniceconnectionstatechange = () => {
|
||||
console.log('[PeerConnection] ICE连接状态:', this.pc!.iceConnectionState);
|
||||
|
||||
switch (this.pc!.iceConnectionState) {
|
||||
case 'connected':
|
||||
case 'completed':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
|
||||
break;
|
||||
case 'failed':
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('ICE_FAILED', 'ICE连接失败', true)
|
||||
});
|
||||
break;
|
||||
case 'disconnected':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onconnectionstatechange = () => {
|
||||
console.log('[PeerConnection] 连接状态:', this.pc!.connectionState);
|
||||
|
||||
switch (this.pc!.connectionState) {
|
||||
case 'connected':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
|
||||
break;
|
||||
case 'failed':
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('CONNECTION_FAILED', 'WebRTC连接失败', true)
|
||||
});
|
||||
break;
|
||||
case 'disconnected':
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.ontrack = (event) => {
|
||||
console.log('[PeerConnection] 收到轨道:', event.track.kind);
|
||||
if (this.config.onTrack) {
|
||||
this.config.onTrack(event);
|
||||
}
|
||||
};
|
||||
|
||||
this.pc.onsignalingstatechange = () => {
|
||||
console.log('[PeerConnection] 信令状态:', this.pc!.signalingState);
|
||||
if (this.pc!.signalingState === 'stable') {
|
||||
this.isNegotiating = false;
|
||||
this.processNegotiationQueue();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async createOffer(options: NegotiationOptions = {}): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
const offerOptions: RTCOfferOptions = {
|
||||
offerToReceiveAudio: options.offerToReceiveAudio ?? true,
|
||||
offerToReceiveVideo: options.offerToReceiveVideo ?? true,
|
||||
};
|
||||
|
||||
const offer = await this.pc.createOffer(offerOptions);
|
||||
await this.pc.setLocalDescription(offer);
|
||||
|
||||
return offer;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('OFFER_FAILED', '创建Offer失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
const answer = await this.pc.createAnswer();
|
||||
await this.pc.setLocalDescription(answer);
|
||||
|
||||
return answer;
|
||||
} catch (error) {
|
||||
throw new WebRTCError('ANSWER_FAILED', '创建Answer失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pc.setRemoteDescription(description);
|
||||
|
||||
// 添加缓存的远程候选
|
||||
for (const candidate of this.remoteCandidates) {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
}
|
||||
this.remoteCandidates = [];
|
||||
} catch (error) {
|
||||
throw new WebRTCError('REMOTE_DESC_FAILED', '设置远程描述失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (!this.pc) {
|
||||
this.remoteCandidates.push(new RTCIceCandidate(candidate));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
} catch (error) {
|
||||
console.warn('添加ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
return this.pc.addTrack(track, stream);
|
||||
} catch (error) {
|
||||
throw new WebRTCError('ADD_TRACK_FAILED', '添加轨道失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
removeTrack(sender: RTCRtpSender): void {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
try {
|
||||
this.pc.removeTrack(sender);
|
||||
} catch (error) {
|
||||
throw new WebRTCError('REMOVE_TRACK_FAILED', '移除轨道失败', false, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
return this.pc.createDataChannel(label, options);
|
||||
}
|
||||
|
||||
async renegotiate(options: NegotiationOptions = {}): Promise<void> {
|
||||
if (!this.pc || this.isNegotiating) {
|
||||
this.negotiationQueue.push(() => this.doRenegotiate(options));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.doRenegotiate(options);
|
||||
}
|
||||
|
||||
private async doRenegotiate(options: NegotiationOptions): Promise<void> {
|
||||
if (!this.pc || this.isNegotiating) return;
|
||||
|
||||
this.isNegotiating = true;
|
||||
|
||||
try {
|
||||
const offer = await this.createOffer(options);
|
||||
this.config.onSignalingMessage({
|
||||
type: 'offer',
|
||||
payload: offer
|
||||
});
|
||||
} catch (error) {
|
||||
this.isNegotiating = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private processNegotiationQueue(): void {
|
||||
if (this.negotiationQueue.length === 0) return;
|
||||
|
||||
const nextNegotiation = this.negotiationQueue.shift();
|
||||
if (nextNegotiation) {
|
||||
nextNegotiation().catch(error => {
|
||||
console.error('处理协商队列失败:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): Promise<RTCStatsReport> {
|
||||
if (!this.pc) {
|
||||
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
|
||||
}
|
||||
|
||||
return this.pc.getStats();
|
||||
}
|
||||
|
||||
getConnectionState(): RTCPeerConnectionState {
|
||||
return this.pc?.connectionState || 'closed';
|
||||
}
|
||||
|
||||
getIceConnectionState(): RTCIceConnectionState {
|
||||
return this.pc?.iceConnectionState || 'closed';
|
||||
}
|
||||
|
||||
getSignalingState(): RTCSignalingState {
|
||||
return this.pc?.signalingState || 'stable';
|
||||
}
|
||||
|
||||
destroyPeerConnection(): void {
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
this.isNegotiating = false;
|
||||
this.negotiationQueue = [];
|
||||
this.localCandidates = [];
|
||||
this.remoteCandidates = [];
|
||||
|
||||
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler<ConnectionEvent>): this {
|
||||
return super.on(event, handler);
|
||||
}
|
||||
|
||||
emit(eventName: string, event?: ConnectionEvent): boolean {
|
||||
return super.emit(eventName, event);
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocketManager } from './WebSocketManager';
|
||||
import { PeerConnectionManager } from './PeerConnectionManager';
|
||||
import { DataChannelManager } from './DataChannelManager';
|
||||
import { MessageRouter } from './MessageRouter';
|
||||
import {
|
||||
WebRTCConnectionState,
|
||||
WebRTCMessage,
|
||||
WebRTCConfig,
|
||||
WebRTCError,
|
||||
MessageHandler,
|
||||
DataHandler
|
||||
} from './types';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
|
||||
interface WebRTCManagerConfig extends Partial<WebRTCConfig> {
|
||||
dataChannelName?: string;
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
interface SignalingMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export class WebRTCManager extends EventEmitter {
|
||||
private wsManager: WebSocketManager;
|
||||
private pcManager: PeerConnectionManager;
|
||||
private dcManager: DataChannelManager;
|
||||
private messageRouter: MessageRouter;
|
||||
private config: WebRTCManagerConfig;
|
||||
|
||||
private state: WebRTCConnectionState;
|
||||
private currentRoom: { code: string; role: 'sender' | 'receiver' } | null = null;
|
||||
private isUserDisconnecting = false;
|
||||
private abortController = new AbortController();
|
||||
|
||||
constructor(config: WebRTCManagerConfig = {}) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:global.stun.twilio.com:3478' },
|
||||
],
|
||||
iceCandidatePoolSize: 10,
|
||||
chunkSize: 256 * 1024,
|
||||
maxRetries: 5,
|
||||
retryDelay: 1000,
|
||||
ackTimeout: 5000,
|
||||
dataChannelName: 'shared-channel',
|
||||
enableLogging: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.state = {
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false,
|
||||
currentRoom: null,
|
||||
};
|
||||
|
||||
// 初始化各个管理器
|
||||
this.wsManager = new WebSocketManager({
|
||||
url: '',
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.pcManager = new PeerConnectionManager({
|
||||
iceServers: this.config.iceServers!,
|
||||
iceCandidatePoolSize: this.config.iceCandidatePoolSize!,
|
||||
chunkSize: this.config.chunkSize!,
|
||||
maxRetries: this.config.maxRetries!,
|
||||
retryDelay: this.config.retryDelay!,
|
||||
ackTimeout: this.config.ackTimeout!,
|
||||
onSignalingMessage: this.handleSignalingMessage.bind(this),
|
||||
onTrack: this.handleTrack.bind(this),
|
||||
});
|
||||
|
||||
this.dcManager = new DataChannelManager({
|
||||
channelName: this.config.dataChannelName!,
|
||||
onMessage: this.handleDataChannelMessage.bind(this),
|
||||
onData: this.handleDataChannelData.bind(this),
|
||||
});
|
||||
|
||||
this.messageRouter = new MessageRouter();
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// WebSocket 事件处理
|
||||
this.wsManager.on('connecting', (event) => {
|
||||
this.updateState({ isConnecting: true, error: null });
|
||||
});
|
||||
|
||||
this.wsManager.on('connected', (event) => {
|
||||
this.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnecting: false,
|
||||
isConnected: true
|
||||
});
|
||||
});
|
||||
|
||||
this.wsManager.on('disconnected', (event: any) => {
|
||||
this.updateState({ isWebSocketConnected: false });
|
||||
|
||||
if (!this.isUserDisconnecting) {
|
||||
this.updateState({
|
||||
error: event.reason,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.wsManager.on('error', (event: any) => {
|
||||
this.updateState({
|
||||
error: event.error.message,
|
||||
canRetry: event.error.retryable
|
||||
});
|
||||
});
|
||||
|
||||
this.wsManager.on('message', (message: any) => {
|
||||
this.handleWebSocketMessage(message);
|
||||
});
|
||||
|
||||
// PeerConnection 事件处理
|
||||
this.pcManager.on('state-change', (event: any) => {
|
||||
this.updateState(event.state);
|
||||
});
|
||||
|
||||
this.pcManager.on('error', (event: any) => {
|
||||
this.updateState({
|
||||
error: event.error.message,
|
||||
canRetry: event.error.retryable
|
||||
});
|
||||
});
|
||||
|
||||
// DataChannel 事件处理
|
||||
this.dcManager.on('state-change', (event: any) => {
|
||||
this.updateState(event.state);
|
||||
});
|
||||
|
||||
this.dcManager.on('error', (event: any) => {
|
||||
this.updateState({
|
||||
error: event.error.message,
|
||||
canRetry: event.error.retryable
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(updates: Partial<WebRTCConnectionState>): void {
|
||||
this.state = { ...this.state, ...updates };
|
||||
this.emit('state-change', { type: 'state-change', state: updates });
|
||||
}
|
||||
|
||||
private handleWebSocketMessage(message: SignalingMessage): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 收到信令消息:', message.type);
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'peer-joined':
|
||||
this.handlePeerJoined(message.payload);
|
||||
break;
|
||||
case 'offer':
|
||||
this.handleOffer(message.payload);
|
||||
break;
|
||||
case 'answer':
|
||||
this.handleAnswer(message.payload);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
this.handleIceCandidate(message.payload);
|
||||
break;
|
||||
case 'error':
|
||||
this.handleError(message);
|
||||
break;
|
||||
case 'disconnection':
|
||||
this.handleDisconnection(message);
|
||||
break;
|
||||
default:
|
||||
if (this.config.enableLogging) {
|
||||
console.warn('[WebRTCManager] 未知消息类型:', message.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: SignalingMessage): void {
|
||||
this.wsManager.send(message);
|
||||
}
|
||||
|
||||
private handleTrack(event: RTCTrackEvent): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 收到媒体轨道:', event.track.kind);
|
||||
}
|
||||
// 这里可以添加轨道处理逻辑,或者通过事件传递给业务层
|
||||
}
|
||||
|
||||
private handleDataChannelMessage(message: WebRTCMessage): void {
|
||||
this.messageRouter.routeMessage(message);
|
||||
}
|
||||
|
||||
private handleDataChannelData(data: ArrayBuffer): void {
|
||||
// 默认路由到文件传输通道
|
||||
this.messageRouter.routeData(data, 'file-transfer');
|
||||
}
|
||||
|
||||
private async handlePeerJoined(payload: any): Promise<void> {
|
||||
if (!this.currentRoom) return;
|
||||
|
||||
const { role } = payload;
|
||||
const { role: currentRole } = this.currentRoom;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 对方加入房间:', role);
|
||||
}
|
||||
|
||||
if (currentRole === 'sender' && role === 'receiver') {
|
||||
this.updateState({ isPeerConnected: true });
|
||||
try {
|
||||
await this.pcManager.createOffer();
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 创建Offer失败:', error);
|
||||
}
|
||||
} else if (currentRole === 'receiver' && role === 'sender') {
|
||||
this.updateState({ isPeerConnected: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOffer(payload: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
await this.pcManager.setRemoteDescription(payload);
|
||||
const answer = await this.pcManager.createAnswer();
|
||||
this.handleSignalingMessage({ type: 'answer', payload: answer });
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 处理Offer失败:', error);
|
||||
this.updateState({
|
||||
error: '处理连接请求失败',
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnswer(payload: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
await this.pcManager.setRemoteDescription(payload);
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 处理Answer失败:', error);
|
||||
this.updateState({
|
||||
error: '处理连接响应失败',
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIceCandidate(payload: RTCIceCandidateInit): Promise<void> {
|
||||
try {
|
||||
await this.pcManager.addIceCandidate(payload);
|
||||
} catch (error) {
|
||||
console.warn('[WebRTCManager] 添加ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(message: any): void {
|
||||
this.updateState({
|
||||
error: message.error || '信令服务器错误',
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
|
||||
private handleDisconnection(message: any): void {
|
||||
this.updateState({
|
||||
isPeerConnected: false,
|
||||
error: '对方已离开房间',
|
||||
canRetry: true
|
||||
});
|
||||
|
||||
// 清理P2P连接但保持WebSocket连接
|
||||
this.pcManager.destroyPeerConnection();
|
||||
this.dcManager.close();
|
||||
}
|
||||
|
||||
async connect(roomCode: string, role: 'sender' | 'receiver'): Promise<void> {
|
||||
if (this.state.isConnecting) {
|
||||
console.warn('[WebRTCManager] 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUserDisconnecting = false;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
this.currentRoom = { code: roomCode, role };
|
||||
this.updateState({
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
currentRoom: { code: roomCode, role }
|
||||
});
|
||||
|
||||
// 创建PeerConnection
|
||||
const pc = await this.pcManager.createPeerConnection();
|
||||
|
||||
// 如果是发送方,创建数据通道
|
||||
if (role === 'sender') {
|
||||
this.dcManager.createDataChannel(pc);
|
||||
}
|
||||
|
||||
// 连接WebSocket
|
||||
const baseWsUrl = getWsUrl();
|
||||
if (!baseWsUrl) {
|
||||
throw new WebRTCError('WS_URL_NOT_CONFIGURED', 'WebSocket URL未配置', false);
|
||||
}
|
||||
|
||||
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
|
||||
this.wsManager = new WebSocketManager({
|
||||
url: wsUrl,
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
await this.wsManager.connect();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 连接失败:', error);
|
||||
this.updateState({
|
||||
error: error instanceof WebRTCError ? error.message : '连接失败',
|
||||
isConnecting: false,
|
||||
canRetry: true
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 主动断开连接');
|
||||
}
|
||||
|
||||
this.isUserDisconnecting = true;
|
||||
this.abortController.abort();
|
||||
|
||||
// 通知对方断开连接
|
||||
this.wsManager.send({
|
||||
type: 'disconnection',
|
||||
payload: { reason: '用户主动断开' }
|
||||
});
|
||||
|
||||
// 清理所有连接
|
||||
this.dcManager.close();
|
||||
this.pcManager.destroyPeerConnection();
|
||||
this.wsManager.disconnect();
|
||||
this.messageRouter.clear();
|
||||
|
||||
// 重置状态
|
||||
this.currentRoom = null;
|
||||
this.updateState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
canRetry: false,
|
||||
currentRoom: null,
|
||||
});
|
||||
}
|
||||
|
||||
async retry(): Promise<void> {
|
||||
if (!this.currentRoom) {
|
||||
throw new WebRTCError('NO_ROOM_INFO', '没有房间信息,无法重试', false);
|
||||
}
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebRTCManager] 重试连接:', this.currentRoom);
|
||||
}
|
||||
|
||||
this.disconnect();
|
||||
await this.connect(this.currentRoom.code, this.currentRoom.role);
|
||||
}
|
||||
|
||||
sendMessage(message: WebRTCMessage, channel?: string): boolean {
|
||||
const messageWithChannel = channel ? { ...message, channel } : message;
|
||||
return this.dcManager.sendMessage(messageWithChannel);
|
||||
}
|
||||
|
||||
sendData(data: ArrayBuffer): boolean {
|
||||
return this.dcManager.sendData(data);
|
||||
}
|
||||
|
||||
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
|
||||
return this.messageRouter.registerMessageHandler(channel, handler);
|
||||
}
|
||||
|
||||
registerDataHandler(channel: string, handler: DataHandler): () => void {
|
||||
return this.messageRouter.registerDataHandler(channel, handler);
|
||||
}
|
||||
|
||||
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
|
||||
return this.pcManager.addTrack(track, stream);
|
||||
}
|
||||
|
||||
removeTrack(sender: RTCRtpSender): void {
|
||||
this.pcManager.removeTrack(sender);
|
||||
}
|
||||
|
||||
onTrack(handler: (event: RTCTrackEvent) => void): void {
|
||||
// 简化实现,直接设置处理器
|
||||
this.pcManager.on('track', (event) => {
|
||||
if (event.type === 'state-change' && 'onTrack' in this.config) {
|
||||
// 这里需要适配事件类型
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPeerConnection(): RTCPeerConnection | null {
|
||||
// 返回内部 PeerConnection 的引用
|
||||
return (this.pcManager as any).pc;
|
||||
}
|
||||
|
||||
async createOfferNow(): Promise<boolean> {
|
||||
try {
|
||||
await this.pcManager.createOffer();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[WebRTCManager] 创建Offer失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getChannelState(): RTCDataChannelState {
|
||||
return this.dcManager.getState();
|
||||
}
|
||||
|
||||
isConnectedToRoom(roomCode: string, role: 'sender' | 'receiver'): boolean {
|
||||
return this.currentRoom?.code === roomCode &&
|
||||
this.currentRoom?.role === role &&
|
||||
this.state.isConnected;
|
||||
}
|
||||
|
||||
getState(): WebRTCConnectionState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
getConfig(): WebRTCManagerConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler } from './types';
|
||||
|
||||
interface WebSocketManagerConfig {
|
||||
url: string;
|
||||
reconnectAttempts?: number;
|
||||
reconnectDelay?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export class WebSocketManager extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: WebSocketManagerConfig;
|
||||
private reconnectCount = 0;
|
||||
private isConnecting = false;
|
||||
private isUserDisconnecting = false;
|
||||
private messageQueue: WebSocketMessage[] = [];
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(config: WebSocketManagerConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
timeout: 10000,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.isConnecting || this.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
this.isUserDisconnecting = false;
|
||||
this.emit('connecting', { type: 'connecting' });
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(this.config.url);
|
||||
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
throw new WebRTCError('WS_TIMEOUT', 'WebSocket连接超时', true);
|
||||
}
|
||||
}, this.config.timeout);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnecting = false;
|
||||
this.reconnectCount = 0;
|
||||
this.ws = ws;
|
||||
this.setupEventHandlers();
|
||||
this.flushMessageQueue();
|
||||
this.emit('connected', { type: 'connected' });
|
||||
resolve();
|
||||
};
|
||||
|
||||
ws.onerror = (errorEvent) => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnecting = false;
|
||||
const wsError = new WebRTCError('WS_ERROR', 'WebSocket连接错误', true, new Error('WebSocket连接错误'));
|
||||
this.emit('error', { type: 'error', error: wsError });
|
||||
reject(wsError);
|
||||
};
|
||||
|
||||
ws.onclose = (closeEvent) => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
|
||||
if (!this.isUserDisconnecting) {
|
||||
this.handleReconnect();
|
||||
}
|
||||
|
||||
this.emit('disconnected', {
|
||||
type: 'disconnected',
|
||||
reason: `WebSocket关闭: ${closeEvent.code} - ${closeEvent.reason}`
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
if (error instanceof WebRTCError) {
|
||||
this.emit('error', { type: 'error', error });
|
||||
throw error;
|
||||
}
|
||||
throw new WebRTCError('WS_CONNECTION_FAILED', 'WebSocket连接失败', true, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.ws) return;
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.emit('message', message);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (errorEvent) => {
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_ERROR', 'WebSocket错误', true, new Error('WebSocket错误'))
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.ws = null;
|
||||
if (!this.isUserDisconnecting) {
|
||||
this.handleReconnect();
|
||||
}
|
||||
this.emit('disconnected', {
|
||||
type: 'disconnected',
|
||||
reason: `WebSocket关闭: ${event.code} - ${event.reason}`
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private handleReconnect(): void {
|
||||
if (this.reconnectCount >= this.config.reconnectAttempts!) {
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_RECONNECT_FAILED', '重连失败,已达最大重试次数', false)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.config.reconnectDelay! * Math.pow(2, this.reconnectCount);
|
||||
this.reconnectCount++;
|
||||
|
||||
console.log(`WebSocket重连中... (${this.reconnectCount}/${this.config.reconnectAttempts}),延迟: ${delay}ms`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.emit('retry', { type: 'retry' });
|
||||
this.connect().catch(error => {
|
||||
console.error('WebSocket重连失败:', error);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
send(message: WebSocketMessage): boolean {
|
||||
if (!this.isConnected()) {
|
||||
this.messageQueue.push(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws!.send(JSON.stringify(message));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('发送WebSocket消息失败:', error);
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: new WebRTCError('WS_SEND_ERROR', '发送消息失败', true, error as Error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private flushMessageQueue(): void {
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.isUserDisconnecting = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, '用户主动断开');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.messageQueue = [];
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
isConnectingState(): boolean {
|
||||
return this.isConnecting;
|
||||
}
|
||||
|
||||
on(event: string, handler: EventHandler<ConnectionEvent>): this {
|
||||
return super.on(event, handler);
|
||||
}
|
||||
|
||||
emit(eventName: string, event?: ConnectionEvent): boolean {
|
||||
return super.emit(eventName, event);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// WebRTC核心功能
|
||||
export * from './DataChannelManager';
|
||||
export * from './MessageRouter';
|
||||
export * from './PeerConnectionManager';
|
||||
export * from './WebRTCManager';
|
||||
export * from './WebSocketManager';
|
||||
export * from './types';
|
||||
@@ -1,58 +0,0 @@
|
||||
// WebRTC 核心类型定义
|
||||
|
||||
// 基础连接状态
|
||||
export interface WebRTCConnectionState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
error: string | null;
|
||||
canRetry: boolean;
|
||||
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
export interface WebRTCMessage<T = any> {
|
||||
type: string;
|
||||
payload: T;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
// 消息处理器类型
|
||||
export type MessageHandler = (message: WebRTCMessage) => void;
|
||||
export type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
// WebRTC 配置
|
||||
export interface WebRTCConfig {
|
||||
iceServers: RTCIceServer[];
|
||||
iceCandidatePoolSize: number;
|
||||
chunkSize: number;
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
ackTimeout: number;
|
||||
}
|
||||
|
||||
// 错误类型
|
||||
export class WebRTCError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public retryable: boolean = false,
|
||||
public cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'WebRTCError';
|
||||
}
|
||||
}
|
||||
|
||||
// 连接事件
|
||||
export type ConnectionEvent =
|
||||
| { type: 'connecting' }
|
||||
| { type: 'connected' }
|
||||
| { type: 'disconnected'; reason?: string }
|
||||
| { type: 'error'; error: WebRTCError }
|
||||
| { type: 'retry' }
|
||||
| { type: 'state-change'; state: Partial<WebRTCConnectionState> };
|
||||
|
||||
// 事件处理器
|
||||
export type EventHandler<T extends ConnectionEvent> = (event: T) => void;
|
||||
@@ -33,12 +33,12 @@ const getCurrentWsUrl = () => {
|
||||
|
||||
if (isNextDevServer) {
|
||||
// 开发模式:通过 Next.js 开发服务器访问,连接到后端 WebSocket
|
||||
return 'ws://localhost:8080/ws/p2p';
|
||||
return 'ws://localhost:8080';
|
||||
}
|
||||
|
||||
// 生产模式或通过 Go 服务器访问:使用当前域名和端口
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws/p2p`;
|
||||
return `${protocol}//${window.location.host}`;
|
||||
}
|
||||
// 服务器端返回空字符串,强制在客户端计算
|
||||
return '';
|
||||
|
||||
@@ -43,6 +43,7 @@ export function detectWebRTCSupport(): WebRTCSupport {
|
||||
pc.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
missing.push('DataChannel');
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ func main() {
|
||||
r.Handle("/*", web.CreateFrontendHandler())
|
||||
|
||||
// WebRTC信令WebSocket路由
|
||||
r.Get("/api/ws/webrtc", h.HandleWebRTCWebSocket)
|
||||
r.Get("/ws/webrtc", h.HandleWebRTCWebSocket)
|
||||
|
||||
// WebRTC房间API
|
||||
|
||||
1
go.mod
1
go.mod
@@ -5,6 +5,5 @@ go 1.21
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebRTCOffer WebRTC offer 结构
|
||||
type WebRTCOffer struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// WebRTCAnswer WebRTC answer 结构
|
||||
type WebRTCAnswer struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// WebRTCICECandidate ICE candidate 结构
|
||||
type WebRTCICECandidate struct {
|
||||
Candidate string `json:"candidate"`
|
||||
SDPMLineIndex int `json:"sdpMLineIndex"`
|
||||
SDPMid string `json:"sdpMid"`
|
||||
}
|
||||
|
||||
// VideoMessage 视频消息结构
|
||||
type VideoMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// ClientInfo 客户端连接信息
|
||||
type ClientInfo struct {
|
||||
ID string `json:"id"` // 客户端唯一标识
|
||||
Role string `json:"role"` // sender 或 receiver
|
||||
Connection *websocket.Conn `json:"-"` // WebSocket连接(不序列化)
|
||||
JoinedAt time.Time `json:"joined_at"` // 加入时间
|
||||
UserAgent string `json:"user_agent"` // 用户代理
|
||||
}
|
||||
|
||||
// RoomStatus 房间状态信息
|
||||
type RoomStatus struct {
|
||||
Code string `json:"code"`
|
||||
SenderOnline bool `json:"sender_online"`
|
||||
ReceiverOnline bool `json:"receiver_online"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ErrorResponse 错误响应结构
|
||||
type ErrorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
@@ -23,7 +23,6 @@ type WebRTCRoom struct {
|
||||
Receiver *WebRTCClient
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time // 添加过期时间
|
||||
LastOffer *WebRTCMessage // 保存最后的offer消息
|
||||
}
|
||||
|
||||
type WebRTCClient struct {
|
||||
@@ -107,6 +106,20 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查房间是否已满(两个连接都已存在)
|
||||
ws.roomsMux.RLock()
|
||||
isRoomFull := room.Sender != nil && room.Receiver != nil
|
||||
ws.roomsMux.RUnlock()
|
||||
|
||||
if isRoomFull {
|
||||
log.Printf("房间已满,拒绝连接: %s", code)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "当前房间人数已满,正在传输中无法加入",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成客户端ID
|
||||
clientID := ws.generateClientID()
|
||||
client := &WebRTCClient{
|
||||
@@ -187,15 +200,6 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
|
||||
}
|
||||
room.Sender.Connection.WriteJSON(peerJoinedMsg)
|
||||
}
|
||||
|
||||
// 如果接收方连接,且有保存的offer,立即发送给接收方
|
||||
if room.LastOffer != nil {
|
||||
log.Printf("向新连接的接收方发送保存的offer")
|
||||
err := client.Connection.WriteJSON(room.LastOffer)
|
||||
if err != nil {
|
||||
log.Printf("发送保存的offer失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +237,6 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是offer消息,保存起来
|
||||
if msg.Type == "offer" {
|
||||
room.LastOffer = msg
|
||||
log.Printf("保存offer消息,等待接收方连接")
|
||||
}
|
||||
|
||||
var targetClient *WebRTCClient
|
||||
if room.Sender != nil && room.Sender.ID == fromClientID {
|
||||
// 消息来自sender,转发给receiver
|
||||
@@ -374,6 +372,7 @@ func (ws *WebRTCService) notifyRoomDisconnection(roomCode string, disconnectedCl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
|
||||
ws.roomsMux.RLock()
|
||||
defer ws.roomsMux.RUnlock()
|
||||
@@ -387,11 +386,15 @@ func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查房间是否已满(两个连接都已存在)
|
||||
isRoomFull := room.Sender != nil && room.Receiver != nil
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"exists": true,
|
||||
"sender_online": room.Sender != nil,
|
||||
"receiver_online": room.Receiver != nil,
|
||||
"is_room_full": isRoomFull,
|
||||
"created_at": room.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 嵌入静态文件
|
||||
//
|
||||
//go:embed all:static
|
||||
var StaticFiles embed.FS
|
||||
|
||||
// StaticFileServer 创建静态文件服务器
|
||||
func StaticFileServer() http.Handler {
|
||||
// 获取嵌入的文件系统
|
||||
staticFS, err := fs.Sub(StaticFiles, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return http.FileServer(http.FS(staticFS))
|
||||
}
|
||||
|
||||
// FrontendFileServer 创建前端文件服务器
|
||||
func FrontendFileServer() http.Handler {
|
||||
return &frontendHandler{}
|
||||
}
|
||||
|
||||
// frontendHandler 处理前端文件请求,支持 SPA 路由
|
||||
type frontendHandler struct{}
|
||||
|
||||
func (h *frontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// 返回一个简单的占位页面
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>文件传输</title>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<h1>文件传输服务</h1>
|
||||
<p>前端文件未嵌入,请先构建前端项目。</p>
|
||||
<p>运行以下命令构建前端:</p>
|
||||
<pre>cd chuan-next && yarn build:ssg</pre>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}
|
||||
Reference in New Issue
Block a user