mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-04-15 01:47:29 +08:00
第一版本
This commit is contained in:
105
.gitignore
vendored
Normal file
105
.gitignore
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
# Go相关
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# 测试二进制文件
|
||||
*.test
|
||||
|
||||
# 输出文件
|
||||
*.out
|
||||
|
||||
# Go工作区文件
|
||||
go.work
|
||||
|
||||
# 构建目录
|
||||
build/
|
||||
dist/
|
||||
|
||||
# 上传文件目录
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# IDE相关
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量文件
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# 依赖目录
|
||||
vendor/
|
||||
|
||||
# 覆盖率报告
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
# Air热重载工具配置
|
||||
.air.toml
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Redis数据
|
||||
dump.rdb
|
||||
|
||||
# 缓存文件
|
||||
*.cache
|
||||
|
||||
# 证书文件
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# 配置文件(可能包含敏感信息)
|
||||
config.json
|
||||
config.yaml
|
||||
config.yml
|
||||
|
||||
# Docker相关
|
||||
docker-compose.override.yml
|
||||
|
||||
# 编辑器备份文件
|
||||
*~
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
# 压缩文件
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
|
||||
# Node.js相关(如果有前端构建)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# 生产环境文件
|
||||
production/
|
||||
|
||||
# 备份文件
|
||||
backup/
|
||||
|
||||
*.log
|
||||
/bin/*
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# 多阶段构建 - 构建阶段
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 复制go.mod和go.sum文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用程序
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main cmd/main.go
|
||||
|
||||
# 运行阶段
|
||||
FROM alpine:latest
|
||||
|
||||
# 安装ca-certificates用于HTTPS请求
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# 从构建阶段复制二进制文件
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# 复制静态文件
|
||||
COPY --from=builder /app/web ./web
|
||||
|
||||
# 创建必要的目录
|
||||
RUN mkdir -p uploads logs
|
||||
|
||||
# 设置权限
|
||||
RUN chmod +x ./main
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
|
||||
|
||||
# 运行应用程序
|
||||
CMD ["./main"]
|
||||
185
Makefile
Normal file
185
Makefile
Normal file
@@ -0,0 +1,185 @@
|
||||
# Makefile for Chuan File Transfer System
|
||||
|
||||
# 默认目标
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "可用的命令:"
|
||||
@echo " run - 运行应用程序"
|
||||
@echo " build - 构建应用程序"
|
||||
@echo " clean - 清理构建文件"
|
||||
@echo " deps - 安装依赖"
|
||||
@echo " test - 运行测试"
|
||||
@echo " docker - 构建Docker镜像"
|
||||
|
||||
# 应用程序名称
|
||||
APP_NAME=chuan
|
||||
BUILD_DIR=build
|
||||
MAIN_FILE=cmd/main.go
|
||||
|
||||
# Go相关命令
|
||||
GO=go
|
||||
GOCMD=$(GO)
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
|
||||
# 构建标志
|
||||
LDFLAGS=-ldflags "-X main.Version=1.0.0 -X main.BuildTime=$$(date +'%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# 运行应用程序
|
||||
.PHONY: run
|
||||
run:
|
||||
@echo "启动文件传输系统..."
|
||||
@mkdir -p uploads
|
||||
$(GOCMD) run $(MAIN_FILE)
|
||||
|
||||
# 构建应用程序
|
||||
.PHONY: build
|
||||
build:
|
||||
@echo "构建应用程序..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_FILE)
|
||||
|
||||
# 构建Linux版本
|
||||
.PHONY: build-linux
|
||||
build-linux:
|
||||
@echo "构建Linux版本..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME)-linux $(MAIN_FILE)
|
||||
|
||||
# 构建Windows版本
|
||||
.PHONY: build-windows
|
||||
build-windows:
|
||||
@echo "构建Windows版本..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME)-windows.exe $(MAIN_FILE)
|
||||
|
||||
# 构建MacOS版本
|
||||
.PHONY: build-macos
|
||||
build-macos:
|
||||
@echo "构建MacOS版本..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME)-macos $(MAIN_FILE)
|
||||
|
||||
# 构建所有平台版本
|
||||
.PHONY: build-all
|
||||
build-all: build-linux build-windows build-macos
|
||||
@echo "所有平台构建完成"
|
||||
|
||||
# 安装依赖
|
||||
.PHONY: deps
|
||||
deps:
|
||||
@echo "安装Go模块依赖..."
|
||||
$(GOMOD) download
|
||||
$(GOMOD) tidy
|
||||
|
||||
# 运行测试
|
||||
.PHONY: test
|
||||
test:
|
||||
@echo "运行测试..."
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
# 清理构建文件
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "清理构建文件..."
|
||||
$(GOCLEAN)
|
||||
rm -rf $(BUILD_DIR)
|
||||
rm -rf uploads/*
|
||||
|
||||
# 格式化代码
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
@echo "格式化Go代码..."
|
||||
$(GOCMD) fmt ./...
|
||||
|
||||
# 代码检查
|
||||
.PHONY: vet
|
||||
vet:
|
||||
@echo "运行go vet..."
|
||||
$(GOCMD) vet ./...
|
||||
|
||||
# 安全检查
|
||||
.PHONY: security
|
||||
security:
|
||||
@echo "运行安全检查..."
|
||||
@which gosec > /dev/null || $(GOGET) github.com/securecodewarrior/gosec/v2/cmd/gosec@latest
|
||||
gosec ./...
|
||||
|
||||
# 性能测试
|
||||
.PHONY: bench
|
||||
bench:
|
||||
@echo "运行性能测试..."
|
||||
$(GOTEST) -bench=. -benchmem ./...
|
||||
|
||||
# 代码覆盖率
|
||||
.PHONY: coverage
|
||||
coverage:
|
||||
@echo "生成代码覆盖率报告..."
|
||||
$(GOTEST) -coverprofile=coverage.out ./...
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "覆盖率报告已生成: coverage.html"
|
||||
|
||||
# 创建Docker镜像
|
||||
.PHONY: docker
|
||||
docker:
|
||||
@echo "构建Docker镜像..."
|
||||
docker build -t $(APP_NAME):latest .
|
||||
|
||||
# 运行Docker容器
|
||||
.PHONY: docker-run
|
||||
docker-run:
|
||||
@echo "运行Docker容器..."
|
||||
docker run -p 8080:8080 -v $(PWD)/uploads:/app/uploads $(APP_NAME):latest
|
||||
|
||||
# 开发模式(热重载)
|
||||
.PHONY: dev
|
||||
dev:
|
||||
@echo "启动开发模式(需要安装air)..."
|
||||
@which air > /dev/null || $(GOGET) github.com/cosmtrek/air@latest
|
||||
air
|
||||
|
||||
# 安装开发工具
|
||||
.PHONY: tools
|
||||
tools:
|
||||
@echo "安装开发工具..."
|
||||
$(GOGET) github.com/cosmtrek/air@latest
|
||||
$(GOGET) github.com/securecodewarrior/gosec/v2/cmd/gosec@latest
|
||||
$(GOGET) golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
# 初始化项目
|
||||
.PHONY: init
|
||||
init: deps tools
|
||||
@echo "项目初始化完成"
|
||||
@mkdir -p uploads
|
||||
@mkdir -p logs
|
||||
@echo "创建必要的目录"
|
||||
|
||||
# 部署到生产环境
|
||||
.PHONY: deploy
|
||||
deploy: build-linux
|
||||
@echo "部署到生产环境..."
|
||||
@echo "请手动将 $(BUILD_DIR)/$(APP_NAME)-linux 上传到服务器"
|
||||
|
||||
# 查看项目状态
|
||||
.PHONY: status
|
||||
status:
|
||||
@echo "项目状态:"
|
||||
@echo " Go版本: $$($(GOCMD) version)"
|
||||
@echo " 项目路径: $$(pwd)"
|
||||
@echo " 模块信息:"
|
||||
@$(GOMOD) list -m all | head -10
|
||||
|
||||
# 生成API文档
|
||||
.PHONY: docs
|
||||
docs:
|
||||
@echo "生成API文档..."
|
||||
@which swag > /dev/null || $(GOGET) github.com/swaggo/swag/cmd/swag@latest
|
||||
swag init -g $(MAIN_FILE)
|
||||
|
||||
# 运行所有检查
|
||||
.PHONY: check
|
||||
check: fmt vet test
|
||||
@echo "所有检查完成"
|
||||
89
cmd/main.go
Normal file
89
cmd/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"chuan/internal/handlers"
|
||||
"chuan/internal/services"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化服务
|
||||
p2pService := services.NewP2PService()
|
||||
|
||||
// 初始化处理器
|
||||
h := handlers.NewHandler(p2pService)
|
||||
|
||||
// 创建路由
|
||||
r := chi.NewRouter()
|
||||
|
||||
// 中间件
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// CORS 配置
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// 静态文件服务
|
||||
fileServer := http.FileServer(http.Dir("./web/static/"))
|
||||
r.Handle("/static/*", http.StripPrefix("/static", fileServer))
|
||||
|
||||
// 路由定义
|
||||
r.Get("/", h.IndexHandler)
|
||||
r.Get("/ws/p2p", h.HandleP2PWebSocket)
|
||||
|
||||
// API路由
|
||||
r.Post("/api/create-room", h.CreateRoomHandler)
|
||||
r.Get("/api/room-info", h.GetRoomInfoHandler)
|
||||
|
||||
// 启动服务器
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: r,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
log.Printf("服务器启动在端口 :8080")
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待中断信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("正在关闭服务器...")
|
||||
|
||||
// 设置关闭超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("服务器强制关闭:", err)
|
||||
}
|
||||
|
||||
log.Println("服务器已退出")
|
||||
}
|
||||
256
deploy.sh
Executable file
256
deploy.sh
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 文件传输系统部署脚本
|
||||
# 使用方法: ./deploy.sh [环境]
|
||||
# 环境选项: dev, staging, production
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 环境变量
|
||||
ENV=${1:-dev}
|
||||
APP_NAME="chuan"
|
||||
DOCKER_IMAGE="${APP_NAME}:${ENV}"
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
||||
# 检查Docker和Docker Compose
|
||||
check_dependencies() {
|
||||
log_info "检查依赖..."
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker未安装,请先安装Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
log_error "Docker Compose未安装,请先安装Docker Compose"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "依赖检查完成"
|
||||
}
|
||||
|
||||
# 构建应用
|
||||
build_app() {
|
||||
log_info "构建应用..."
|
||||
|
||||
# 清理旧的构建
|
||||
docker-compose down --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# 构建新镜像
|
||||
docker-compose build --no-cache
|
||||
|
||||
log_info "应用构建完成"
|
||||
}
|
||||
|
||||
# 生成SSL证书(开发环境)
|
||||
generate_ssl_cert() {
|
||||
if [ "$ENV" = "dev" ]; then
|
||||
log_info "生成开发环境SSL证书..."
|
||||
|
||||
mkdir -p ssl
|
||||
|
||||
if [ ! -f ssl/cert.pem ] || [ ! -f ssl/key.pem ]; then
|
||||
openssl req -x509 -newkey rsa:4096 -keyout ssl/key.pem -out ssl/cert.pem -days 365 -nodes \
|
||||
-subj "/C=CN/ST=Beijing/L=Beijing/O=Chuan/OU=Dev/CN=localhost"
|
||||
log_info "SSL证书生成完成"
|
||||
else
|
||||
log_info "SSL证书已存在,跳过生成"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 部署应用
|
||||
deploy_app() {
|
||||
log_info "部署应用到${ENV}环境..."
|
||||
|
||||
# 根据环境选择不同的配置
|
||||
case $ENV in
|
||||
"dev")
|
||||
export COMPOSE_FILE="docker-compose.yml"
|
||||
;;
|
||||
"staging")
|
||||
export COMPOSE_FILE="docker-compose.staging.yml"
|
||||
;;
|
||||
"production")
|
||||
export COMPOSE_FILE="docker-compose.prod.yml"
|
||||
;;
|
||||
*)
|
||||
log_error "未知环境: $ENV"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 等待服务启动
|
||||
log_info "等待服务启动..."
|
||||
sleep 10
|
||||
|
||||
# 健康检查
|
||||
if curl -f http://localhost:8080/health > /dev/null 2>&1; then
|
||||
log_info "应用健康检查通过"
|
||||
else
|
||||
log_warn "应用健康检查失败,请检查日志"
|
||||
fi
|
||||
|
||||
log_info "部署完成"
|
||||
}
|
||||
|
||||
# 显示服务状态
|
||||
show_status() {
|
||||
log_info "服务状态:"
|
||||
docker-compose ps
|
||||
|
||||
log_info "服务日志(最近20行):"
|
||||
docker-compose logs --tail=20
|
||||
}
|
||||
|
||||
# 备份数据
|
||||
backup_data() {
|
||||
log_info "备份数据..."
|
||||
|
||||
BACKUP_DIR="backup/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 备份上传文件
|
||||
if [ -d "uploads" ]; then
|
||||
cp -r uploads "$BACKUP_DIR/"
|
||||
log_info "上传文件已备份到 $BACKUP_DIR/uploads"
|
||||
fi
|
||||
|
||||
# 备份Redis数据
|
||||
docker-compose exec -T redis redis-cli BGSAVE
|
||||
docker cp $(docker-compose ps -q redis):/data/dump.rdb "$BACKUP_DIR/"
|
||||
log_info "Redis数据已备份到 $BACKUP_DIR/dump.rdb"
|
||||
|
||||
log_info "数据备份完成: $BACKUP_DIR"
|
||||
}
|
||||
|
||||
# 恢复数据
|
||||
restore_data() {
|
||||
BACKUP_DIR=$2
|
||||
|
||||
if [ -z "$BACKUP_DIR" ]; then
|
||||
log_error "请指定备份目录"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
log_error "备份目录不存在: $BACKUP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "从 $BACKUP_DIR 恢复数据..."
|
||||
|
||||
# 恢复上传文件
|
||||
if [ -d "$BACKUP_DIR/uploads" ]; then
|
||||
rm -rf uploads/*
|
||||
cp -r "$BACKUP_DIR/uploads/"* uploads/
|
||||
log_info "上传文件已恢复"
|
||||
fi
|
||||
|
||||
# 恢复Redis数据
|
||||
if [ -f "$BACKUP_DIR/dump.rdb" ]; then
|
||||
docker-compose stop redis
|
||||
docker cp "$BACKUP_DIR/dump.rdb" $(docker-compose ps -q redis):/data/
|
||||
docker-compose start redis
|
||||
log_info "Redis数据已恢复"
|
||||
fi
|
||||
|
||||
log_info "数据恢复完成"
|
||||
}
|
||||
|
||||
# 清理资源
|
||||
cleanup() {
|
||||
log_info "清理资源..."
|
||||
|
||||
docker-compose down --volumes --remove-orphans
|
||||
docker system prune -af
|
||||
docker volume prune -f
|
||||
|
||||
log_info "清理完成"
|
||||
}
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
echo "文件传输系统部署脚本"
|
||||
echo ""
|
||||
echo "使用方法:"
|
||||
echo " $0 [命令] [环境/参数]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " deploy [env] - 部署应用 (环境: dev, staging, production)"
|
||||
echo " build - 构建应用"
|
||||
echo " status - 显示服务状态"
|
||||
echo " backup - 备份数据"
|
||||
echo " restore [dir] - 恢复数据"
|
||||
echo " cleanup - 清理资源"
|
||||
echo " help - 显示帮助信息"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 deploy dev # 部署到开发环境"
|
||||
echo " $0 deploy production # 部署到生产环境"
|
||||
echo " $0 backup # 备份数据"
|
||||
echo " $0 restore backup/20241128_120000 # 恢复数据"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
case ${1:-deploy} in
|
||||
"deploy")
|
||||
check_dependencies
|
||||
generate_ssl_cert
|
||||
build_app
|
||||
deploy_app
|
||||
show_status
|
||||
;;
|
||||
"build")
|
||||
check_dependencies
|
||||
build_app
|
||||
;;
|
||||
"status")
|
||||
show_status
|
||||
;;
|
||||
"backup")
|
||||
backup_data
|
||||
;;
|
||||
"restore")
|
||||
restore_data $@
|
||||
;;
|
||||
"cleanup")
|
||||
cleanup
|
||||
;;
|
||||
"help"|"-h"|"--help")
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "未知命令: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main $@
|
||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 主应用服务
|
||||
chuan:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./uploads:/root/uploads
|
||||
- ./logs:/root/logs
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- PORT=8080
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- chuan-network
|
||||
|
||||
# Redis服务(用于存储取件码)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes --requirepass ""
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- chuan-network
|
||||
|
||||
# Nginx反向代理
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./ssl:/etc/nginx/ssl
|
||||
- ./uploads:/var/www/uploads
|
||||
depends_on:
|
||||
- chuan
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- chuan-network
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
chuan-network:
|
||||
driver: bridge
|
||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module chuan
|
||||
|
||||
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
|
||||
)
|
||||
8
go.sum
Normal file
8
go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
129
internal/handlers/handlers.go
Normal file
129
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"chuan/internal/models"
|
||||
"chuan/internal/services"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
p2pService *services.P2PService
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewHandler(p2pService *services.P2PService) *Handler {
|
||||
h := &Handler{
|
||||
p2pService: p2pService,
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
|
||||
// 加载模板
|
||||
h.loadTemplates()
|
||||
return h
|
||||
}
|
||||
|
||||
// 加载模板
|
||||
func (h *Handler) loadTemplates() {
|
||||
templateDir := "web/templates"
|
||||
|
||||
// 加载基础模板
|
||||
baseTemplate := filepath.Join(templateDir, "base.html")
|
||||
|
||||
// 加载各个页面模板
|
||||
templates := []string{"index.html"}
|
||||
|
||||
for _, tmplName := range templates {
|
||||
tmplPath := filepath.Join(templateDir, tmplName)
|
||||
tmpl, err := template.ParseFiles(baseTemplate, tmplPath)
|
||||
if err != nil {
|
||||
panic("加载模板失败: " + err.Error())
|
||||
}
|
||||
h.templates[tmplName] = tmpl
|
||||
println("模板加载成功:", tmplName)
|
||||
}
|
||||
}
|
||||
|
||||
// IndexHandler 首页处理器
|
||||
func (h *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, exists := h.templates["index.html"]
|
||||
if !exists {
|
||||
http.Error(w, "模板不存在", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Title": "P2P文件传输",
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "渲染模板失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRoomHandler 创建房间API
|
||||
func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "方法不允许", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Files []models.FileTransferInfo `json:"files"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "解析请求失败", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建房间
|
||||
code := h.p2pService.CreateRoom(req.Files)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"code": code,
|
||||
"message": "房间创建成功",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetRoomInfoHandler 获取房间信息API
|
||||
func (h *Handler) GetRoomInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "缺少取件码", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
room, exists := h.p2pService.GetRoomByCode(code)
|
||||
if !exists {
|
||||
response := map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "取件码不存在或已过期",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"files": room.Files,
|
||||
"message": "房间信息获取成功",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleP2PWebSocket 处理P2P WebSocket连接
|
||||
func (h *Handler) HandleP2PWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
h.p2pService.HandleWebSocket(w, r)
|
||||
}
|
||||
68
internal/models/models.go
Normal file
68
internal/models/models.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileInfo 文件信息结构
|
||||
type FileInfo struct {
|
||||
ID string `json:"id"`
|
||||
FileName string `json:"filename"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Code string `json:"code"`
|
||||
UploadTime time.Time `json:"upload_time"`
|
||||
ExpiryTime time.Time `json:"expiry_time"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
// UploadResponse 上传响应结构
|
||||
type UploadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code,omitempty"`
|
||||
FileInfo FileInfo `json:"file_info,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// FileTransferInfo P2P文件传输信息
|
||||
type FileTransferInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"type"`
|
||||
LastModified int64 `json:"lastModified"`
|
||||
}
|
||||
|
||||
// ErrorResponse 错误响应结构
|
||||
type ErrorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
168
internal/services/file_service.go
Normal file
168
internal/services/file_service.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chuan/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileService struct {
|
||||
uploadDir string
|
||||
}
|
||||
|
||||
func NewFileService() *FileService {
|
||||
return &FileService{
|
||||
uploadDir: "./uploads",
|
||||
}
|
||||
}
|
||||
|
||||
// SaveFile 保存上传的文件
|
||||
func (fs *FileService) SaveFile(file multipart.File, header *multipart.FileHeader) (*models.FileInfo, error) {
|
||||
// 生成唯一文件ID
|
||||
fileID := uuid.New().String()
|
||||
|
||||
// 生成取件码
|
||||
code := fs.generateCode()
|
||||
|
||||
// 创建文件路径
|
||||
fileExt := filepath.Ext(header.Filename)
|
||||
fileName := fmt.Sprintf("%s%s", fileID, fileExt)
|
||||
filePath := filepath.Join(fs.uploadDir, fileName)
|
||||
|
||||
// 确保上传目录存在
|
||||
if err := os.MkdirAll(fs.uploadDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建上传目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建目标文件
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建文件失败: %v", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// 复制文件内容
|
||||
size, err := io.Copy(dst, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取文件内容类型
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = fs.getContentType(header.Filename)
|
||||
}
|
||||
|
||||
fileInfo := &models.FileInfo{
|
||||
ID: fileID,
|
||||
FileName: header.Filename,
|
||||
FileSize: size,
|
||||
ContentType: contentType,
|
||||
Code: code,
|
||||
UploadTime: time.Now(),
|
||||
ExpiryTime: time.Now().Add(24 * time.Hour), // 24小时过期
|
||||
FilePath: filePath,
|
||||
DownloadURL: fmt.Sprintf("/download/%s", code),
|
||||
}
|
||||
|
||||
// 存储文件信息到内存(生产环境应使用Redis)
|
||||
store := GetStore()
|
||||
if err := store.StoreFileInfo(fileInfo); err != nil {
|
||||
return nil, fmt.Errorf("存储文件信息失败: %v", err)
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// generateCode 生成6位取件码
|
||||
func (fs *FileService) generateCode() string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 6)
|
||||
rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = charset[b[i]%byte(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// getContentType 根据文件扩展名获取内容类型
|
||||
func (fs *FileService) getContentType(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".epub":
|
||||
return "application/epub+zip"
|
||||
case ".mobi":
|
||||
return "application/x-mobipocket-ebook"
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".avi":
|
||||
return "video/avi"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".rar":
|
||||
return "application/x-rar-compressed"
|
||||
case ".7z":
|
||||
return "application/x-7z-compressed"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// GetFileByCode 根据取件码获取文件信息
|
||||
func (fs *FileService) GetFileByCode(code string) (*models.FileInfo, error) {
|
||||
store := GetStore()
|
||||
return store.GetFileInfo(code)
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func (fs *FileService) DeleteFile(code string) error {
|
||||
fileInfo, err := fs.GetFileByCode(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除物理文件
|
||||
if err := os.Remove(fileInfo.FilePath); err != nil {
|
||||
return fmt.Errorf("删除文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 从内存存储删除文件信息
|
||||
store := GetStore()
|
||||
store.DeleteFileInfo(code)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertEpubToMobi 将EPUB转换为MOBI格式
|
||||
func (fs *FileService) ConvertEpubToMobi(epubPath string) (string, error) {
|
||||
// TODO: 集成Calibre API进行格式转换
|
||||
// 这里暂时返回原文件路径
|
||||
return epubPath, fmt.Errorf("格式转换功能尚未实现")
|
||||
}
|
||||
|
||||
// CleanExpiredFiles 清理过期文件
|
||||
func (fs *FileService) CleanExpiredFiles() error {
|
||||
// TODO: 实现定期清理过期文件的逻辑
|
||||
return nil
|
||||
}
|
||||
61
internal/services/memory_store.go
Normal file
61
internal/services/memory_store.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chuan/internal/models"
|
||||
)
|
||||
|
||||
// 内存存储(生产环境应使用Redis)
|
||||
type MemoryStore struct {
|
||||
files map[string]*models.FileInfo
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var globalStore = &MemoryStore{
|
||||
files: make(map[string]*models.FileInfo),
|
||||
}
|
||||
|
||||
// StoreFileInfo 存储文件信息
|
||||
func (ms *MemoryStore) StoreFileInfo(fileInfo *models.FileInfo) error {
|
||||
ms.mutex.Lock()
|
||||
defer ms.mutex.Unlock()
|
||||
|
||||
ms.files[fileInfo.Code] = fileInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
func (ms *MemoryStore) GetFileInfo(code string) (*models.FileInfo, error) {
|
||||
ms.mutex.RLock()
|
||||
defer ms.mutex.RUnlock()
|
||||
|
||||
fileInfo, exists := ms.files[code]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("文件不存在或已过期")
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().After(fileInfo.ExpiryTime) {
|
||||
delete(ms.files, code)
|
||||
return nil, fmt.Errorf("文件已过期")
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// DeleteFileInfo 删除文件信息
|
||||
func (ms *MemoryStore) DeleteFileInfo(code string) error {
|
||||
ms.mutex.Lock()
|
||||
defer ms.mutex.Unlock()
|
||||
|
||||
delete(ms.files, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStore 获取全局存储实例
|
||||
func GetStore() *MemoryStore {
|
||||
return globalStore
|
||||
}
|
||||
256
internal/services/p2p_service.go
Normal file
256
internal/services/p2p_service.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chuan/internal/models"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type FileTransferRoom struct {
|
||||
ID string
|
||||
Code string // 取件码
|
||||
Files []models.FileTransferInfo // 待传输文件信息
|
||||
Sender *websocket.Conn // 发送方连接
|
||||
Receiver *websocket.Conn // 接收方连接
|
||||
CreatedAt time.Time // 创建时间
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
type P2PService struct {
|
||||
rooms map[string]*FileTransferRoom // 使用取件码作为key
|
||||
roomsMux sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewP2PService() *P2PService {
|
||||
service := &P2PService{
|
||||
rooms: make(map[string]*FileTransferRoom),
|
||||
roomsMux: sync.RWMutex{},
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有来源,生产环境应当限制
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 启动房间清理任务
|
||||
go service.cleanupExpiredRooms()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// CreateRoom 创建新房间并返回取件码
|
||||
func (p *P2PService) CreateRoom(files []models.FileTransferInfo) string {
|
||||
code := generatePickupCode()
|
||||
|
||||
p.roomsMux.Lock()
|
||||
defer p.roomsMux.Unlock()
|
||||
|
||||
room := &FileTransferRoom{
|
||||
ID: "room_" + code,
|
||||
Code: code,
|
||||
Files: files,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
p.rooms[code] = room
|
||||
log.Printf("创建房间,取件码: %s,文件数量: %d", code, len(files))
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// GetRoomByCode 根据取件码获取房间
|
||||
func (p *P2PService) GetRoomByCode(code string) (*FileTransferRoom, bool) {
|
||||
p.roomsMux.RLock()
|
||||
defer p.roomsMux.RUnlock()
|
||||
|
||||
room, exists := p.rooms[code]
|
||||
return room, exists
|
||||
}
|
||||
|
||||
// HandleWebSocket 处理WebSocket连接
|
||||
func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := p.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket升级失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 获取取件码和角色
|
||||
code := r.URL.Query().Get("code")
|
||||
role := r.URL.Query().Get("role") // "sender" or "receiver"
|
||||
|
||||
if code == "" || (role != "sender" && role != "receiver") {
|
||||
log.Printf("缺少取件码或角色参数")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取房间
|
||||
room, exists := p.GetRoomByCode(code)
|
||||
if !exists {
|
||||
log.Printf("房间不存在: %s", code)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置连接
|
||||
room.mutex.Lock()
|
||||
if role == "sender" {
|
||||
room.Sender = conn
|
||||
log.Printf("发送方连接到房间: %s", code)
|
||||
} else {
|
||||
room.Receiver = conn
|
||||
log.Printf("接收方连接到房间: %s", code)
|
||||
|
||||
// 发送文件列表给接收方
|
||||
filesMsg := models.VideoMessage{
|
||||
Type: "file-list",
|
||||
Payload: map[string]interface{}{"files": room.Files},
|
||||
}
|
||||
if err := conn.WriteJSON(filesMsg); err != nil {
|
||||
log.Printf("发送文件列表失败: %v", err)
|
||||
}
|
||||
|
||||
// 通知发送方接收方已连接
|
||||
if room.Sender != nil {
|
||||
readyMsg := models.VideoMessage{
|
||||
Type: "receiver-ready",
|
||||
Payload: map[string]interface{}{},
|
||||
}
|
||||
if err := room.Sender.WriteJSON(readyMsg); err != nil {
|
||||
log.Printf("发送接收方就绪消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
room.mutex.Unlock() // 连接关闭时清理
|
||||
defer func() {
|
||||
room.mutex.Lock()
|
||||
if role == "sender" {
|
||||
room.Sender = nil
|
||||
} else {
|
||||
room.Receiver = nil
|
||||
}
|
||||
room.mutex.Unlock()
|
||||
|
||||
// 如果双方都断开连接,删除房间
|
||||
p.cleanupRoom(code)
|
||||
}()
|
||||
|
||||
// 处理消息
|
||||
for {
|
||||
var msg models.VideoMessage
|
||||
err := conn.ReadJSON(&msg)
|
||||
if err != nil {
|
||||
log.Printf("读取WebSocket消息失败: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
log.Printf("收到WebSocket消息: 类型=%s, 来自=%s, 房间=%s", msg.Type, role, code)
|
||||
|
||||
// 转发消息到对方
|
||||
p.forwardMessage(room, role, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardMessage 转发消息到对方
|
||||
func (p *P2PService) forwardMessage(room *FileTransferRoom, senderRole string, msg models.VideoMessage) {
|
||||
room.mutex.RLock()
|
||||
defer room.mutex.RUnlock()
|
||||
|
||||
var targetConn *websocket.Conn
|
||||
var targetRole string
|
||||
if senderRole == "sender" && room.Receiver != nil {
|
||||
targetConn = room.Receiver
|
||||
targetRole = "receiver"
|
||||
} else if senderRole == "receiver" && room.Sender != nil {
|
||||
targetConn = room.Sender
|
||||
targetRole = "sender"
|
||||
}
|
||||
|
||||
if targetConn != nil {
|
||||
log.Printf("转发消息: 类型=%s, 从%s到%s", msg.Type, senderRole, targetRole)
|
||||
if err := targetConn.WriteJSON(msg); err != nil {
|
||||
log.Printf("转发消息失败: %v", err)
|
||||
} else {
|
||||
log.Printf("消息转发成功: 类型=%s", msg.Type)
|
||||
}
|
||||
} else {
|
||||
log.Printf("无法转发消息: 目标连接不存在, 发送方=%s", senderRole)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupRoom 清理房间
|
||||
func (p *P2PService) cleanupRoom(code string) {
|
||||
p.roomsMux.Lock()
|
||||
defer p.roomsMux.Unlock()
|
||||
|
||||
if room, exists := p.rooms[code]; exists {
|
||||
room.mutex.RLock()
|
||||
bothDisconnected := room.Sender == nil && room.Receiver == nil
|
||||
room.mutex.RUnlock()
|
||||
|
||||
if bothDisconnected {
|
||||
delete(p.rooms, code)
|
||||
log.Printf("清理房间: %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpiredRooms 定期清理过期房间
|
||||
func (p *P2PService) cleanupExpiredRooms() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
p.roomsMux.Lock()
|
||||
now := time.Now()
|
||||
for code, room := range p.rooms {
|
||||
// 房间存在超过1小时则删除
|
||||
if now.Sub(room.CreatedAt) > time.Hour {
|
||||
delete(p.rooms, code)
|
||||
log.Printf("清理过期房间: %s", code)
|
||||
}
|
||||
}
|
||||
p.roomsMux.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// generatePickupCode 生成6位取件码
|
||||
func generatePickupCode() string {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
code := rand.Intn(900000) + 100000
|
||||
return strconv.Itoa(code)
|
||||
}
|
||||
|
||||
// GetRoomStats 获取房间统计信息
|
||||
func (p *P2PService) GetRoomStats() map[string]interface{} {
|
||||
p.roomsMux.RLock()
|
||||
defer p.roomsMux.RUnlock()
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_rooms": len(p.rooms),
|
||||
"rooms": make([]map[string]interface{}, 0),
|
||||
}
|
||||
|
||||
for code, room := range p.rooms {
|
||||
room.mutex.RLock()
|
||||
roomInfo := map[string]interface{}{
|
||||
"code": code,
|
||||
"file_count": len(room.Files),
|
||||
"has_sender": room.Sender != nil,
|
||||
"has_receiver": room.Receiver != nil,
|
||||
"created_at": room.CreatedAt,
|
||||
}
|
||||
room.mutex.RUnlock()
|
||||
stats["rooms"] = append(stats["rooms"].([]map[string]interface{}), roomInfo)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
175
internal/services/webrtc_service.go
Normal file
175
internal/services/webrtc_service.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"chuan/internal/models"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type WebRTCService struct {
|
||||
clients map[string]*websocket.Conn
|
||||
clientsMux sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewWebRTCService() *WebRTCService {
|
||||
return &WebRTCService{
|
||||
clients: make(map[string]*websocket.Conn),
|
||||
clientsMux: sync.RWMutex{},
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有来源,生产环境应当限制
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket 处理WebSocket连接
|
||||
func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := ws.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket升级失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 为客户端生成唯一ID
|
||||
clientID := ws.generateClientID()
|
||||
|
||||
// 添加客户端到连接池
|
||||
ws.clientsMux.Lock()
|
||||
ws.clients[clientID] = conn
|
||||
ws.clientsMux.Unlock()
|
||||
|
||||
// 连接关闭时清理
|
||||
defer func() {
|
||||
ws.clientsMux.Lock()
|
||||
delete(ws.clients, clientID)
|
||||
ws.clientsMux.Unlock()
|
||||
}()
|
||||
|
||||
// 发送欢迎消息
|
||||
welcomeMsg := models.VideoMessage{
|
||||
Type: "welcome",
|
||||
Payload: map[string]string{"clientId": clientID},
|
||||
}
|
||||
ws.sendMessage(conn, welcomeMsg)
|
||||
|
||||
// 处理消息
|
||||
for {
|
||||
var msg models.VideoMessage
|
||||
err := conn.ReadJSON(&msg)
|
||||
if err != nil {
|
||||
log.Printf("读取WebSocket消息失败: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "offer":
|
||||
ws.handleOffer(clientID, msg)
|
||||
case "answer":
|
||||
ws.handleAnswer(clientID, msg)
|
||||
case "ice-candidate":
|
||||
ws.handleICECandidate(clientID, msg)
|
||||
case "join-room":
|
||||
ws.handleJoinRoom(clientID, msg)
|
||||
case "leave-room":
|
||||
ws.handleLeaveRoom(clientID, msg)
|
||||
default:
|
||||
log.Printf("未知消息类型: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleOffer 处理WebRTC Offer
|
||||
func (ws *WebRTCService) handleOffer(clientID string, msg models.VideoMessage) {
|
||||
// 广播offer到其他客户端
|
||||
ws.broadcastToOthers(clientID, msg)
|
||||
}
|
||||
|
||||
// handleAnswer 处理WebRTC Answer
|
||||
func (ws *WebRTCService) handleAnswer(clientID string, msg models.VideoMessage) {
|
||||
// 广播answer到其他客户端
|
||||
ws.broadcastToOthers(clientID, msg)
|
||||
}
|
||||
|
||||
// handleICECandidate 处理ICE candidate
|
||||
func (ws *WebRTCService) handleICECandidate(clientID string, msg models.VideoMessage) {
|
||||
// 广播ICE candidate到其他客户端
|
||||
ws.broadcastToOthers(clientID, msg)
|
||||
}
|
||||
|
||||
// handleJoinRoom 处理加入房间
|
||||
func (ws *WebRTCService) handleJoinRoom(clientID string, msg models.VideoMessage) {
|
||||
// TODO: 实现房间管理逻辑
|
||||
log.Printf("客户端 %s 加入房间", clientID)
|
||||
}
|
||||
|
||||
// handleLeaveRoom 处理离开房间
|
||||
func (ws *WebRTCService) handleLeaveRoom(clientID string, msg models.VideoMessage) {
|
||||
// TODO: 实现房间管理逻辑
|
||||
log.Printf("客户端 %s 离开房间", clientID)
|
||||
}
|
||||
|
||||
// broadcastToOthers 向其他客户端广播消息
|
||||
func (ws *WebRTCService) broadcastToOthers(senderID string, msg models.VideoMessage) {
|
||||
ws.clientsMux.RLock()
|
||||
defer ws.clientsMux.RUnlock()
|
||||
|
||||
for clientID, conn := range ws.clients {
|
||||
if clientID != senderID {
|
||||
ws.sendMessage(conn, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendMessage 发送消息到WebSocket连接
|
||||
func (ws *WebRTCService) sendMessage(conn *websocket.Conn, msg models.VideoMessage) {
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
log.Printf("发送WebSocket消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateClientID 生成客户端ID
|
||||
func (ws *WebRTCService) generateClientID() string {
|
||||
// 简单的ID生成,生产环境应使用更安全的方法
|
||||
return "client_" + randomString(8)
|
||||
}
|
||||
|
||||
// CreateOffer 创建WebRTC Offer
|
||||
func (ws *WebRTCService) CreateOffer() (*models.WebRTCOffer, error) {
|
||||
// TODO: 实现WebRTC Offer创建
|
||||
return &models.WebRTCOffer{
|
||||
SDP: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n...", // 示例SDP
|
||||
Type: "offer",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAnswer 创建WebRTC Answer
|
||||
func (ws *WebRTCService) CreateAnswer(offer *models.WebRTCOffer) (*models.WebRTCAnswer, error) {
|
||||
// TODO: 实现WebRTC Answer创建
|
||||
return &models.WebRTCAnswer{
|
||||
SDP: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n...", // 示例SDP
|
||||
Type: "answer",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddICECandidate 添加ICE候选
|
||||
func (ws *WebRTCService) AddICECandidate(candidate *models.WebRTCICECandidate) error {
|
||||
// TODO: 实现ICE候选处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// randomString 生成随机字符串
|
||||
func randomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[i%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
181
nginx.conf
Normal file
181
nginx.conf
Normal file
@@ -0,0 +1,181 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日志格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# 基本设置
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/json;
|
||||
|
||||
# 文件上传限制
|
||||
client_max_body_size 64G;
|
||||
client_body_timeout 60s;
|
||||
client_header_timeout 60s;
|
||||
|
||||
# 缓存设置
|
||||
open_file_cache max=10000 inactive=5m;
|
||||
open_file_cache_valid 2m;
|
||||
open_file_cache_min_uses 1;
|
||||
open_file_cache_errors on;
|
||||
|
||||
# 上游服务器
|
||||
upstream chuan_backend {
|
||||
server chuan:8080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# HTTP服务器(重定向到HTTPS)
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS服务器
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL配置
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# 现代SSL配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary Accept-Encoding;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 上传文件服务
|
||||
location /uploads/ {
|
||||
alias /var/www/uploads/;
|
||||
expires 24h;
|
||||
add_header Cache-Control "public";
|
||||
|
||||
# 安全设置
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline';";
|
||||
|
||||
# 限制访问
|
||||
valid_referers none blocked server_names;
|
||||
if ($invalid_referer) {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
|
||||
# WebSocket代理
|
||||
location /ws/ {
|
||||
proxy_pass http://chuan_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# API代理
|
||||
location /api/ {
|
||||
proxy_pass http://chuan_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 主应用代理
|
||||
location / {
|
||||
proxy_pass http://chuan_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 缓存设置
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
location = /404.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
uploads/.gitkeep
Normal file
3
uploads/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
||||
# 上传文件存储目录
|
||||
|
||||
该目录用于存储用户上传的文件,请保持此文件以确保目录在版本控制中被保留。
|
||||
357
web/static/css/style.css
Normal file
357
web/static/css/style.css
Normal file
@@ -0,0 +1,357 @@
|
||||
/* 自定义样式 - 补充Tailwind CSS */
|
||||
|
||||
/* 全局样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 响应式设计优化 */
|
||||
@media (max-width: 640px) {
|
||||
.max-w-7xl {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
nav .flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
nav .space-x-4 {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.grid.md\\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid.md\\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lg\\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lg\\:max-w-md {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件拖拽区域样式 */
|
||||
#dropZone {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#dropZone:hover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
#dropZone.drag-over {
|
||||
border-color: #3b82f6 !important;
|
||||
background-color: #dbeafe !important;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 进度条动画 */
|
||||
#progressBar {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 视频元素样式 */
|
||||
video {
|
||||
background-color: #000;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 聊天消息滚动条样式 */
|
||||
#chatMessages {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e0 #f7fafc;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar-track {
|
||||
background: #f7fafc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0aec0;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果优化 */
|
||||
button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.bg-white {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.bg-white:hover {
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 渐入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* 成功提示动画 */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: translate3d(0,0,0);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: translate3d(0, -30px, 0);
|
||||
}
|
||||
70% {
|
||||
transform: translate3d(0, -15px, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.success-bounce {
|
||||
animation: bounce 1s ease;
|
||||
}
|
||||
|
||||
/* 错误提示样式 */
|
||||
.error-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件图标样式 */
|
||||
.file-icon {
|
||||
font-size: 2rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background-color: #10b981;
|
||||
box-shadow: 0 0 10px #10b981;
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
background-color: #f59e0b;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 代码块样式 */
|
||||
pre, code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 工具提示样式 */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 120px;
|
||||
background-color: #555;
|
||||
color: white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
margin-left: -60px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
nav, footer, button, .no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度支持 */
|
||||
@media (prefers-contrast: high) {
|
||||
button {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画支持 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
393
web/static/js/common.js
Normal file
393
web/static/js/common.js
Normal file
@@ -0,0 +1,393 @@
|
||||
// 通用JavaScript工具函数
|
||||
|
||||
// 工具函数
|
||||
const Utils = {
|
||||
// 格式化文件大小
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
// 格式化时间
|
||||
formatTime(date) {
|
||||
return new Date(date).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// 生成随机字符串
|
||||
randomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// 复制到剪贴板
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// 兼容性处理
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const result = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取文件类型图标
|
||||
getFileIcon(fileName, fileType) {
|
||||
const ext = fileName.split('.').pop().toLowerCase();
|
||||
|
||||
// 根据MIME类型
|
||||
if (fileType) {
|
||||
if (fileType.startsWith('image/')) return '🖼️';
|
||||
if (fileType.startsWith('video/')) return '🎥';
|
||||
if (fileType.startsWith('audio/')) return '🎵';
|
||||
if (fileType.includes('pdf')) return '📄';
|
||||
if (fileType.includes('text')) return '📝';
|
||||
if (fileType.includes('zip') || fileType.includes('rar')) return '📦';
|
||||
}
|
||||
|
||||
// 根据文件扩展名
|
||||
switch (ext) {
|
||||
case 'pdf': return '📄';
|
||||
case 'doc':
|
||||
case 'docx': return '📝';
|
||||
case 'xls':
|
||||
case 'xlsx': return '📊';
|
||||
case 'ppt':
|
||||
case 'pptx': return '📈';
|
||||
case 'txt': return '📄';
|
||||
case 'epub':
|
||||
case 'mobi': return '📚';
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z': return '📦';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp': return '🖼️';
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv': return '🎥';
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
case 'aac': return '🎵';
|
||||
case 'js':
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'py':
|
||||
case 'java':
|
||||
case 'cpp': return '💻';
|
||||
default: return '📁';
|
||||
}
|
||||
},
|
||||
|
||||
// 验证取件码格式
|
||||
validateCode(code) {
|
||||
return /^[A-Z0-9]{6}$/.test(code);
|
||||
},
|
||||
|
||||
// 获取浏览器信息
|
||||
getBrowserInfo() {
|
||||
const ua = navigator.userAgent;
|
||||
let browser = 'Unknown';
|
||||
let version = 'Unknown';
|
||||
|
||||
if (ua.indexOf('Chrome') > -1) {
|
||||
browser = 'Chrome';
|
||||
version = ua.match(/Chrome\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('Firefox') > -1) {
|
||||
browser = 'Firefox';
|
||||
version = ua.match(/Firefox\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1) {
|
||||
browser = 'Safari';
|
||||
version = ua.match(/Version\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('Edge') > -1) {
|
||||
browser = 'Edge';
|
||||
version = ua.match(/Edge\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('360SE') > -1) {
|
||||
browser = '360浏览器';
|
||||
} else if (ua.indexOf('QQBrowser') > -1) {
|
||||
browser = 'QQ浏览器';
|
||||
version = ua.match(/QQBrowser\/(\d+)/)[1];
|
||||
}
|
||||
|
||||
return { browser, version };
|
||||
},
|
||||
|
||||
// 检查WebRTC支持
|
||||
checkWebRTCSupport() {
|
||||
return !!(window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection);
|
||||
},
|
||||
|
||||
// 检查文件API支持
|
||||
checkFileAPISupport() {
|
||||
return !!(window.File && window.FileReader && window.FileList && window.Blob);
|
||||
},
|
||||
|
||||
// 节流函数
|
||||
throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 防抖函数
|
||||
debounce(func, delay) {
|
||||
let timeoutId;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(context, args), delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 通知系统
|
||||
const Notification = {
|
||||
// 显示成功消息
|
||||
success(message, duration = 3000) {
|
||||
this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
// 显示错误消息
|
||||
error(message, duration = 5000) {
|
||||
this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
// 显示警告消息
|
||||
warning(message, duration = 4000) {
|
||||
this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
// 显示信息消息
|
||||
info(message, duration = 3000) {
|
||||
this.show(message, 'info', duration);
|
||||
},
|
||||
|
||||
// 显示通知
|
||||
show(message, type = 'info', duration = 3000) {
|
||||
// 创建通知容器(如果不存在)
|
||||
let container = document.getElementById('notification-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'notification-container';
|
||||
container.className = 'fixed top-4 right-4 z-50 space-y-2';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// 创建通知元素
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5 fade-in`;
|
||||
|
||||
const bgColor = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
warning: 'bg-yellow-50 border-yellow-200',
|
||||
info: 'bg-blue-50 border-blue-200'
|
||||
}[type] || 'bg-gray-50 border-gray-200';
|
||||
|
||||
const iconEmoji = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
}[type] || 'ℹ️';
|
||||
|
||||
notification.innerHTML = `
|
||||
<div class="flex-1 w-0 p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-xl">${iconEmoji}</span>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-gray-900">${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex border-l border-gray-200">
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="w-full border border-transparent rounded-none rounded-r-lg p-4 flex items-center justify-center text-sm font-medium text-gray-600 hover:text-gray-500 focus:outline-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.className += ` ${bgColor}`;
|
||||
container.appendChild(notification);
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加载管理器
|
||||
const Loading = {
|
||||
show(message = '加载中...') {
|
||||
this.hide(); // 先隐藏现有的加载提示
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'loading-overlay';
|
||||
overlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 flex items-center space-x-3">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span class="text-gray-700">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
hide() {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// API请求工具
|
||||
const API = {
|
||||
async request(url, options = {}) {
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const config = { ...defaultOptions, ...options };
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async get(url, params = {}) {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
Object.keys(params).forEach(key =>
|
||||
urlObj.searchParams.append(key, params[key])
|
||||
);
|
||||
|
||||
return this.request(urlObj.toString());
|
||||
},
|
||||
|
||||
async post(url, data = {}) {
|
||||
return this.request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(url) {
|
||||
return this.request(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 检查浏览器兼容性
|
||||
const browserInfo = Utils.getBrowserInfo();
|
||||
console.log(`浏览器: ${browserInfo.browser} ${browserInfo.version}`);
|
||||
|
||||
// 检查功能支持
|
||||
if (!Utils.checkFileAPISupport()) {
|
||||
Notification.warning('您的浏览器不完全支持文件API,部分功能可能受限');
|
||||
}
|
||||
|
||||
if (!Utils.checkWebRTCSupport()) {
|
||||
console.warn('浏览器不支持WebRTC,视频功能不可用');
|
||||
}
|
||||
|
||||
// 添加全局错误处理
|
||||
window.addEventListener('error', function(event) {
|
||||
console.error('全局错误:', event.error);
|
||||
Notification.error('页面发生错误,请刷新后重试');
|
||||
});
|
||||
|
||||
// 添加网络状态监听
|
||||
window.addEventListener('online', function() {
|
||||
Notification.success('网络连接已恢复');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', function() {
|
||||
Notification.warning('网络连接已断开,请检查网络设置');
|
||||
});
|
||||
|
||||
// 添加页面可见性变化监听
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
console.log('页面已隐藏');
|
||||
} else {
|
||||
console.log('页面已显示');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 导出全局对象
|
||||
window.Utils = Utils;
|
||||
window.Notification = Notification;
|
||||
window.Loading = Loading;
|
||||
window.API = API;
|
||||
373
web/static/js/file-transfer.js
Normal file
373
web/static/js/file-transfer.js
Normal file
@@ -0,0 +1,373 @@
|
||||
// 文件传输相关功能
|
||||
|
||||
// 设置数据通道
|
||||
function setupDataChannel(channel) {
|
||||
dataChannel = channel;
|
||||
let pendingChunkMeta = null;
|
||||
|
||||
channel.onopen = () => {
|
||||
console.log('数据通道已打开');
|
||||
isP2PConnected = true;
|
||||
updateP2PStatus(true);
|
||||
|
||||
// 清除连接超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
// 检查是否是二进制数据
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// 处理二进制数据块
|
||||
if (pendingChunkMeta && currentRole === 'receiver') {
|
||||
receiveFileChunk(pendingChunkMeta, event.data);
|
||||
pendingChunkMeta = null;
|
||||
}
|
||||
} else {
|
||||
// 处理JSON消息
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'file-chunk-meta') {
|
||||
pendingChunkMeta = message;
|
||||
} else {
|
||||
handleDataChannelMessage(event.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析数据通道消息失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel.onerror = (error) => {
|
||||
console.error('数据通道错误:', error);
|
||||
isP2PConnected = false;
|
||||
updateP2PStatus(false);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.log('数据通道已关闭');
|
||||
isP2PConnected = false;
|
||||
updateP2PStatus(false);
|
||||
};
|
||||
}
|
||||
|
||||
// 更新P2P连接状态
|
||||
function updateP2PStatus(connected) {
|
||||
const receiverStatus = document.getElementById('receiverStatus');
|
||||
const downloadButtons = document.querySelectorAll('button[onclick^="downloadFile"]');
|
||||
|
||||
if (currentRole === 'receiver' && receiverStatus) {
|
||||
if (connected) {
|
||||
receiverStatus.innerHTML = `
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
P2P连接已建立,可以下载文件
|
||||
</div>`;
|
||||
|
||||
// 启用下载按钮
|
||||
downloadButtons.forEach(btn => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
btn.classList.add('hover:bg-blue-600');
|
||||
});
|
||||
} else {
|
||||
receiverStatus.innerHTML = `
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
|
||||
正在建立P2P连接...
|
||||
</div>`;
|
||||
|
||||
// 禁用下载按钮
|
||||
downloadButtons.forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
btn.classList.remove('hover:bg-blue-600');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function downloadFile(fileId) {
|
||||
if (!isP2PConnected || !dataChannel || dataChannel.readyState !== 'open') {
|
||||
alert('P2P连接未建立,请等待连接建立后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送文件请求
|
||||
const request = {
|
||||
type: 'file-request',
|
||||
fileId: fileId
|
||||
};
|
||||
|
||||
dataChannel.send(JSON.stringify(request));
|
||||
showTransferProgress(fileId, 'downloading');
|
||||
}
|
||||
|
||||
// 处理数据通道消息
|
||||
function handleDataChannelMessage(data) {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-request':
|
||||
if (currentRole === 'sender') {
|
||||
sendFileData(message.fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-info':
|
||||
if (currentRole === 'receiver') {
|
||||
// 存储文件信息用于下载
|
||||
if (!fileTransfers.has(message.fileId)) {
|
||||
fileTransfers.set(message.fileId, {
|
||||
chunks: [],
|
||||
totalSize: message.size,
|
||||
receivedSize: 0,
|
||||
fileName: message.name,
|
||||
mimeType: message.mimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-data':
|
||||
// 旧的file-data类型已被file-chunk-meta + 二进制数据替代
|
||||
// 这里保留是为了向后兼容
|
||||
if (currentRole === 'receiver') {
|
||||
receiveFileDataLegacy(message);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-complete':
|
||||
if (currentRole === 'receiver') {
|
||||
completeFileDownload(message.fileId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理数据通道消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送文件数据
|
||||
function sendFileData(fileId) {
|
||||
const fileIndex = parseInt(fileId.split('_')[1]);
|
||||
const file = selectedFiles[fileIndex];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// 首先发送文件元信息
|
||||
const fileInfo = {
|
||||
type: 'file-info',
|
||||
fileId: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
dataChannel.send(JSON.stringify(fileInfo));
|
||||
|
||||
const reader = new FileReader();
|
||||
const chunkSize = 65536; // 增加到64KB chunks以提高速度
|
||||
let offset = 0;
|
||||
|
||||
const sendChunk = () => {
|
||||
const slice = file.slice(offset, offset + chunkSize);
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
reader.onload = (e) => {
|
||||
const chunk = e.target.result;
|
||||
|
||||
// 使用更高效的方式传输二进制数据
|
||||
if (dataChannel.readyState === 'open') {
|
||||
// 先发送元数据
|
||||
const metadata = {
|
||||
type: 'file-chunk-meta',
|
||||
fileId: fileId,
|
||||
offset: offset,
|
||||
size: chunk.byteLength,
|
||||
total: file.size,
|
||||
isLast: offset + chunk.byteLength >= file.size
|
||||
};
|
||||
dataChannel.send(JSON.stringify(metadata));
|
||||
|
||||
// 再发送二进制数据
|
||||
dataChannel.send(chunk);
|
||||
}
|
||||
|
||||
offset += chunk.byteLength;
|
||||
|
||||
if (offset < file.size) {
|
||||
// 减少延迟以提高传输速度
|
||||
setTimeout(sendChunk, 1);
|
||||
} else {
|
||||
dataChannel.send(JSON.stringify({
|
||||
type: 'file-complete',
|
||||
fileId: fileId
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
sendChunk();
|
||||
}
|
||||
|
||||
// 接收文件块(二进制数据)
|
||||
function receiveFileChunk(meta, chunkData) {
|
||||
if (!fileTransfers.has(meta.fileId)) {
|
||||
// 如果没有文件信息,创建默认的
|
||||
fileTransfers.set(meta.fileId, {
|
||||
chunks: [],
|
||||
totalSize: meta.total,
|
||||
receivedSize: 0,
|
||||
fileName: `unknown_file_${meta.fileId}`,
|
||||
mimeType: 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
const transfer = fileTransfers.get(meta.fileId);
|
||||
transfer.chunks.push(new Uint8Array(chunkData));
|
||||
transfer.receivedSize += chunkData.byteLength;
|
||||
|
||||
// 更新总大小(以防文件信息还没收到)
|
||||
if (transfer.totalSize !== meta.total) {
|
||||
transfer.totalSize = meta.total;
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
updateTransferProgress(meta.fileId, transfer.receivedSize, transfer.totalSize);
|
||||
|
||||
if (meta.isLast) {
|
||||
completeFileDownload(meta.fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// 接收文件数据(向后兼容的旧版本)
|
||||
function receiveFileDataLegacy(message) {
|
||||
if (!fileTransfers.has(message.fileId)) {
|
||||
// 如果没有文件信息,创建默认的
|
||||
fileTransfers.set(message.fileId, {
|
||||
chunks: [],
|
||||
totalSize: message.total,
|
||||
receivedSize: 0,
|
||||
fileName: `unknown_file_${message.fileId}`,
|
||||
mimeType: 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
const transfer = fileTransfers.get(message.fileId);
|
||||
transfer.chunks.push(new Uint8Array(message.chunk));
|
||||
transfer.receivedSize += message.chunk.length;
|
||||
|
||||
// 更新总大小(以防文件信息还没收到)
|
||||
if (transfer.totalSize !== message.total) {
|
||||
transfer.totalSize = message.total;
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
updateTransferProgress(message.fileId, transfer.receivedSize, transfer.totalSize);
|
||||
|
||||
if (message.isLast) {
|
||||
completeFileDownload(message.fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// 完成文件下载
|
||||
function completeFileDownload(fileId) {
|
||||
const transfer = fileTransfers.get(fileId);
|
||||
if (!transfer) return;
|
||||
|
||||
// 合并所有chunks,使用正确的MIME类型
|
||||
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
||||
|
||||
// 使用正确的文件名
|
||||
const fileName = transfer.fileName || `downloaded_file_${fileId}`;
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log(`文件下载完成: ${fileName}, 大小: ${formatFileSize(transfer.totalSize)}`);
|
||||
|
||||
// 清理
|
||||
fileTransfers.delete(fileId);
|
||||
hideTransferProgress(fileId);
|
||||
}
|
||||
|
||||
// 显示传输进度
|
||||
function showTransferProgress(fileId, type) {
|
||||
const progressContainer = document.getElementById('transferProgress');
|
||||
const progressList = document.getElementById('progressList');
|
||||
|
||||
progressContainer.classList.remove('hidden');
|
||||
|
||||
// 获取文件名
|
||||
let fileName = fileId;
|
||||
if (currentRole === 'receiver') {
|
||||
// 从接收方文件列表中获取文件名
|
||||
const fileIndex = parseInt(fileId.split('_')[1]);
|
||||
const receiverFilesList = document.getElementById('receiverFilesList');
|
||||
const fileItems = receiverFilesList.querySelectorAll('.font-medium');
|
||||
if (fileItems[fileIndex]) {
|
||||
fileName = fileItems[fileIndex].textContent;
|
||||
}
|
||||
} else if (currentRole === 'sender') {
|
||||
// 从发送方文件列表中获取文件名
|
||||
const fileIndex = parseInt(fileId.split('_')[1]);
|
||||
if (selectedFiles[fileIndex]) {
|
||||
fileName = selectedFiles[fileIndex].name;
|
||||
}
|
||||
}
|
||||
|
||||
const progressItem = document.createElement('div');
|
||||
progressItem.id = `progress_${fileId}`;
|
||||
progressItem.className = 'bg-gray-50 p-3 rounded-lg';
|
||||
progressItem.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">${type === 'downloading' ? '📥 下载' : '📤 上传'}: ${fileName}</span>
|
||||
<span class="text-sm text-gray-500">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
progressList.appendChild(progressItem);
|
||||
}
|
||||
|
||||
// 更新传输进度
|
||||
function updateTransferProgress(fileId, received, total) {
|
||||
const progressItem = document.getElementById(`progress_${fileId}`);
|
||||
if (!progressItem) return;
|
||||
|
||||
const percentage = Math.round((received / total) * 100);
|
||||
const progressBar = progressItem.querySelector('.bg-blue-600');
|
||||
const percentageText = progressItem.querySelector('.text-gray-500');
|
||||
|
||||
progressBar.style.width = percentage + '%';
|
||||
percentageText.textContent = percentage + '%';
|
||||
}
|
||||
|
||||
// 隐藏传输进度
|
||||
function hideTransferProgress(fileId) {
|
||||
const progressItem = document.getElementById(`progress_${fileId}`);
|
||||
if (progressItem) {
|
||||
progressItem.remove();
|
||||
}
|
||||
|
||||
// 如果没有进度项了,隐藏整个进度容器
|
||||
const progressList = document.getElementById('progressList');
|
||||
if (progressList.children.length === 0) {
|
||||
document.getElementById('transferProgress').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
256
web/static/js/p2p-transfer.js
Normal file
256
web/static/js/p2p-transfer.js
Normal file
@@ -0,0 +1,256 @@
|
||||
// P2P文件传输系统
|
||||
// 全局变量
|
||||
let websocket = null;
|
||||
let peerConnection = null;
|
||||
let dataChannel = null;
|
||||
let selectedFiles = [];
|
||||
let currentPickupCode = '';
|
||||
let currentRole = ''; // 'sender' or 'receiver'
|
||||
let fileTransfers = new Map(); // 存储文件传输状态
|
||||
let isP2PConnected = false; // P2P连接状态
|
||||
let isConnecting = false; // 是否正在连接中
|
||||
let connectionTimeout = null; // 连接超时定时器
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeEventListeners();
|
||||
});
|
||||
|
||||
// 初始化事件监听器
|
||||
function initializeEventListeners() {
|
||||
// 文件选择事件
|
||||
document.getElementById('fileInput').addEventListener('change', handleFileSelect);
|
||||
|
||||
// 取件码输入事件
|
||||
document.getElementById('pickupCodeInput').addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.toUpperCase();
|
||||
if (e.target.value.length === 6) {
|
||||
// 自动连接
|
||||
setTimeout(() => joinRoom(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 拖拽上传
|
||||
setupDragAndDrop();
|
||||
}
|
||||
|
||||
// 设置拖拽上传
|
||||
function setupDragAndDrop() {
|
||||
const dropArea = document.querySelector('.border-dashed');
|
||||
dropArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropArea.classList.add('border-blue-400');
|
||||
});
|
||||
|
||||
dropArea.addEventListener('dragleave', () => {
|
||||
dropArea.classList.remove('border-blue-400');
|
||||
});
|
||||
|
||||
dropArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropArea.classList.remove('border-blue-400');
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
selectedFiles = files;
|
||||
displaySelectedFiles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
function handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (files.length > 0) {
|
||||
selectedFiles = files;
|
||||
displaySelectedFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示选中的文件
|
||||
function displaySelectedFiles() {
|
||||
const container = document.getElementById('selectedFiles');
|
||||
const filesList = document.getElementById('filesList');
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
container.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
container.classList.remove('hidden');
|
||||
filesList.innerHTML = '';
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg';
|
||||
fileItem.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
|
||||
<div>
|
||||
<div class="font-medium">${file.name}</div>
|
||||
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeFile(${index})" class="text-red-500 hover:text-red-700 p-1">
|
||||
❌
|
||||
</button>
|
||||
`;
|
||||
filesList.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function removeFile(index) {
|
||||
selectedFiles.splice(index, 1);
|
||||
displaySelectedFiles();
|
||||
}
|
||||
|
||||
// 生成取件码
|
||||
async function generatePickupCode() {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// 准备文件信息
|
||||
const fileInfos = selectedFiles.map((file, index) => ({
|
||||
id: 'file_' + index,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ files: fileInfos })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
currentPickupCode = data.code;
|
||||
currentRole = 'sender';
|
||||
showPickupCode(data.code);
|
||||
connectWebSocket();
|
||||
} else {
|
||||
alert('生成取件码失败: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成取件码失败:', error);
|
||||
alert('生成取件码失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示取件码
|
||||
function showPickupCode(code) {
|
||||
document.getElementById('pickupCodeDisplay').textContent = code;
|
||||
document.getElementById('pickupCodeSection').classList.remove('hidden');
|
||||
document.getElementById('generateCodeBtn').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 复制取件码
|
||||
function copyPickupCode() {
|
||||
navigator.clipboard.writeText(currentPickupCode).then(() => {
|
||||
alert('取件码已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
// 重置发送方
|
||||
function resetSender() {
|
||||
selectedFiles = [];
|
||||
currentPickupCode = '';
|
||||
currentRole = '';
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
document.getElementById('selectedFiles').classList.add('hidden');
|
||||
document.getElementById('pickupCodeSection').classList.add('hidden');
|
||||
document.getElementById('generateCodeBtn').classList.remove('hidden');
|
||||
document.getElementById('fileInput').value = '';
|
||||
}
|
||||
|
||||
// 加入房间
|
||||
async function joinRoom() {
|
||||
const code = document.getElementById('pickupCodeInput').value.trim();
|
||||
if (code.length !== 6) {
|
||||
alert('请输入6位取件码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentPickupCode = code;
|
||||
currentRole = 'receiver';
|
||||
displayReceiverFiles(data.files);
|
||||
connectWebSocket();
|
||||
} else {
|
||||
alert(data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
alert('连接失败,请检查取件码是否正确');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示接收方文件列表
|
||||
function displayReceiverFiles(files) {
|
||||
document.getElementById('codeInputSection').classList.add('hidden');
|
||||
document.getElementById('receiverFilesSection').classList.remove('hidden');
|
||||
|
||||
const filesList = document.getElementById('receiverFilesList');
|
||||
filesList.innerHTML = '';
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg';
|
||||
fileItem.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
|
||||
<div>
|
||||
<div class="font-medium">${file.name}</div>
|
||||
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="downloadFile('${file.id}')" disabled
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded font-semibold opacity-50 cursor-not-allowed">
|
||||
📥 下载
|
||||
</button>
|
||||
`;
|
||||
filesList.appendChild(fileItem);
|
||||
});
|
||||
|
||||
// 初始化时显示正在建立连接状态
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '🖼️';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar')) return '📦';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 页面卸载时清理
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
}
|
||||
});
|
||||
374
web/static/js/webrtc-connection.js
Normal file
374
web/static/js/webrtc-connection.js
Normal file
@@ -0,0 +1,374 @@
|
||||
// WebSocket和WebRTC连接管理
|
||||
|
||||
// WebSocket连接
|
||||
function connectWebSocket() {
|
||||
console.log('尝试连接WebSocket, 角色:', currentRole, '取件码:', currentPickupCode);
|
||||
|
||||
if (isConnecting) {
|
||||
console.log('已在连接中,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting = true;
|
||||
|
||||
// 如果已经有连接,先关闭
|
||||
if (websocket) {
|
||||
console.log('关闭现有WebSocket连接');
|
||||
websocket.close();
|
||||
websocket = null;
|
||||
}
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/p2p?code=${currentPickupCode}&role=${currentRole}`;
|
||||
console.log('WebSocket URL:', wsUrl);
|
||||
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(true);
|
||||
|
||||
// 发送方在WebSocket连接建立后立即初始化P2P(但不创建offer)
|
||||
if (currentRole === 'sender') {
|
||||
console.log('发送方初始化P2P连接(等待接收方就绪)');
|
||||
initPeerConnectionForSender();
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
await handleWebSocketMessage(message);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(false);
|
||||
updateP2PStatus(false);
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(false);
|
||||
updateP2PStatus(false);
|
||||
websocket = null;
|
||||
|
||||
// 如果不是正常关闭且还需要连接,尝试重连
|
||||
if (event.code !== 1000 && currentPickupCode && !isConnecting) {
|
||||
console.log('WebSocket异常关闭,5秒后尝试重连');
|
||||
setTimeout(() => {
|
||||
if (currentPickupCode && !websocket && !isConnecting) {
|
||||
console.log('尝试重新连接WebSocket');
|
||||
connectWebSocket();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
function updateConnectionStatus(connected) {
|
||||
const senderStatus = document.getElementById('senderStatus');
|
||||
const receiverStatus = document.getElementById('receiverStatus');
|
||||
|
||||
if (currentRole === 'sender' && senderStatus) {
|
||||
senderStatus.innerHTML = connected ?
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
接收方已连接
|
||||
</div>` :
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
|
||||
等待接收方连接...
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 为发送方初始化P2P连接(不立即创建offer)
|
||||
function initPeerConnectionForSender() {
|
||||
console.log('为发送方初始化P2P连接(等待接收方就绪)');
|
||||
|
||||
// 清除之前的超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
}
|
||||
|
||||
// 设置连接超时(60秒,合理的超时时间)
|
||||
connectionTimeout = setTimeout(() => {
|
||||
console.error('P2P连接超时(60秒)');
|
||||
if (peerConnection && !isP2PConnected) {
|
||||
console.log('关闭超时的P2P连接');
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
updateP2PStatus(false);
|
||||
alert('P2P连接超时,请检查网络连接并重试');
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// 使用国内优化的WebRTC配置
|
||||
peerConnection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
// 阿里云和腾讯STUN服务器
|
||||
{ urls: 'stun:stun.chat.bilibili.com:3478' },
|
||||
{ urls: 'stun:stun.voipbuster.com' },
|
||||
{ urls: 'stun:stun.voipstunt.com' },
|
||||
{ urls: 'stun:stun.qq.com:3478' },
|
||||
// 备用国外服务器
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
],
|
||||
iceCandidatePoolSize: 10
|
||||
});
|
||||
|
||||
// 连接状态监听
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log('P2P连接状态:', peerConnection.connectionState);
|
||||
if (peerConnection.connectionState === 'connected') {
|
||||
console.log('P2P连接建立成功');
|
||||
isP2PConnected = true;
|
||||
updateP2PStatus(true);
|
||||
|
||||
// 清除连接超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
} else if (peerConnection.connectionState === 'failed') {
|
||||
console.error('P2P连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ICE连接状态监听
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log('ICE连接状态:', peerConnection.iceConnectionState);
|
||||
if (peerConnection.iceConnectionState === 'failed') {
|
||||
console.error('ICE连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建数据通道
|
||||
dataChannel = peerConnection.createDataChannel('fileTransfer', {
|
||||
ordered: true
|
||||
});
|
||||
setupDataChannel(dataChannel);
|
||||
|
||||
// 处理ICE候选
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('发送ICE候选:', event.candidate.candidate);
|
||||
sendWebSocketMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
} else {
|
||||
console.log('ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 创建offer(发送方专用)
|
||||
function createOffer() {
|
||||
if (!peerConnection) {
|
||||
console.error('PeerConnection未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('发送方创建 offer');
|
||||
|
||||
peerConnection.createOffer().then(offer => {
|
||||
console.log('Offer 创建成功');
|
||||
return peerConnection.setLocalDescription(offer);
|
||||
}).then(() => {
|
||||
console.log('本地描述设置成功,发送 offer');
|
||||
sendWebSocketMessage({
|
||||
type: 'offer',
|
||||
payload: peerConnection.localDescription
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('创建 offer 失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化P2P连接(接收方使用)
|
||||
function initPeerConnection() {
|
||||
console.log('接收方初始化P2P连接');
|
||||
|
||||
// 清除之前的超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
}
|
||||
|
||||
// 设置连接超时(60秒)
|
||||
connectionTimeout = setTimeout(() => {
|
||||
console.error('P2P连接超时(60秒)');
|
||||
if (peerConnection && !isP2PConnected) {
|
||||
console.log('关闭超时的P2P连接');
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
updateP2PStatus(false);
|
||||
alert('P2P连接超时,请检查网络连接并重试');
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// 使用国内优化配置
|
||||
peerConnection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
// 阿里云和腾讯STUN服务器
|
||||
{ urls: 'stun:stun.chat.bilibili.com:3478' },
|
||||
{ urls: 'stun:stun.voipbuster.com' },
|
||||
{ urls: 'stun:stun.voipstunt.com' },
|
||||
{ urls: 'stun:stun.qq.com:3478' },
|
||||
// 备用国外服务器
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
],
|
||||
iceCandidatePoolSize: 10
|
||||
});
|
||||
|
||||
// 连接状态监听
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log('P2P连接状态:', peerConnection.connectionState);
|
||||
if (peerConnection.connectionState === 'connected') {
|
||||
console.log('P2P连接建立成功');
|
||||
isP2PConnected = true;
|
||||
updateP2PStatus(true);
|
||||
|
||||
// 清除连接超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
} else if (peerConnection.connectionState === 'failed') {
|
||||
console.error('P2P连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ICE连接状态监听
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log('ICE连接状态:', peerConnection.iceConnectionState);
|
||||
if (peerConnection.iceConnectionState === 'failed') {
|
||||
console.error('ICE连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理数据通道
|
||||
peerConnection.ondatachannel = (event) => {
|
||||
console.log('接收到数据通道');
|
||||
const channel = event.channel;
|
||||
setupDataChannel(channel);
|
||||
};
|
||||
|
||||
// 处理ICE候选
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('发送ICE候选:', event.candidate.candidate);
|
||||
sendWebSocketMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
} else {
|
||||
console.log('ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 处理WebSocket消息
|
||||
async function handleWebSocketMessage(message) {
|
||||
console.log('收到WebSocket消息:', message.type);
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'offer':
|
||||
console.log('处理 offer');
|
||||
// 确保接收方的peerConnection已初始化
|
||||
if (!peerConnection) {
|
||||
console.log('接收方peerConnection未初始化,先初始化');
|
||||
initPeerConnection();
|
||||
// 等待一小段时间让peerConnection完全初始化
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('远程描述设置成功,创建 answer');
|
||||
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
console.log('本地描述设置成功,发送 answer');
|
||||
|
||||
sendWebSocketMessage({
|
||||
type: 'answer',
|
||||
payload: answer
|
||||
});
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
console.log('处理 answer');
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('远程 answer 设置成功');
|
||||
} else {
|
||||
console.error('收到answer但peerConnection未初始化');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
console.log('处理 ICE 候选:', message.payload.candidate);
|
||||
if (peerConnection && peerConnection.remoteDescription) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(message.payload));
|
||||
console.log('ICE 候选添加成功');
|
||||
} catch (error) {
|
||||
console.error('添加ICE候选失败:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('收到ICE候选但远程描述未设置,暂时缓存');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-list':
|
||||
if (currentRole === 'receiver') {
|
||||
console.log('接收到文件列表');
|
||||
displayReceiverFiles(message.payload.files);
|
||||
// 接收方在收到文件列表后初始化P2P连接
|
||||
if (!peerConnection) {
|
||||
console.log('初始化接收方P2P连接');
|
||||
initPeerConnection();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'receiver-ready':
|
||||
if (currentRole === 'sender') {
|
||||
console.log('接收方已连接,创建offer');
|
||||
// 发送方现在可以创建offer了
|
||||
setTimeout(() => {
|
||||
if (peerConnection && !isP2PConnected) {
|
||||
createOffer();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理WebSocket消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送WebSocket消息
|
||||
function sendWebSocketMessage(message) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket未连接,无法发送消息:', message.type);
|
||||
}
|
||||
}
|
||||
80
web/templates/base.html
Normal file
80
web/templates/base.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - 文件传输系统</title>
|
||||
|
||||
<!-- SEO优化 -->
|
||||
<meta name="description" content="安全快速的P2P文件传输系统,支持大文件上传、视频流传输、取件码分享">
|
||||
<meta name="keywords" content="文件传输,P2P,WebRTC,大文件上传,视频传输,取件码">
|
||||
<meta name="author" content="文件传输系统">
|
||||
|
||||
<!-- 移动端优化 -->
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<!-- 使用Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
|
||||
<!-- WebRTC兼容性 -->
|
||||
<script>
|
||||
// WebRTC 兼容性检查
|
||||
window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
|
||||
window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
|
||||
window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-900">
|
||||
📁 文件传输
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
首页
|
||||
</a>
|
||||
<a href="/upload" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
上传文件
|
||||
</a>
|
||||
<a href="/video" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
视频传输
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="bg-white border-t mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center text-gray-500 text-sm">
|
||||
<p>© 2024 文件传输系统. 支持P2P传输、WebRTC视频、大文件上传</p>
|
||||
<p class="mt-2">
|
||||
兼容主流浏览器: Chrome, Safari, Firefox, Edge, 360浏览器, QQ浏览器
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 通用JavaScript -->
|
||||
<script src="/static/js/common.js"></script>
|
||||
|
||||
<!-- 页面特定脚本 -->
|
||||
{{template "scripts" .}}
|
||||
</body>
|
||||
</html>
|
||||
115
web/templates/index.html
Normal file
115
web/templates/index.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{{define "content"}}
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-800 mb-2">P2P文件传输</h1>
|
||||
<p class="text-gray-600">选择文件自动生成取件码,对方输入取件码即可在线下载</p>
|
||||
</div>
|
||||
|
||||
<!-- 主界面 -->
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<!-- 发送文件区域 -->
|
||||
<div id="senderSection" class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">📤 发送文件</h3>
|
||||
|
||||
<!-- 文件选择区域 -->
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
onclick="document.getElementById('fileInput').click()">
|
||||
<div class="text-6xl mb-4">📁</div>
|
||||
<p class="text-lg mb-2">点击选择文件或拖拽文件到此处</p>
|
||||
<p class="text-gray-500">支持多文件选择</p>
|
||||
<input type="file" id="fileInput" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- 选中的文件列表 -->
|
||||
<div id="selectedFiles" class="mt-6 hidden">
|
||||
<h4 class="font-semibold mb-3">已选择的文件:</h4>
|
||||
<div id="filesList" class="space-y-2 max-h-60 overflow-y-auto"></div>
|
||||
<div class="mt-4 text-center">
|
||||
<button id="generateCodeBtn" onclick="generatePickupCode()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold">
|
||||
🎯 生成取件码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 取件码显示 -->
|
||||
<div id="pickupCodeSection" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg hidden">
|
||||
<div class="text-center">
|
||||
<h4 class="font-semibold text-green-800 mb-2">取件码已生成</h4>
|
||||
<div class="text-3xl font-mono font-bold text-green-600 mb-2" id="pickupCodeDisplay"></div>
|
||||
<p class="text-green-700 mb-4">请将此取件码发送给对方</p>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button onclick="copyPickupCode()" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
|
||||
📋 复制取件码
|
||||
</button>
|
||||
<button onclick="resetSender()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded">
|
||||
🔄 重新选择文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接状态 -->
|
||||
<div id="senderStatus" class="mt-4 text-center">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
|
||||
等待接收方连接...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="border-t border-gray-200 my-8"></div>
|
||||
|
||||
<!-- 接收文件区域 -->
|
||||
<div id="receiverSection">
|
||||
<h3 class="text-xl font-semibold mb-4">📥 接收文件</h3>
|
||||
|
||||
<!-- 取件码输入 -->
|
||||
<div id="codeInputSection">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="flex flex-col items-center max-w-md w-full">
|
||||
<input type="text" id="pickupCodeInput" placeholder="输入6位取件码" maxlength="6"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg text-center text-2xl font-mono font-bold uppercase mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<button onclick="joinRoom()" class="bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-lg font-semibold">
|
||||
🔗 连接并获取文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表显示 -->
|
||||
<div id="receiverFilesSection" class="hidden">
|
||||
<h4 class="font-semibold mb-3">可下载的文件:</h4>
|
||||
<div id="receiverFilesList" class="space-y-2"></div>
|
||||
|
||||
<!-- 接收状态 -->
|
||||
<div id="receiverStatus" class="mt-4 text-center">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
已连接,可以下载文件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输进度 -->
|
||||
<div id="transferProgress" class="bg-white rounded-xl shadow-lg p-6 hidden">
|
||||
<h3 class="text-xl font-semibold mb-4">传输进度</h3>
|
||||
<div id="progressList" class="space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<!-- P2P文件传输相关脚本 -->
|
||||
<script src="/static/js/p2p-transfer.js"></script>
|
||||
<script src="/static/js/webrtc-connection.js"></script>
|
||||
<script src="/static/js/file-transfer.js"></script>
|
||||
{{end}}
|
||||
353
web/templates/upload.html
Normal file
353
web/templates/upload.html
Normal file
@@ -0,0 +1,353 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 页面标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">📤 文件上传</h1>
|
||||
<p class="text-lg text-gray-600">
|
||||
支持拖拽上传、多文件批量上传、进度显示。最大支持64TB文件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<!-- 上传成功提示 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-8">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">✅</div>
|
||||
<h2 class="text-xl font-bold text-green-800 mb-4">文件上传成功!</h2>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border mb-4">
|
||||
<div class="text-left space-y-2">
|
||||
<p><strong>文件名:</strong>{{.FileInfo.FileName}}</p>
|
||||
<p><strong>文件大小:</strong><span id="fileSize">{{.FileInfo.FileSize}}</span> 字节</p>
|
||||
<p><strong>上传时间:</strong>{{.FileInfo.UploadTime.Format "2006-01-02 15:04:05"}}</p>
|
||||
<p><strong>过期时间:</strong>{{.FileInfo.ExpiryTime.Format "2006-01-02 15:04:05"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 p-4 rounded-lg mb-4">
|
||||
<p class="text-sm text-gray-600 mb-2">您的取件码:</p>
|
||||
<div class="text-3xl font-bold text-blue-600 mb-2" id="shareCode">{{.FileInfo.Code}}</div>
|
||||
<button onclick="copyCode('{{.FileInfo.Code}}')" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded text-sm">
|
||||
📋 复制取件码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="{{.FileInfo.DownloadURL}}" class="bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-lg inline-block">
|
||||
⬇️ 直接下载
|
||||
</a>
|
||||
<br>
|
||||
<button onclick="shareFile('{{.FileInfo.Code}}')" class="bg-purple-500 hover:bg-purple-600 text-white px-6 py-3 rounded-lg">
|
||||
🔗 分享文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-8">
|
||||
<form id="uploadForm" enctype="multipart/form-data" method="post">
|
||||
<!-- 拖拽上传区域 -->
|
||||
<div id="dropZone" class="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-blue-400 transition-colors">
|
||||
<div id="dropZoneContent">
|
||||
<div class="text-6xl mb-4">📁</div>
|
||||
<h3 class="text-xl font-semibold mb-2">拖拽文件到此处</h3>
|
||||
<p class="text-gray-600 mb-4">或者点击选择文件</p>
|
||||
<input type="file" id="fileInput" name="file" multiple class="hidden">
|
||||
<button type="button" onclick="document.getElementById('fileInput').click()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold">
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div id="fileList" class="mt-6 hidden">
|
||||
<h3 class="text-lg font-semibold mb-3">选中的文件:</h3>
|
||||
<div id="files"></div>
|
||||
</div>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<div id="uploadSection" class="mt-6 text-center hidden">
|
||||
<button type="submit" id="uploadBtn"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-lg font-semibold text-lg">
|
||||
🚀 开始上传
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div id="uploadProgress" class="mt-6 hidden">
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<span>上传进度</span>
|
||||
<span id="progressText">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadStatus" class="text-center text-gray-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 格式转换 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-8 mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">📚 Kindle格式转换</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
专为Kindle用户提供EPUB到MOBI格式转换服务。上传EPUB文件后自动转换为MOBI格式。
|
||||
</p>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p class="text-yellow-800">
|
||||
💡 <strong>提示:</strong>格式转换功能正在开发中,敬请期待。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术说明 -->
|
||||
<div class="bg-gray-50 rounded-lg p-8 mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">🔧 技术特性</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">✨ 拖拽上传</h3>
|
||||
<p class="text-gray-600 text-sm">支持拖拽文件到页面,HTML5 File API优化用户体验。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">📊 进度显示</h3>
|
||||
<p class="text-gray-600 text-sm">实时显示上传进度,支持多文件批量上传。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">🔄 断点续传</h3>
|
||||
<p class="text-gray-600 text-sm">大文件分片上传,网络中断后可继续上传。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">🛡️ 安全存储</h3>
|
||||
<p class="text-gray-600 text-sm">24小时自动清理,保护用户隐私和服务器空间。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// 文件上传相关变量
|
||||
let selectedFiles = [];
|
||||
let uploadInProgress = false;
|
||||
|
||||
// DOM元素
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileList = document.getElementById('fileList');
|
||||
const filesContainer = document.getElementById('files');
|
||||
const uploadSection = document.getElementById('uploadSection');
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
const uploadProgress = document.getElementById('uploadProgress');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const uploadStatus = document.getElementById('uploadStatus');
|
||||
|
||||
// 拖拽事件处理
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// 文件选择事件
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// 处理选中的文件
|
||||
function handleFiles(files) {
|
||||
selectedFiles = files;
|
||||
displayFiles();
|
||||
|
||||
if (files.length > 0) {
|
||||
fileList.classList.remove('hidden');
|
||||
uploadSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示文件列表
|
||||
function displayFiles() {
|
||||
filesContainer.innerHTML = '';
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg mb-2';
|
||||
|
||||
fileDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
|
||||
<div>
|
||||
<div class="font-medium">${file.name}</div>
|
||||
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="removeFile(${index})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
❌
|
||||
</button>
|
||||
`;
|
||||
|
||||
filesContainer.appendChild(fileDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function removeFile(index) {
|
||||
selectedFiles.splice(index, 1);
|
||||
displayFiles();
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
fileList.classList.add('hidden');
|
||||
uploadSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件图标
|
||||
function getFileIcon(type) {
|
||||
if (type.startsWith('image/')) return '🖼️';
|
||||
if (type.startsWith('video/')) return '🎥';
|
||||
if (type.startsWith('audio/')) return '🎵';
|
||||
if (type.includes('pdf')) return '📄';
|
||||
if (type.includes('text')) return '📝';
|
||||
if (type.includes('zip') || type.includes('rar')) return '📦';
|
||||
return '📁';
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 表单提交处理
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (uploadInProgress || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadInProgress = true;
|
||||
showUploadProgress();
|
||||
|
||||
try {
|
||||
// 这里简化处理,实际应该支持分片上传
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFiles[0]); // 暂时只处理第一个文件
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// 监听上传进度
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = (e.loaded / e.total) * 100;
|
||||
updateProgress(percentComplete);
|
||||
}
|
||||
});
|
||||
|
||||
// 上传完成处理
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
updateProgress(100);
|
||||
uploadStatus.textContent = '上传成功!正在跳转...';
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
xhr.addEventListener('error', () => {
|
||||
throw new Error('网络错误');
|
||||
});
|
||||
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
|
||||
} catch (error) {
|
||||
uploadStatus.textContent = '上传失败: ' + error.message;
|
||||
uploadInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示上传进度
|
||||
function showUploadProgress() {
|
||||
uploadProgress.classList.remove('hidden');
|
||||
uploadSection.classList.add('hidden');
|
||||
uploadStatus.textContent = '正在上传文件...';
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
function updateProgress(percent) {
|
||||
progressBar.style.width = percent + '%';
|
||||
progressText.textContent = Math.round(percent) + '%';
|
||||
}
|
||||
|
||||
// 复制取件码
|
||||
function copyCode(code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
alert('取件码已复制到剪贴板: ' + code);
|
||||
}).catch(() => {
|
||||
// 兼容性处理
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = code;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
alert('取件码已复制到剪贴板: ' + code);
|
||||
});
|
||||
}
|
||||
|
||||
// 分享文件
|
||||
function shareFile(code) {
|
||||
const shareUrl = `${window.location.origin}/download/${code}`;
|
||||
const shareText = `文件分享\n取件码: ${code}\n下载链接: ${shareUrl}\n有效期: 24小时`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: '文件分享',
|
||||
text: shareText,
|
||||
url: shareUrl
|
||||
});
|
||||
} else {
|
||||
copyCode(shareText);
|
||||
alert('分享信息已复制到剪贴板');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时格式化文件大小
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileSizeElement = document.getElementById('fileSize');
|
||||
if (fileSizeElement) {
|
||||
const bytes = parseInt(fileSizeElement.textContent);
|
||||
fileSizeElement.textContent = formatFileSize(bytes);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
571
web/templates/video.html
Normal file
571
web/templates/video.html
Normal file
@@ -0,0 +1,571 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 页面标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">📺 实时视频传输</h1>
|
||||
<p class="text-lg text-gray-600">
|
||||
基于WebRTC的P2P视频传输,低延迟高画质,支持多人连接。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 连接状态 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div id="connectionStatus" class="w-3 h-3 rounded-full bg-red-500 mr-3"></div>
|
||||
<span id="statusText" class="font-medium">未连接</span>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<button id="connectBtn" onclick="connectToRoom()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded font-semibold">
|
||||
🔗 连接
|
||||
</button>
|
||||
<button id="disconnectBtn" onclick="disconnectFromRoom()"
|
||||
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded font-semibold hidden">
|
||||
❌ 断开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频区域 -->
|
||||
<div class="grid lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- 本地视频 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">本地视频</h3>
|
||||
<div class="relative bg-gray-900 rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
|
||||
<video id="localVideo" autoplay muted playsinline
|
||||
class="w-full h-full object-cover">
|
||||
</video>
|
||||
<div id="localVideoOverlay" class="absolute inset-0 flex items-center justify-center text-white text-lg">
|
||||
📹 点击连接开启摄像头
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-x-2">
|
||||
<button id="toggleVideo" onclick="toggleVideo()"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm">
|
||||
📹 视频
|
||||
</button>
|
||||
<button id="toggleAudio" onclick="toggleAudio()"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm">
|
||||
🎤 音频
|
||||
</button>
|
||||
<button onclick="switchCamera()"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-2 rounded text-sm">
|
||||
🔄 切换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 远程视频 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">远程视频</h3>
|
||||
<div class="relative bg-gray-900 rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
|
||||
<video id="remoteVideo" autoplay playsinline
|
||||
class="w-full h-full object-cover">
|
||||
</video>
|
||||
<div id="remoteVideoOverlay" class="absolute inset-0 flex items-center justify-center text-white text-lg">
|
||||
📡 等待远程连接...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天区域 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">💬 文字聊天</h3>
|
||||
<div id="chatMessages" class="bg-gray-50 rounded-lg p-4 h-40 overflow-y-auto mb-4">
|
||||
<div class="text-gray-500 text-center">聊天消息将显示在这里...</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input type="text" id="chatInput" placeholder="输入消息..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<button onclick="sendMessage()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-r-lg">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术信息 -->
|
||||
<div class="bg-gray-50 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">🔧 连接信息</h3>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>连接状态:</strong>
|
||||
<span id="connectionState">未连接</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>ICE状态:</strong>
|
||||
<span id="iceState">new</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>视频编解码器:</strong>
|
||||
<span id="videoCodec">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>音频编解码器:</strong>
|
||||
<span id="audioCodec">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium">WebRTC统计信息</summary>
|
||||
<div id="rtcStats" class="mt-2 p-3 bg-white rounded text-xs font-mono">
|
||||
点击连接后显示详细统计信息...
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浏览器兼容性提示 -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-6">
|
||||
<h4 class="font-semibold text-yellow-800 mb-2">🌐 浏览器兼容性</h4>
|
||||
<p class="text-yellow-800 text-sm">
|
||||
本功能需要支持WebRTC的现代浏览器。推荐使用Chrome、Firefox、Safari、Edge最新版本。
|
||||
部分功能在360浏览器、QQ浏览器等国产浏览器中可能受限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// WebRTC相关变量
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let peerConnection = null;
|
||||
let websocket = null;
|
||||
let isConnected = false;
|
||||
let videoEnabled = true;
|
||||
let audioEnabled = true;
|
||||
|
||||
// DOM元素
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
const remoteVideo = document.getElementById('remoteVideo');
|
||||
const localVideoOverlay = document.getElementById('localVideoOverlay');
|
||||
const remoteVideoOverlay = document.getElementById('remoteVideoOverlay');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const chatMessages = document.getElementById('chatMessages');
|
||||
const chatInput = document.getElementById('chatInput');
|
||||
|
||||
// WebRTC配置
|
||||
const rtcConfig = {
|
||||
iceServers: [
|
||||
// 阿里云STUN服务器
|
||||
{ urls: 'stun:stun.chat.bilibili.com:3478' },
|
||||
{ urls: 'stun:stun.voipbuster.com' },
|
||||
{ urls: 'stun:stun.voipstunt.com' },
|
||||
// 腾讯云STUN服务器
|
||||
{ urls: 'stun:stun.qq.com:3478' },
|
||||
// 备用国外服务器
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// 连接到房间
|
||||
async function connectToRoom() {
|
||||
try {
|
||||
updateStatus('connecting', '正在连接...');
|
||||
|
||||
// 获取本地媒体流
|
||||
await getUserMedia();
|
||||
|
||||
// 建立WebSocket连接
|
||||
await connectWebSocket();
|
||||
|
||||
// 创建PeerConnection
|
||||
createPeerConnection();
|
||||
|
||||
updateStatus('connected', '已连接');
|
||||
connectBtn.classList.add('hidden');
|
||||
disconnectBtn.classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
updateStatus('error', '连接失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
function disconnectFromRoom() {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
}
|
||||
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(track => track.stop());
|
||||
localStream = null;
|
||||
}
|
||||
|
||||
localVideo.srcObject = null;
|
||||
remoteVideo.srcObject = null;
|
||||
|
||||
updateStatus('disconnected', '未连接');
|
||||
connectBtn.classList.remove('hidden');
|
||||
disconnectBtn.classList.add('hidden');
|
||||
|
||||
localVideoOverlay.style.display = 'flex';
|
||||
remoteVideoOverlay.style.display = 'flex';
|
||||
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
// 获取用户媒体
|
||||
async function getUserMedia() {
|
||||
try {
|
||||
const constraints = {
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
};
|
||||
|
||||
localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
localVideo.srcObject = localStream;
|
||||
localVideoOverlay.style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
throw new Error('无法访问摄像头或麦克风: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 建立WebSocket连接
|
||||
function connectWebSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/video`;
|
||||
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
resolve();
|
||||
};
|
||||
|
||||
websocket.onmessage = async (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
await handleWebSocketMessage(message);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
reject(new Error('WebSocket连接失败'));
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('WebSocket连接已关闭');
|
||||
if (isConnected) {
|
||||
updateStatus('error', '连接已断开');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 创建PeerConnection
|
||||
function createPeerConnection() {
|
||||
peerConnection = new RTCPeerConnection(rtcConfig);
|
||||
|
||||
// 添加本地流
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, localStream);
|
||||
});
|
||||
}
|
||||
|
||||
// 处理远程流
|
||||
peerConnection.ontrack = (event) => {
|
||||
remoteStream = event.streams[0];
|
||||
remoteVideo.srcObject = remoteStream;
|
||||
remoteVideoOverlay.style.display = 'none';
|
||||
};
|
||||
|
||||
// 处理ICE候选
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate && websocket) {
|
||||
sendWebSocketMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听连接状态
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
updateConnectionInfo();
|
||||
};
|
||||
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
updateConnectionInfo();
|
||||
};
|
||||
}
|
||||
|
||||
// 处理WebSocket消息
|
||||
async function handleWebSocketMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
console.log('收到欢迎消息:', message.payload);
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
await handleOffer(message.payload);
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
await handleAnswer(message.payload);
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
await handleICECandidate(message.payload);
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
displayChatMessage(message.payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('未知消息类型:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送WebSocket消息
|
||||
function sendWebSocketMessage(message) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理Offer
|
||||
async function handleOffer(offer) {
|
||||
try {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
sendWebSocketMessage({
|
||||
type: 'answer',
|
||||
payload: answer
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('处理Offer失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理Answer
|
||||
async function handleAnswer(answer) {
|
||||
try {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
} catch (error) {
|
||||
console.error('处理Answer失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理ICE候选
|
||||
async function handleICECandidate(candidate) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (error) {
|
||||
console.error('添加ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
function updateStatus(status, text) {
|
||||
statusText.textContent = text;
|
||||
connectionStatus.className = `w-3 h-3 rounded-full mr-3 ${getStatusColor(status)}`;
|
||||
isConnected = status === 'connected';
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'connected': return 'bg-green-500';
|
||||
case 'connecting': return 'bg-yellow-500';
|
||||
case 'error': return 'bg-red-500';
|
||||
default: return 'bg-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换视频
|
||||
function toggleVideo() {
|
||||
if (localStream) {
|
||||
videoEnabled = !videoEnabled;
|
||||
localStream.getVideoTracks().forEach(track => {
|
||||
track.enabled = videoEnabled;
|
||||
});
|
||||
|
||||
const btn = document.getElementById('toggleVideo');
|
||||
btn.textContent = videoEnabled ? '📹 视频' : '📹 关闭';
|
||||
btn.className = videoEnabled ?
|
||||
'bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm' :
|
||||
'bg-red-500 hover:bg-red-600 text-white px-3 py-2 rounded text-sm';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换音频
|
||||
function toggleAudio() {
|
||||
if (localStream) {
|
||||
audioEnabled = !audioEnabled;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = audioEnabled;
|
||||
});
|
||||
|
||||
const btn = document.getElementById('toggleAudio');
|
||||
btn.textContent = audioEnabled ? '🎤 音频' : '🎤 静音';
|
||||
btn.className = audioEnabled ?
|
||||
'bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm' :
|
||||
'bg-red-500 hover:bg-red-600 text-white px-3 py-2 rounded text-sm';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换摄像头
|
||||
async function switchCamera() {
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
||||
|
||||
if (videoDevices.length > 1) {
|
||||
// 简单的前后摄像头切换逻辑
|
||||
const currentDevice = videoTrack.getSettings().deviceId;
|
||||
const newDevice = videoDevices.find(device => device.deviceId !== currentDevice);
|
||||
|
||||
if (newDevice) {
|
||||
try {
|
||||
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: newDevice.deviceId },
|
||||
audio: true
|
||||
});
|
||||
|
||||
// 替换视频轨道
|
||||
const newVideoTrack = newStream.getVideoTracks()[0];
|
||||
if (peerConnection) {
|
||||
const sender = peerConnection.getSenders().find(s =>
|
||||
s.track && s.track.kind === 'video'
|
||||
);
|
||||
if (sender) {
|
||||
sender.replaceTrack(newVideoTrack);
|
||||
}
|
||||
}
|
||||
|
||||
videoTrack.stop();
|
||||
localStream.removeTrack(videoTrack);
|
||||
localStream.addTrack(newVideoTrack);
|
||||
localVideo.srcObject = localStream;
|
||||
|
||||
} catch (error) {
|
||||
console.error('切换摄像头失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送聊天消息
|
||||
function sendMessage() {
|
||||
const message = chatInput.value.trim();
|
||||
if (message && websocket) {
|
||||
sendWebSocketMessage({
|
||||
type: 'chat',
|
||||
payload: {
|
||||
text: message,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
displayChatMessage({
|
||||
text: message,
|
||||
sender: 'me',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
chatInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 显示聊天消息
|
||||
function displayChatMessage(messageData) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `mb-2 ${messageData.sender === 'me' ? 'text-right' : 'text-left'}`;
|
||||
|
||||
const time = new Date(messageData.timestamp).toLocaleTimeString();
|
||||
messageDiv.innerHTML = `
|
||||
<div class="inline-block max-w-xs lg:max-w-md px-3 py-2 rounded-lg ${
|
||||
messageData.sender === 'me' ?
|
||||
'bg-blue-500 text-white' :
|
||||
'bg-gray-200 text-gray-800'
|
||||
}">
|
||||
<div>${messageData.text}</div>
|
||||
<div class="text-xs opacity-75 mt-1">${time}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(messageDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
// 更新连接信息
|
||||
function updateConnectionInfo() {
|
||||
if (peerConnection) {
|
||||
document.getElementById('connectionState').textContent = peerConnection.connectionState;
|
||||
document.getElementById('iceState').textContent = peerConnection.iceConnectionState;
|
||||
|
||||
// 获取统计信息
|
||||
peerConnection.getStats().then(stats => {
|
||||
let videoCodec = '-';
|
||||
let audioCodec = '-';
|
||||
let statsText = '';
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'codec') {
|
||||
if (report.mimeType && report.mimeType.includes('video')) {
|
||||
videoCodec = report.mimeType.split('/')[1];
|
||||
} else if (report.mimeType && report.mimeType.includes('audio')) {
|
||||
audioCodec = report.mimeType.split('/')[1];
|
||||
}
|
||||
}
|
||||
|
||||
statsText += `${report.type}: ${JSON.stringify(report, null, 2)}\n\n`;
|
||||
});
|
||||
|
||||
document.getElementById('videoCodec').textContent = videoCodec;
|
||||
document.getElementById('audioCodec').textContent = audioCodec;
|
||||
document.getElementById('rtcStats').textContent = statsText;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天输入回车发送
|
||||
chatInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// 页面卸载时清理资源
|
||||
window.addEventListener('beforeunload', () => {
|
||||
disconnectFromRoom();
|
||||
});
|
||||
|
||||
// 检查浏览器兼容性
|
||||
if (!navigator.mediaDevices || !window.RTCPeerConnection) {
|
||||
alert('您的浏览器不支持WebRTC功能,请使用Chrome、Firefox、Safari或Edge最新版本。');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user