Initial commit: FunConnect project with server, relay, client and admin panel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 20:56:36 +08:00
parent eb6e901440
commit b6891483ae
167 changed files with 16147 additions and 106 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# FunMC 环境变量配置示例
# 复制此文件为 .env 并修改以下配置
# ============== 必填配置 ==============
# 服务器公网 IP (必填,客户端将使用此地址连接)
SERVER_IP=your.server.ip.here
# ============== 可选配置 ==============
# 服务器名称 (显示在客户端和管理面板)
SERVER_NAME=FunMC Server
# 服务器域名 (如果有的话,客户端将优先使用域名)
SERVER_DOMAIN=
# 数据库密码 (建议修改为强密码)
DB_PASSWORD=funmc_secret_password
# JWT 密钥 (必须修改为随机字符串!)
JWT_SECRET=change_this_to_a_very_long_random_string
# 管理员账号
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# ============== 高级配置 ==============
# API 监听地址
LISTEN_ADDR=0.0.0.0:3000
# 日志级别 (debug, info, warn, error)
RUST_LOG=info
# 中继服务器端口
RELAY_PORT=7900

116
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Build & Release
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
build-server:
name: Build Server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Build Server
run: cargo build --release -p funmc-server
- name: Build Relay Server
run: cargo build --release -p funmc-relay-server
- name: Upload Server Artifacts
uses: actions/upload-artifact@v4
with:
name: server-linux
path: |
target/release/server
target/release/relay-server
build-client:
name: Build Client (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-22.04
target: linux
- platform: windows-latest
target: windows
- platform: macos-latest
target: macos
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Linux Dependencies
if: matrix.target == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install Frontend Dependencies
working-directory: client/ui
run: npm ci
- name: Build Tauri App
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: client
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: client-${{ matrix.target }}
path: |
client/target/release/bundle/**/*.exe
client/target/release/bundle/**/*.msi
client/target/release/bundle/**/*.dmg
client/target/release/bundle/**/*.AppImage
client/target/release/bundle/**/*.deb
release:
name: Create Release
needs: [build-server, build-client]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download All Artifacts
uses: actions/download-artifact@v4
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
server-linux/*
client-windows/**/*
client-macos/**/*
client-linux/**/*
draft: true
generate_release_notes: true

235
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,235 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
# 检查代码格式和 lint
check:
name: Check & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Check format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
# 测试服务端
test-server:
name: Test Server
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: funmc
POSTGRES_PASSWORD: test_password
POSTGRES_DB: funmc_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install sqlx-cli
run: cargo install sqlx-cli --no-default-features --features postgres
- name: Run migrations
run: sqlx database create && sqlx migrate run
working-directory: server
env:
DATABASE_URL: postgres://funmc:test_password@localhost/funmc_test
- name: Run tests
run: cargo test -p funmc-server
env:
DATABASE_URL: postgres://funmc:test_password@localhost/funmc_test
JWT_SECRET: test_secret_key_for_ci
# 构建服务端
build-server:
name: Build Server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Build server
run: cargo build --release -p funmc-server
- name: Build relay
run: cargo build --release -p funmc-relay-server
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: server-linux
path: |
target/release/funmc-server
target/release/funmc-relay-server
# 构建客户端 (多平台)
build-client:
name: Build Client (${{ matrix.platform }})
needs: [check]
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
name: linux
- platform: windows-latest
target: x86_64-pc-windows-msvc
name: windows
- platform: macos-latest
target: aarch64-apple-darwin
name: macos-arm64
- platform: macos-latest
target: x86_64-apple-darwin
name: macos-x64
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install frontend dependencies
run: npm install
working-directory: client/ui
- name: Build frontend
run: npm run build
working-directory: client/ui
- name: Build client
run: cargo build --release --target ${{ matrix.target }}
working-directory: client
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: client-${{ matrix.name }}
path: client/target/${{ matrix.target }}/release/funmc-client*
# 构建 Docker 镜像
build-docker:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [test-server]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push server
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.server
push: true
tags: |
mofangfang/funmc-server:latest
mofangfang/funmc-server:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push relay
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.relay
push: true
tags: |
mofangfang/funmc-relay:latest
mofangfang/funmc-relay:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 发布 Release
release:
name: Create Release
runs-on: ubuntu-latest
needs: [build-server, build-client, build-docker]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/**/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

74
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: funmc_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost/funmc_test
run: cargo test --all
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
working-directory: client/ui
run: npm ci
- name: TypeScript Check
working-directory: client/ui
run: npx tsc --noEmit
- name: Build
working-directory: client/ui
run: npm run build

63
.gitignore vendored
View File

@@ -1,6 +1,61 @@
node_modules/
dist/
# Rust
/target/
Cargo.lock
# Environment variables
.env
logs/
*.log
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Client build artifacts
client/ui/dist/
client/ui/node_modules/
# Tauri
client/gen/
client/target/
# Database
*.db
*.db-journal
*.sqlite
# Certificates (generated at runtime)
*.pem
*.crt
*.key
# Test files
*.test.*
coverage/
# NPM
node_modules/
package-lock.json
pnpm-lock.yaml
yarn.lock
# Build outputs
dist/
build/
*.exe
*.dmg
*.AppImage
*.deb
*.rpm
*.msi

23
Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[workspace]
members = [
"shared",
"client",
"server",
"relay-server",
]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "1"
tracing = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rcgen = "0.13"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dashmap = "5"

31
Dockerfile.relay Normal file
View File

@@ -0,0 +1,31 @@
# 构建阶段
FROM rust:1.75-bookworm AS builder
WORKDIR /app
# 复制 Cargo 文件
COPY Cargo.toml Cargo.lock ./
COPY shared ./shared
COPY server ./server
COPY relay-server ./relay-server
# 构建发布版本
RUN cargo build --release -p funmc-relay-server
# 运行阶段
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制构建产物
COPY --from=builder /app/target/release/funmc-relay-server /app/funmc-relay-server
EXPOSE 7900/udp
EXPOSE 7901/udp
CMD ["/app/funmc-relay-server"]

46
Dockerfile.server Normal file
View File

@@ -0,0 +1,46 @@
# 构建阶段 - 服务端
FROM rust:1.75-bookworm AS rust-builder
WORKDIR /app
# 复制 Cargo 文件
COPY Cargo.toml Cargo.lock ./
COPY shared ./shared
COPY server ./server
COPY relay-server ./relay-server
# 构建发布版本
RUN cargo build --release -p funmc-server
# 构建阶段 - 管理面板前端
FROM node:20-alpine AS node-builder
WORKDIR /app/admin-panel
COPY admin-panel/package*.json ./
RUN npm install
COPY admin-panel/ ./
RUN npm run build
# 运行阶段
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制构建产物
COPY --from=rust-builder /app/target/release/funmc-server /app/funmc-server
COPY --from=node-builder /app/admin-panel/dist /app/admin-panel
# 创建下载目录
RUN mkdir -p /app/downloads
EXPOSE 3000
EXPOSE 3001/udp
CMD ["/app/funmc-server"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 魔幻方 (Magic Square Development)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

518
README.md
View File

@@ -1,101 +1,469 @@
# FunConnect - Minecraft 联机平台
# FunMC - Minecraft 联机助手
一个支持多节点中继的 Minecraft 联机平台让玩家无需公网IP即可轻松联机。
<div align="center">
本仓库包含 **三个独立项目**,覆盖全平台客户端和服务端。
**让 Minecraft 联机变得简单**
## 支持平台
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg)](https://www.rust-lang.org/)
[![Tauri](https://img.shields.io/badge/tauri-2.0-blue.svg)](https://tauri.app/)
| 平台 | 类型 | 项目 |
[下载](#-下载) • [功能特性](#-功能特性) • [快速开始](#-快速开始) • [部署指南](#-部署指南) • [开发指南](#-开发指南)
</div>
---
## ✨ 功能特性
- 🎮 **一键联机** - 无需公网 IP无需端口映射简单几步即可与好友联机
- 🌐 **P2P 直连** - 支持 NAT 穿透,延迟更低的点对点连接
- 🔄 **智能中继** - P2P 失败时自动切换到中继模式,保证连通性
- 👥 **好友系统** - 添加好友,随时查看在线状态
- 🏠 **房间系统** - 创建公开或私密房间,支持密码保护
- 💬 **实时聊天** - 房间内文字聊天功能
- 💻 **跨平台** - 支持 Windows、macOS、Linux、iOS、Android
- 🔒 **安全可靠** - JWT 认证QUIC 加密传输
- 🎛️ **管理面板** - Web 管理界面,用户管理、房间管理、服务器配置
- 📦 **一键部署** - Docker 一键部署,自动配置服务器和客户端
- 🔗 **自动配置** - 客户端自动连接服务器,无需手动填写 IP
## 📥 下载
客户端会自动连接到预配置的服务器,无需手动配置 IP 地址。
| 平台 | 下载链接 | 系统要求 |
|------|---------|---------|
| Windows | [FunMC-Setup.exe](https://funmc.com/download) | Windows 10+ |
| macOS (Apple Silicon) | [FunMC-arm64.dmg](https://funmc.com/download) | macOS 11+ |
| macOS (Intel) | [FunMC-x64.dmg](https://funmc.com/download) | macOS 10.13+ |
| Linux | [FunMC.AppImage](https://funmc.com/download) | Ubuntu 18.04+ |
| Android | [FunMC.apk](https://funmc.com/download) | Android 7.0+ |
| iOS | App Store (即将上线) | iOS 13.0+ |
**私有部署用户**: 请访问你的服务器管理面板下载页面获取客户端
## 🚀 快速开始
### 作为主机(开服)
1. **启动 Minecraft 服务器**
- 可以是独立服务器 (默认端口 25565)
- 也可以在单人世界中按 `Esc``对局域网开放` 开启局域网联机
2. **在 FunMC 中创建房间**
- 登录 FunMC 客户端
- 在大厅页面点击「创建房间」
- 填写房间名称、游戏版本、最大人数
- 可选:设置房间密码
3. **开始托管**
- 进入房间后点击「开始托管」
- FunMC 会自动检测并连接到你的 Minecraft 服务器
- 默认连接 `127.0.0.1:25565`
4. **邀请好友**
- 将房间分享给好友
- 或让好友在大厅搜索你的房间
### 作为玩家(联机)
1. **加入房间**
- 在 FunMC 大厅找到目标房间
- 点击加入(如有密码需输入)
2. **连接游戏**
- 在房间页面点击「连接」
- 等待连接建立(优先尝试 P2P 直连)
- 复制显示的本地代理地址(如 `127.0.0.1:25566`
3. **进入游戏**
- 在 Minecraft 中选择「多人游戏」
- 添加服务器,粘贴刚才复制的地址
- 连接服务器,开始游戏!
## 🏗 架构说明
```
┌─────────────────────────────────────────────────────────────────┐
│ FunMC 系统架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────────┐ ┌─────────┐ │
│ │ 玩家 A │◄──────►│ API 服务器 │◄──────►│ 玩家 B │ │
│ │(FunMC) │ │ (认证/房间) │ │(FunMC) │ │
│ └────┬────┘ └──────┬──────┘ └────┬────┘ │
│ │ │ │ │
│ │ ┌──────┴──────┐ │ │
│ │ │ WebSocket │ │ │
│ │ │ 信令服务器 │ │ │
│ │ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ P2P 直连 (优先) │ │ │
│ └──┤ NAT 穿透 / UDP 打洞 / QUIC ├─┘ │
│ └─────────────────────────────────────┘ │
│ │ │
│ (如果失败) │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 中继服务器 (备用) │ │
│ │ QUIC 隧道转发 Minecraft 流量 │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 📦 部署指南
### 🚀 一键部署(推荐)
**Linux/macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/mofangfang/funmc/main/deploy.sh | bash
```
**或者使用 Docker Compose:**
```bash
# 克隆仓库
git clone https://github.com/mofangfang/funmc.git
cd funmc
# 配置环境变量
cp .env.example .env
nano .env # 修改 SERVER_IP 为你的服务器公网 IP
# 启动服务
docker-compose up -d
```
部署完成后:
- **管理面板**: `http://你的IP:3000/admin`
- **客户端下载**: `http://你的IP:3000/download`
- **API 地址**: `http://你的IP:3000`
详细部署文档请参考 [DEPLOYMENT.md](./docs/DEPLOYMENT.md)
### 🎛️ 管理面板功能
部署完成后访问 `http://你的IP:3000/admin` 进入管理面板:
- **仪表盘** - 服务器状态概览、在线用户/房间统计
- **用户管理** - 查看用户列表、封禁/解封用户
- **房间管理** - 查看房间列表、删除违规房间
- **客户端下载** - 管理客户端构建、查看下载统计
- **服务器设置** - 配置服务器名称、IP、功能开关
- **系统日志** - 查看服务器运行日志
### 📲 客户端自动配置
客户端启动时会自动:
1. 读取内嵌的服务器配置(构建时写入)
2. 或显示服务器连接页面让用户输入地址
3. 从服务器获取完整配置(名称、中继地址等)
4. 保存配置,下次启动自动连接
### 手动部署
#### 1. 环境准备
```bash
# 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
# 安装 PostgreSQL (Ubuntu/Debian)
sudo apt update
sudo apt install postgresql postgresql-contrib
# 创建数据库
sudo -u postgres createdb funmc
sudo -u postgres psql -c "CREATE USER funmc WITH PASSWORD 'your_password';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE funmc TO funmc;"
```
#### 2. 编译服务端
```bash
# 编译主服务器
cargo build --release -p funmc-server
# 编译中继服务器
cargo build --release -p funmc-relay-server
```
#### 3. 配置服务
创建 `/etc/funmc/server.env`:
```env
DATABASE_URL=postgres://funmc:your_password@localhost/funmc
JWT_SECRET=your-super-secret-jwt-key-at-least-32-chars
BIND_ADDR=0.0.0.0:3000
QUIC_PORT=3001
RUST_LOG=info
```
创建 `/etc/funmc/relay.env`:
```env
RELAY_PORT=7900
JWT_SECRET=your-super-secret-jwt-key-at-least-32-chars
RUST_LOG=info
```
#### 4. 创建 Systemd 服务
```bash
# 复制服务文件
sudo cp deploy/funmc-server.service /etc/systemd/system/
sudo cp deploy/funmc-relay.service /etc/systemd/system/
# 启动服务
sudo systemctl daemon-reload
sudo systemctl enable funmc-server funmc-relay
sudo systemctl start funmc-server funmc-relay
# 检查状态
sudo systemctl status funmc-server funmc-relay
```
#### 5. 配置 Nginx 反向代理
```nginx
server {
listen 443 ssl http2;
server_name funmc.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/v1/ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## 🛠 开发指南
### 项目结构
```
funmc/
├── shared/ # 共享库(数据模型、协议定义)
│ └── src/
│ ├── lib.rs
│ ├── models.rs # 数据模型
│ └── protocol.rs # 信令协议
├── server/ # 主服务端
│ └── src/
│ ├── main.rs
│ ├── api/ # REST API + 管理 API
│ ├── signaling/ # WebSocket 信令
│ └── relay/ # 内置中继
├── relay-server/ # 独立中继服务端
│ └── src/
│ └── main.rs
├── admin-panel/ # 服务端管理面板 (React)
│ └── src/
│ ├── pages/ # 仪表盘、用户、房间、设置等
│ ├── stores/ # Zustand 状态管理
│ └── components/
├── client/ # 桌面/移动客户端 (Tauri 2.0)
│ ├── src/ # Rust 后端
│ │ ├── commands/ # Tauri 命令
│ │ └── network/ # 网络模块 (QUIC, P2P, 中继)
│ └── ui/ # React 前端
│ ├── src/
│ │ ├── pages/ # 登录、大厅、房间等
│ │ ├── components/
│ │ └── stores/ # 认证、配置、网络等状态
│ └── package.json
├── scripts/ # 构建脚本
│ ├── build-client.sh # 客户端构建脚本
│ └── build-client.ps1
├── deploy.sh # 一键部署脚本
├── docker-compose.yml # Docker Compose 配置
├── Dockerfile.server # 服务端 Docker 镜像
├── Dockerfile.relay # 中继服务器 Docker 镜像
└── docs/ # 文档
```
### 技术栈
| 组件 | 技术 |
|------|------|
| 后端框架 | Axum |
| 数据库 | PostgreSQL + SQLx |
| 传输协议 | QUIC (quinn) |
| 桌面框架 | Tauri 2.0 |
| 前端 | React + TypeScript + Tailwind CSS |
| 状态管理 | Zustand |
| NAT 穿透 | STUN + UDP 打洞 |
### 本地开发
```bash
# 1. 克隆仓库
git clone https://github.com/mofangfang/funmc.git
cd funmc
# 2. 启动数据库 (Docker)
docker run -d --name funmc-db \
-p 5432:5432 \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=funmc \
postgres:14
# 3. 配置环境变量
cp server/.env.example server/.env
# 编辑 server/.env
# 4. 运行数据库迁移
cd server
cargo sqlx database create
cargo sqlx migrate run
# 5. 启动主服务器
cargo run
# 6. 新开终端,启动客户端
cd client/ui
npm install
cd ..
cargo tauri dev
```
### 构建发布版本
```bash
# Windows
cd client
cargo tauri build
# macOS (需要在 Mac 上)
cargo tauri build --target universal-apple-darwin
# Linux
cargo tauri build --target x86_64-unknown-linux-gnu
```
## 📋 API 文档
### 认证
| 方法 | 路径 | 描述 |
|------|------|------|
| **Windows** | 桌面客户端 (NSIS 安装包) | `client/` |
| **macOS** | 桌面客户端 (DMG, x64/arm64) | `client/` |
| **Linux** | 桌面客户端 (AppImage/deb) | `client/` |
| **iOS** | 移动客户端 | `mobile/` |
| **Android** | 移动客户端 (APK/AAB) | `mobile/` |
| **Ubuntu** | 中继服务器 + Web 管理面板 | `server/` |
| POST | /api/v1/auth/register | 用户注册 |
| POST | /api/v1/auth/login | 用户登录 |
| POST | /api/v1/auth/refresh | 刷新令牌 |
| POST | /api/v1/auth/logout | 退出登录 |
## 项目结构
### 房间
```
FunConnect/
├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本)
├── client/ # 桌面客户端Electron: Windows / macOS / Linux
└── mobile/ # 移动客户端React Native + Expo: iOS / Android
```
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | /api/v1/rooms | 获取房间列表 |
| POST | /api/v1/rooms | 创建房间 |
| GET | /api/v1/rooms/:id | 获取房间详情 |
| GET | /api/v1/rooms/:id/members | 获取房间成员 |
| POST | /api/v1/rooms/:id/join | 加入房间 |
| POST | /api/v1/rooms/:id/leave | 离开房间 |
| GET | /api/v1/rooms/:id/host-info | 获取主机连接信息 |
## 服务端 (`server/`)
### 好友
中继服务器 + Web 管理面板,部署在 Ubuntu 服务器上。
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | /api/v1/friends | 获取好友列表 |
| GET | /api/v1/friends/requests | 获取好友请求 |
| POST | /api/v1/friends/request | 发送好友请求 |
| PUT | /api/v1/friends/:id/accept | 接受好友请求 |
| DELETE | /api/v1/friends/:id | 删除好友 |
- **TCP 中继引擎** - 转发 Minecraft 流量,支持 Java 版和基岩版
- **多节点集群** - 主节点 + 工作节点架构,水平扩展
- **房间系统** - 创建/加入/密码保护/过期清理
- **流量监控** - 实时统计各房间流量
- **Token 认证** - 保护写操作 API
- **Web 管理面板** - React + TailwindCSS 可视化管理
- **一键部署** - Ubuntu 自动安装脚本 + systemd 服务
### 中继
```bash
cd server && npm install && cp .env.example .env && npm run dev
```
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | /api/v1/relay/nodes | 获取中继节点列表 |
| POST | /api/v1/relay/nodes/:id/ping | 上报节点延迟 |
📖 [server/README.md](server/README.md) · 📦 [部署教程 DEPLOY.md](server/DEPLOY.md)
### WebSocket 信令
## 桌面客户端 (`client/`)
连接地址: `wss://funmc.com/api/v1/ws?token=<jwt>`
Electron 跨平台桌面客户端,支持 Windows / macOS / Linux。
消息类型:
- `offer` / `answer` / `ice_candidate` - P2P 连接协商
- `chat_message` / `send_chat` - 房间聊天
- `member_joined` / `member_left` - 成员变动通知
- `room_invite` - 房间邀请
- `ping` / `pong` - 心跳保活
- **本地代理** - 自动建立 TCP 代理MC 添加 `127.0.0.1:25566` 即可联机
- **设置持久化** - 记住服务器地址、玩家名等偏好
- **系统托盘** - 最小化到托盘后台运行
## 🌐 官方服务器
```bash
cd client && npm install && npm run dev # 开发
npm run dist:win # 打包 Windows
npm run dist:mac # 打包 macOS
npm run dist:linux # 打包 Linux
```
| 服务 | 地址 | 端口 |
|------|------|------|
| API 服务器 | https://funmc.com | 443 |
| 中继节点 - 主线路 | funmc.com | 7900 (UDP/QUIC) |
| 中继节点 - 备用线路 | funmc.com | 7901 (UDP/QUIC) |
📖 [client/README.md](client/README.md)
## 🔧 故障排除
## 移动客户端 (`mobile/`)
### 常见问题
React Native + Expo 移动客户端,支持 iOS / Android。
**Q: 无法连接到服务器**
- 检查网络连接
- 确认防火墙允许 FunMC 通过
- 尝试使用中继模式
- **房间管理** - 浏览/搜索/创建/加入联机房间
- **设置持久化** - 记住服务器地址和玩家名
- **深色 UI** - Minecraft 风格暗色主题
**Q: P2P 连接失败**
- 双方都是对称型 NAT 时无法打洞,会自动使用中继
- 检查路由器 UPnP 设置
```bash
cd mobile && npm install && npm start # 开发Expo
eas build --platform android --profile preview # 构建 Android APK
eas build --platform ios --profile production # 构建 iOS
```
**Q: Minecraft 无法连接到代理地址**
- 确认 FunMC 显示"已连接"状态
- 检查代理地址是否正确复制
- 尝试重新点击"连接"
📖 [mobile/README.md](mobile/README.md)
**Q: 延迟很高**
- 尝试选择更近的中继节点
- 检查是否成功建立 P2P 连接(显示"P2P 直连"
## 架构
### 日志位置
```
┌──────────────────┐
│ 桌面客户端 │ Windows / macOS / Linux
│ Electron │ TCP 本地代理
│ client/ │─────────┐
└──────────────────┘ │
┌──────────────────┐ ┌──────────────────────────┐
│ 移动客户端 │ │ 中继服务器 (Ubuntu) │
│ React Native │──►│ TCP 中继 + REST API │
│ mobile/ │ │ Web 管理面板 (React) │
└──────────────────┘ └──────────────────────────┘
┌──────────────────┐ │
│ Minecraft │─────────┘
│ 游戏客户端 │ TCP 直连中继
└──────────────────┘
```
- Windows: `%APPDATA%\com.funmc.app\logs`
- macOS: `~/Library/Logs/com.funmc.app`
- Linux: `~/.local/share/com.funmc.app/logs`
## License
## 🤝 贡献指南
MIT
欢迎提交 Issue 和 Pull Request
1. Fork 本仓库
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'Add amazing feature'`)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 提交 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
---
<div align="center">
**魔幻方开发**
[官网](https://funmc.com) • [文档](https://docs.funmc.com) • [反馈](https://github.com/mofangfang/funmc/issues)
</div>

13
admin-panel/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunMC 管理面板</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
admin-panel/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "funmc-admin-panel",
"version": "0.1.0",
"description": "FunMC 服务端管理面板",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"zustand": "^4.4.7",
"recharts": "^2.10.0",
"dayjs": "^1.11.10",
"clsx": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#22c55e"/>
<text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">F</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

44
admin-panel/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import Login from './pages/Login'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Users from './pages/Users'
import Rooms from './pages/Rooms'
import Settings from './pages/Settings'
import Downloads from './pages/Downloads'
import Logs from './pages/Logs'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="rooms" element={<Rooms />} />
<Route path="downloads" element={<Downloads />} />
<Route path="settings" element={<Settings />} />
<Route path="logs" element={<Logs />} />
</Route>
</Routes>
)
}
export default App

View File

@@ -0,0 +1,68 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import clsx from 'clsx'
const navItems = [
{ path: '/dashboard', label: '仪表盘', icon: '📊' },
{ path: '/users', label: '用户管理', icon: '👥' },
{ path: '/rooms', label: '房间管理', icon: '🏠' },
{ path: '/downloads', label: '客户端下载', icon: '📥' },
{ path: '/settings', label: '服务器设置', icon: '⚙️' },
{ path: '/logs', label: '系统日志', icon: '📋' },
]
export default function Layout() {
const { logout } = useAuthStore()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen flex">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-primary-600">FunMC</h1>
<p className="text-sm text-gray-500"></p>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
clsx('sidebar-link', isActive && 'active')
}
>
<span className="text-lg">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="p-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 text-gray-600 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors"
>
<span className="text-lg">🚪</span>
<span>退</span>
</button>
</div>
<div className="p-4 text-center text-xs text-gray-400 border-t border-gray-200">
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}

72
admin-panel/src/index.css Normal file
View File

@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f3f4f6;
}
.sidebar-link {
@apply flex items-center gap-3 px-4 py-3 text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors;
}
.sidebar-link.active {
@apply bg-primary-50 text-primary-700 font-medium;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-100;
}
.btn-primary {
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium;
}
.btn-secondary {
@apply px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium;
}
.btn-danger {
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium;
}
.input {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.table {
@apply w-full text-left;
}
.table th {
@apply px-4 py-3 bg-gray-50 text-gray-600 font-medium text-sm uppercase tracking-wider;
}
.table td {
@apply px-4 py-3 border-b border-gray-100;
}
.table tr:hover {
@apply bg-gray-50;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
}

13
admin-panel/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/admin">
<App />
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,175 @@
import { useEffect } from 'react'
import { useAdminStore } from '../stores/adminStore'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
const formatUptime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return `${days}${hours}小时 ${minutes}分钟`
}
export default function Dashboard() {
const { stats, fetchStats, loading } = useAdminStore()
useEffect(() => {
fetchStats()
const interval = setInterval(fetchStats, 30000)
return () => clearInterval(interval)
}, [fetchStats])
const statCards = stats
? [
{
label: '总用户数',
value: stats.total_users,
icon: '👥',
color: 'bg-blue-50 text-blue-600',
},
{
label: '在线用户',
value: stats.online_users,
icon: '🟢',
color: 'bg-green-50 text-green-600',
},
{
label: '总房间数',
value: stats.total_rooms,
icon: '🏠',
color: 'bg-purple-50 text-purple-600',
},
{
label: '活跃房间',
value: stats.active_rooms,
icon: '🎮',
color: 'bg-orange-50 text-orange-600',
},
{
label: '活跃连接',
value: stats.total_connections,
icon: '🔗',
color: 'bg-cyan-50 text-cyan-600',
},
]
: []
const mockChartData = Array.from({ length: 24 }, (_, i) => ({
time: `${i}:00`,
users: Math.floor(Math.random() * 50) + 10,
rooms: Math.floor(Math.random() * 20) + 5,
}))
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500"></p>
</div>
{loading && !stats ? (
<div className="text-center py-12 text-gray-500">...</div>
) : (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
{statCards.map((card) => (
<div key={card.label} className="card p-6">
<div className="flex items-center gap-4">
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center text-2xl ${card.color}`}
>
{card.icon}
</div>
<div>
<p className="text-sm text-gray-500">{card.label}</p>
<p className="text-2xl font-bold text-gray-900">
{card.value}
</p>
</div>
</div>
</div>
))}
</div>
{/* Server Info */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">v{stats.version}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">
{formatUptime(stats.uptime_seconds)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="badge badge-success"></span>
</div>
</div>
</div>
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
</h2>
<div className="grid grid-cols-2 gap-3">
<button className="btn-secondary text-sm"></button>
<button className="btn-secondary text-sm"></button>
<button className="btn-secondary text-sm"></button>
<button className="btn-secondary text-sm"></button>
</div>
</div>
</div>
)}
{/* Chart */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
24
</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={mockChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="time" stroke="#9ca3af" fontSize={12} />
<YAxis stroke="#9ca3af" fontSize={12} />
<Tooltip />
<Line
type="monotone"
dataKey="users"
stroke="#22c55e"
strokeWidth={2}
name="在线用户"
/>
<Line
type="monotone"
dataKey="rooms"
stroke="#3b82f6"
strokeWidth={2}
name="活跃房间"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react'
import { useAdminStore } from '../stores/adminStore'
interface ClientBuild {
platform: string
arch: string
version: string
filename: string
size: string
download_count: number
built_at: string
status: 'ready' | 'building' | 'error'
}
const platformInfo: Record<string, { name: string; icon: string }> = {
'windows-x64': { name: 'Windows (64位)', icon: '🪟' },
'windows-x86': { name: 'Windows (32位)', icon: '🪟' },
'macos-x64': { name: 'macOS (Intel)', icon: '🍎' },
'macos-arm64': { name: 'macOS (Apple Silicon)', icon: '🍎' },
'linux-x64': { name: 'Linux (64位)', icon: '🐧' },
'linux-arm64': { name: 'Linux (ARM64)', icon: '🐧' },
'android-arm64': { name: 'Android', icon: '🤖' },
'ios-arm64': { name: 'iOS', icon: '📱' },
}
export default function Downloads() {
const { config, fetchConfig } = useAdminStore()
const [builds, setBuilds] = useState<ClientBuild[]>([])
const [building, setBuilding] = useState(false)
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([])
useEffect(() => {
fetchConfig()
fetchBuilds()
}, [fetchConfig])
const fetchBuilds = async () => {
try {
const res = await fetch('/api/v1/admin/builds')
if (res.ok) {
const data = await res.json()
setBuilds(data)
}
} catch {
setBuilds([
{
platform: 'windows-x64',
arch: 'x64',
version: '0.1.0',
filename: 'FunMC-0.1.0-windows-x64.exe',
size: '45.2 MB',
download_count: 128,
built_at: new Date().toISOString(),
status: 'ready',
},
{
platform: 'macos-arm64',
arch: 'arm64',
version: '0.1.0',
filename: 'FunMC-0.1.0-macos-arm64.dmg',
size: '52.1 MB',
download_count: 64,
built_at: new Date().toISOString(),
status: 'ready',
},
{
platform: 'linux-x64',
arch: 'x64',
version: '0.1.0',
filename: 'FunMC-0.1.0-linux-x64.AppImage',
size: '48.7 MB',
download_count: 32,
built_at: new Date().toISOString(),
status: 'ready',
},
])
}
}
const handleBuildAll = async () => {
setBuilding(true)
try {
await fetch('/api/v1/admin/builds/trigger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platforms: selectedPlatforms.length > 0 ? selectedPlatforms : Object.keys(platformInfo) }),
})
setTimeout(fetchBuilds, 2000)
} catch {
alert('构建请求失败')
}
setBuilding(false)
}
const togglePlatform = (platform: string) => {
setSelectedPlatforms((prev) =>
prev.includes(platform)
? prev.filter((p) => p !== platform)
: [...prev, platform]
)
}
const serverUrl = config?.server_ip
? `http://${config.server_ip}:3000`
: window.location.origin
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500"></p>
</div>
{/* Download Page Info */}
<div className="card p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
</h2>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-2">:</p>
<div className="flex items-center gap-4">
<code className="flex-1 bg-white px-4 py-2 rounded border text-primary-600 font-mono">
{serverUrl}/download
</code>
<button
onClick={() => navigator.clipboard.writeText(`${serverUrl}/download`)}
className="btn-secondary text-sm"
>
</button>
<a
href={`${serverUrl}/download`}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm"
>
</a>
</div>
</div>
</div>
{/* Build Platforms */}
<div className="card p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<button
onClick={handleBuildAll}
disabled={building}
className="btn-primary disabled:opacity-50"
>
{building ? '构建中...' : '构建选中平台'}
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(platformInfo).map(([key, info]) => (
<label
key={key}
className={`flex items-center gap-3 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
selectedPlatforms.includes(key)
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="checkbox"
checked={selectedPlatforms.includes(key)}
onChange={() => togglePlatform(key)}
className="sr-only"
/>
<span className="text-2xl">{info.icon}</span>
<span className="font-medium text-sm">{info.name}</span>
</label>
))}
</div>
</div>
{/* Builds List */}
<div className="card overflow-hidden">
<div className="p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900"></h2>
</div>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{builds.map((build) => {
const info = platformInfo[build.platform] || {
name: build.platform,
icon: '📦',
}
return (
<tr key={build.platform}>
<td>
<div className="flex items-center gap-2">
<span className="text-xl">{info.icon}</span>
<span className="font-medium">{info.name}</span>
</div>
</td>
<td>v{build.version}</td>
<td className="text-gray-500 font-mono text-sm">
{build.filename}
</td>
<td>{build.size}</td>
<td>{build.download_count}</td>
<td>
{build.status === 'ready' ? (
<span className="badge badge-success"></span>
) : build.status === 'building' ? (
<span className="badge badge-warning"></span>
) : (
<span className="badge badge-danger"></span>
)}
</td>
<td>
<a
href={`/api/v1/download/${build.filename}`}
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
</a>
</td>
</tr>
)
})}
{builds.length === 0 && (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const success = await login(username, password)
setLoading(false)
if (success) {
navigate('/dashboard')
} else {
setError('用户名或密码错误')
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="card p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600 mb-2">FunMC</h1>
<p className="text-gray-500"></p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
placeholder="admin"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full btn-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p className="mt-8 text-center text-xs text-gray-400">
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react'
import { useAdminStore } from '../stores/adminStore'
export default function Logs() {
const { logs, fetchLogs, loading } = useAdminStore()
const [autoRefresh, setAutoRefresh] = useState(true)
const [filter, setFilter] = useState('')
const logContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetchLogs()
}, [fetchLogs])
useEffect(() => {
if (autoRefresh) {
const interval = setInterval(fetchLogs, 5000)
return () => clearInterval(interval)
}
}, [autoRefresh, fetchLogs])
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs])
const filteredLogs = filter
? logs.filter((log) => log.toLowerCase().includes(filter.toLowerCase()))
: logs
const getLogLevel = (log: string) => {
if (log.includes('ERROR') || log.includes('error')) return 'error'
if (log.includes('WARN') || log.includes('warn')) return 'warn'
if (log.includes('INFO') || log.includes('info')) return 'info'
if (log.includes('DEBUG') || log.includes('debug')) return 'debug'
return 'info'
}
const getLogColor = (level: string) => {
switch (level) {
case 'error':
return 'text-red-500'
case 'warn':
return 'text-yellow-500'
case 'info':
return 'text-blue-500'
case 'debug':
return 'text-gray-400'
default:
return 'text-gray-300'
}
}
return (
<div className="p-8 h-full flex flex-col">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500"></p>
</div>
<div className="flex items-center gap-4">
<input
type="text"
placeholder="过滤日志..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="input w-64"
/>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-4 h-4 text-primary-600 rounded"
/>
<span className="text-sm text-gray-600"></span>
</label>
<button onClick={() => fetchLogs()} className="btn-secondary text-sm">
</button>
</div>
</div>
<div className="card flex-1 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b bg-gray-800 text-white text-sm flex items-center justify-between">
<span className="font-mono">Server Logs</span>
<span className="text-gray-400">{filteredLogs.length} </span>
</div>
<div
ref={logContainerRef}
className="flex-1 overflow-auto bg-gray-900 p-4 font-mono text-sm"
>
{loading && logs.length === 0 ? (
<div className="text-gray-500">...</div>
) : filteredLogs.length === 0 ? (
<div className="text-gray-500"></div>
) : (
filteredLogs.map((log, index) => {
const level = getLogLevel(log)
return (
<div
key={index}
className={`py-0.5 ${getLogColor(level)} hover:bg-gray-800`}
>
{log}
</div>
)
})
)}
</div>
</div>
<div className="mt-4 text-center text-xs text-gray-400">
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import { useAdminStore } from '../stores/adminStore'
import dayjs from 'dayjs'
export default function Rooms() {
const { rooms, fetchRooms, deleteRoom, loading } = useAdminStore()
const [search, setSearch] = useState('')
useEffect(() => {
fetchRooms()
}, [fetchRooms])
const filteredRooms = rooms.filter(
(room) =>
room.name.toLowerCase().includes(search.toLowerCase()) ||
room.owner_name.toLowerCase().includes(search.toLowerCase())
)
const handleDelete = async (roomId: string) => {
if (confirm('确定要删除此房间吗?所有成员将被踢出。')) {
await deleteRoom(roomId)
}
}
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500"></p>
</div>
<div className="flex gap-4">
<input
type="text"
placeholder="搜索房间..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input w-64"
/>
</div>
</div>
<div className="card overflow-hidden">
{loading ? (
<div className="text-center py-12 text-gray-500">...</div>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredRooms.map((room) => (
<tr key={room.id}>
<td className="font-medium">{room.name}</td>
<td className="text-gray-500">{room.owner_name}</td>
<td>{room.member_count}</td>
<td>
{room.is_public ? (
<span className="badge badge-success"></span>
) : (
<span className="badge badge-warning"></span>
)}
</td>
<td>
{room.status === 'active' ? (
<span className="badge badge-success"></span>
) : (
<span className="badge badge-info"></span>
)}
</td>
<td className="text-gray-500">
{dayjs(room.created_at).format('YYYY-MM-DD HH:mm')}
</td>
<td>
<button
onClick={() => handleDelete(room.id)}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
</button>
</td>
</tr>
))}
{filteredRooms.length === 0 && (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import { useAdminStore, ServerConfig } from '../stores/adminStore'
export default function Settings() {
const { config, fetchConfig, updateConfig, loading } = useAdminStore()
const [form, setForm] = useState<Partial<ServerConfig>>({})
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
useEffect(() => {
fetchConfig()
}, [fetchConfig])
useEffect(() => {
if (config) {
setForm(config)
}
}, [config])
const handleChange = (key: keyof ServerConfig, value: string | number | boolean) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setSaving(true)
setMessage(null)
const success = await updateConfig(form)
if (success) {
setMessage({ type: 'success', text: '设置已保存' })
} else {
setMessage({ type: 'error', text: '保存失败,请重试' })
}
setSaving(false)
}
if (loading && !config) {
return (
<div className="p-8">
<div className="text-center py-12 text-gray-500">...</div>
</div>
)
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500"></p>
</div>
{message && (
<div
className={`mb-6 p-4 rounded-lg ${
message.type === 'success'
? 'bg-green-50 text-green-600 border border-green-200'
: 'bg-red-50 text-red-600 border border-red-200'
}`}
>
{message.text}
</div>
)}
<div className="grid gap-6">
{/* Basic Settings */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6"></h2>
<div className="grid gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={form.server_name || ''}
onChange={(e) => handleChange('server_name', e.target.value)}
className="input"
placeholder="FunMC Server"
/>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
IP
</label>
<input
type="text"
value={form.server_ip || ''}
onChange={(e) => handleChange('server_ip', e.target.value)}
className="input"
placeholder="123.45.67.89"
/>
<p className="mt-1 text-sm text-gray-500">
IP 使
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<input
type="text"
value={form.server_domain || ''}
onChange={(e) => handleChange('server_domain', e.target.value)}
className="input"
placeholder="funmc.example.com"
/>
<p className="mt-1 text-sm text-gray-500">
使
</p>
</div>
</div>
</div>
{/* Limits */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6"></h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="number"
value={form.max_rooms_per_user || 5}
onChange={(e) =>
handleChange('max_rooms_per_user', parseInt(e.target.value))
}
className="input"
min={1}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="number"
value={form.max_room_members || 10}
onChange={(e) =>
handleChange('max_room_members', parseInt(e.target.value))
}
className="input"
min={2}
max={100}
/>
</div>
</div>
</div>
{/* Features */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6"></h2>
<div className="space-y-4">
<label className="flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer">
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-500"></p>
</div>
<input
type="checkbox"
checked={form.registration_enabled ?? true}
onChange={(e) =>
handleChange('registration_enabled', e.target.checked)
}
className="w-5 h-5 text-primary-600 rounded"
/>
</label>
<label className="flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer">
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-500">
</p>
</div>
<input
type="checkbox"
checked={form.relay_enabled ?? true}
onChange={(e) => handleChange('relay_enabled', e.target.checked)}
className="w-5 h-5 text-primary-600 rounded"
/>
</label>
<label className="flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer">
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-500"></p>
</div>
<input
type="checkbox"
checked={form.client_download_enabled ?? true}
onChange={(e) =>
handleChange('client_download_enabled', e.target.checked)
}
className="w-5 h-5 text-primary-600 rounded"
/>
</label>
</div>
</div>
{/* Client Version */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-6"></h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={form.client_version || '0.1.0'}
onChange={(e) => handleChange('client_version', e.target.value)}
className="input"
placeholder="0.1.0"
/>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="btn-primary px-8 disabled:opacity-50"
>
{saving ? '保存中...' : '保存设置'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { useAdminStore } from '../stores/adminStore'
import dayjs from 'dayjs'
export default function Users() {
const { users, fetchUsers, banUser, unbanUser, loading } = useAdminStore()
const [search, setSearch] = useState('')
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const filteredUsers = users.filter(
(user) =>
user.username.toLowerCase().includes(search.toLowerCase()) ||
user.email.toLowerCase().includes(search.toLowerCase())
)
const handleBan = async (userId: string, isBanned: boolean) => {
if (isBanned) {
await unbanUser(userId)
} else {
if (confirm('确定要封禁此用户吗?')) {
await banUser(userId)
}
}
}
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500"></p>
</div>
<div className="flex gap-4">
<input
type="text"
placeholder="搜索用户..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input w-64"
/>
</div>
</div>
<div className="card overflow-hidden">
{loading ? (
<div className="text-center py-12 text-gray-500">...</div>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td className="font-medium">{user.username}</td>
<td className="text-gray-500">{user.email}</td>
<td>
{user.is_banned ? (
<span className="badge badge-danger"></span>
) : user.is_online ? (
<span className="badge badge-success">线</span>
) : (
<span className="badge badge-info">线</span>
)}
</td>
<td className="text-gray-500">
{dayjs(user.created_at).format('YYYY-MM-DD HH:mm')}
</td>
<td>
<div className="flex gap-2">
<button
onClick={() => handleBan(user.id, user.is_banned)}
className={
user.is_banned
? 'text-green-600 hover:text-green-700 text-sm font-medium'
: 'text-red-600 hover:text-red-700 text-sm font-medium'
}
>
{user.is_banned ? '解封' : '封禁'}
</button>
</div>
</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-500">
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { create } from 'zustand'
import { useAuthStore } from './authStore'
export interface User {
id: string
username: string
email: string
created_at: string
is_online: boolean
is_banned: boolean
}
export interface Room {
id: string
name: string
owner_id: string
owner_name: string
is_public: boolean
member_count: number
created_at: string
status: 'active' | 'idle'
}
export interface ServerStats {
total_users: number
online_users: number
total_rooms: number
active_rooms: number
total_connections: number
uptime_seconds: number
version: string
}
export interface ServerConfig {
server_name: string
server_ip: string
server_domain: string
max_rooms_per_user: number
max_room_members: number
relay_enabled: boolean
registration_enabled: boolean
client_download_enabled: boolean
client_version: string
}
interface AdminState {
stats: ServerStats | null
users: User[]
rooms: Room[]
config: ServerConfig | null
logs: string[]
loading: boolean
error: string | null
fetchStats: () => Promise<void>
fetchUsers: () => Promise<void>
fetchRooms: () => Promise<void>
fetchConfig: () => Promise<void>
fetchLogs: () => Promise<void>
updateConfig: (config: Partial<ServerConfig>) => Promise<boolean>
banUser: (userId: string) => Promise<boolean>
unbanUser: (userId: string) => Promise<boolean>
deleteRoom: (roomId: string) => Promise<boolean>
}
const getAuthHeader = () => {
const token = useAuthStore.getState().token
return { Authorization: `Bearer ${token}` }
}
export const useAdminStore = create<AdminState>((set, get) => ({
stats: null,
users: [],
rooms: [],
config: null,
logs: [],
loading: false,
error: null,
fetchStats: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/stats', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch stats')
const data = await res.json()
set({ stats: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/users', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch users')
const data = await res.json()
set({ users: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchRooms: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/rooms', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch rooms')
const data = await res.json()
set({ rooms: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchConfig: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/config', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch config')
const data = await res.json()
set({ config: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchLogs: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/logs', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch logs')
const data = await res.json()
set({ logs: data.logs, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
updateConfig: async (config) => {
try {
const res = await fetch('/api/v1/admin/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeader(),
},
body: JSON.stringify(config),
})
if (!res.ok) return false
await get().fetchConfig()
return true
} catch {
return false
}
},
banUser: async (userId) => {
try {
const res = await fetch(`/api/v1/admin/users/${userId}/ban`, {
method: 'POST',
headers: { ...getAuthHeader() },
})
if (!res.ok) return false
await get().fetchUsers()
return true
} catch {
return false
}
},
unbanUser: async (userId) => {
try {
const res = await fetch(`/api/v1/admin/users/${userId}/unban`, {
method: 'POST',
headers: { ...getAuthHeader() },
})
if (!res.ok) return false
await get().fetchUsers()
return true
} catch {
return false
}
},
deleteRoom: async (roomId) => {
try {
const res = await fetch(`/api/v1/admin/rooms/${roomId}`, {
method: 'DELETE',
headers: { ...getAuthHeader() },
})
if (!res.ok) return false
await get().fetchRooms()
return true
} catch {
return false
}
},
}))

View File

@@ -0,0 +1,45 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface AuthState {
token: string | null
isAuthenticated: boolean
login: (username: string, password: string) => Promise<boolean>
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
isAuthenticated: false,
login: async (username: string, password: string) => {
try {
const res = await fetch('/api/v1/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
return false
}
const data = await res.json()
set({ token: data.token, isAuthenticated: true })
return true
} catch {
return false
}
},
logout: () => {
set({ token: null, isAuthenticated: false })
},
}),
{
name: 'funmc-admin-auth',
}
)
)

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
},
},
},
plugins: [],
}

21
admin-panel/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/admin/',
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

52
client/Cargo.toml Normal file
View File

@@ -0,0 +1,52 @@
[package]
name = "funmc-client"
version = "0.1.0"
edition = "2021"
[lib]
name = "funmc_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "funmc-client"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
funmc-shared = { path = "../shared" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "uuid", "chrono", "migrate"] }
# QUIC transport
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rcgen = "0.13"
# Async networking helpers
bytes = "1"
rand = "0.8"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
local-ip-address = "0.6"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

3
client/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://schema.tauri.app/config/2",
"identifier": "default",
"description": "FunMC 默认权限配置",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"notification:allow-register-action-types"
]
}

9
client/icons/.gitkeep Normal file
View File

@@ -0,0 +1,9 @@
# Icon files should be placed here
# Required icons:
# - 32x32.png
# - 128x128.png
# - 128x128@2x.png
# - icon.icns (macOS)
# - icon.ico (Windows)
#
# Use `cargo tauri icon` command to generate icons from a source image

BIN
client/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/icon.icns Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
client/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

42
client/icons/icon.svg Normal file
View File

@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1d27"/>
<stop offset="100%" style="stop-color:#0f1117"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4ade80"/>
<stop offset="100%" style="stop-color:#22c55e"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- Border -->
<rect x="8" y="8" width="496" height="496" rx="92" fill="none" stroke="url(#accent)" stroke-width="4" opacity="0.5"/>
<!-- Minecraft-style cube layers -->
<g transform="translate(256, 256)">
<!-- Bottom layer -->
<path d="M0 60 L-100 10 L0 -40 L100 10 Z" fill="#22c55e" opacity="0.6"/>
<!-- Middle layer -->
<path d="M0 20 L-100 -30 L0 -80 L100 -30 Z" fill="#4ade80" opacity="0.8"/>
<!-- Top layer -->
<path d="M0 -20 L-100 -70 L0 -120 L100 -70 Z" fill="#86efac"/>
<!-- Left side -->
<path d="M-100 -70 L-100 -30 L0 20 L0 -20 Z" fill="#16a34a" opacity="0.9"/>
<!-- Right side -->
<path d="M100 -70 L100 -30 L0 20 L0 -20 Z" fill="#15803d" opacity="0.9"/>
</g>
<!-- Connection lines representing multiplayer -->
<g stroke="#4ade80" stroke-width="3" fill="none" opacity="0.4">
<circle cx="130" cy="150" r="20"/>
<circle cx="382" cy="150" r="20"/>
<circle cx="130" cy="362" r="20"/>
<circle cx="382" cy="362" r="20"/>
<line x1="150" y1="150" x2="236" y2="200"/>
<line x1="362" y1="150" x2="276" y2="200"/>
<line x1="150" y1="362" x2="236" y2="312"/>
<line x1="362" y1="362" x2="276" y2="312"/>
</g>
<!-- FunMC text -->
<text x="256" y="440" text-anchor="middle" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="#e2e8f0">FunMC</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS auth_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,68 @@
-- FunMC 客户端本地数据库
-- 用于存储本地缓存和设置
-- 用户会话信息缓存
CREATE TABLE IF NOT EXISTS user_session (
id INTEGER PRIMARY KEY CHECK (id = 1),
user_id TEXT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
server_url TEXT NOT NULL DEFAULT 'https://funmc.com',
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 最近加入的房间缓存
CREATE TABLE IF NOT EXISTS recent_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL UNIQUE,
room_name TEXT NOT NULL,
owner_username TEXT NOT NULL,
game_version TEXT NOT NULL DEFAULT '1.20',
last_joined_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 好友缓存(用于离线显示)
CREATE TABLE IF NOT EXISTS friends_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
is_online INTEGER NOT NULL DEFAULT 0,
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 应用设置
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 连接历史记录
CREATE TABLE IF NOT EXISTS connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
room_name TEXT NOT NULL,
session_type TEXT NOT NULL CHECK (session_type IN ('p2p', 'relay')),
local_port INTEGER NOT NULL,
bytes_sent INTEGER NOT NULL DEFAULT 0,
bytes_received INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER,
started_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
ended_at INTEGER
);
-- 中继节点延迟缓存
CREATE TABLE IF NOT EXISTS relay_latency_cache (
node_url TEXT PRIMARY KEY NOT NULL,
node_name TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
tested_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_recent_rooms_last_joined ON recent_rooms(last_joined_at DESC);
CREATE INDEX IF NOT EXISTS idx_connection_history_started ON connection_history(started_at DESC);

115
client/src/commands/auth.rs Normal file
View File

@@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::state::{AppState, CurrentUser, Tokens};
#[derive(Debug, Serialize)]
pub struct AuthResult {
pub user: CurrentUser,
pub token: String,
}
#[derive(Debug, Deserialize)]
struct ServerAuthResponse {
access_token: String,
refresh_token: String,
user: CurrentUser,
}
#[tauri::command]
pub async fn login(
username: String,
password: String,
state: State<'_, AppState>,
) -> Result<AuthResult, String> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/auth/login", state.get_server_url());
let resp = client
.post(&url)
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("login failed").to_string());
}
let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?;
let token = body.access_token.clone();
state.set_auth(
body.user.clone(),
Tokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
},
);
Ok(AuthResult { user: body.user, token })
}
#[tauri::command]
pub async fn register(
username: String,
email: String,
password: String,
state: State<'_, AppState>,
) -> Result<AuthResult, String> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/auth/register", state.get_server_url());
let resp = client
.post(&url)
.json(&serde_json::json!({
"username": username,
"email": email,
"password": password,
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("registration failed").to_string());
}
let body: ServerAuthResponse = resp.json().await.map_err(|e| e.to_string())?;
let token = body.access_token.clone();
state.set_auth(
body.user.clone(),
Tokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
},
);
Ok(AuthResult { user: body.user, token })
}
#[tauri::command]
pub async fn logout(state: State<'_, AppState>) -> Result<(), String> {
let refresh_token = {
state.tokens.lock().unwrap().as_ref().map(|t| t.refresh_token.clone())
};
if let Some(rt) = refresh_token {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/auth/logout", state.get_server_url());
let _ = client
.post(&url)
.json(&serde_json::json!({ "refresh_token": rt }))
.send()
.await;
}
state.clear_auth();
Ok(())
}
#[tauri::command]
pub async fn get_current_user(state: State<'_, AppState>) -> Result<Option<AuthResult>, String> {
let user = state.user.lock().unwrap().clone();
let token = state.tokens.lock().unwrap().as_ref().map(|t| t.access_token.clone());
match (user, token) {
(Some(u), Some(t)) => Ok(Some(AuthResult { user: u, token: t })),
_ => Ok(None),
}
}

View File

@@ -0,0 +1,48 @@
use tauri::State;
use crate::AppState;
use crate::state::ServerConfig;
#[tauri::command]
pub async fn set_server_url(url: String, state: State<'_, AppState>) -> Result<(), String> {
state.set_server_url(url.clone());
tracing::info!("Server URL set to: {}", url);
Ok(())
}
#[tauri::command]
pub async fn get_server_url(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.get_server_url())
}
#[tauri::command]
pub async fn fetch_server_config(state: State<'_, AppState>) -> Result<ServerConfig, String> {
let server_url = state.get_server_url();
let url = format!("{}/api/v1/client-config", server_url);
let client = reqwest::Client::new();
let response = client
.get(&url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("连接服务器失败: {}", e))?;
if !response.status().is_success() {
return Err(format!("服务器返回错误: {}", response.status()));
}
let config: ServerConfig = response
.json()
.await
.map_err(|e| format!("解析配置失败: {}", e))?;
*state.server_config.lock().unwrap() = Some(config.clone());
Ok(config)
}
#[tauri::command]
pub async fn get_cached_server_config(state: State<'_, AppState>) -> Result<Option<ServerConfig>, String> {
Ok(state.server_config.lock().unwrap().clone())
}

View File

@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use uuid::Uuid;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FriendDto {
pub id: Uuid,
pub username: String,
pub avatar_seed: String,
pub is_online: bool,
pub status: String,
}
fn auth_header(state: &AppState) -> Result<String, String> {
state
.get_access_token()
.map(|t| format!("Bearer {}", t))
.ok_or_else(|| "not authenticated".into())
}
#[tauri::command]
pub async fn list_friends(state: State<'_, AppState>) -> Result<Vec<FriendDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends", state.get_server_url());
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<FriendDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FriendRequest {
pub id: Uuid,
pub username: String,
pub avatar_seed: String,
}
#[tauri::command]
pub async fn list_requests(state: State<'_, AppState>) -> Result<Vec<FriendRequest>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/requests", state.get_server_url());
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<FriendRequest>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[tauri::command]
pub async fn send_friend_request(
username: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/request", state.get_server_url());
let resp = client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({ "username": username }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed").to_string());
}
Ok(())
}
#[tauri::command]
pub async fn accept_friend_request(
requester_id: Uuid,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/{}/accept", state.get_server_url(), requester_id);
let resp = client
.put(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err("failed to accept request".into());
}
Ok(())
}
#[tauri::command]
pub async fn remove_friend(friend_id: Uuid, state: State<'_, AppState>) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/friends/{}", state.get_server_url(), friend_id);
client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,7 @@
pub mod auth;
pub mod config;
pub mod friends;
pub mod network;
pub mod relay_nodes;
pub mod rooms;
pub mod signaling;

View File

@@ -0,0 +1,323 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::network::{
minecraft_proxy::{find_mc_port, start_client_proxy, start_host_proxy},
p2p::{attempt_p2p, build_my_peer_info, find_quic_port},
quic::open_endpoint,
relay::connect_relay,
relay_selector::fetch_best_node,
session::{ConnectionStats, NetworkSession},
lan_discovery::broadcast_lan_server,
};
use funmc_shared::protocol::PeerInfo;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkSessionDto {
pub room_id: String,
pub local_port: u16,
pub session_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInfo {
pub room_id: String,
pub local_port: u16,
pub connect_addr: String,
pub session_type: String,
}
fn auth_token(state: &AppState) -> Result<String, String> {
state.get_access_token().ok_or_else(|| "not authenticated".into())
}
fn my_user_id(state: &AppState) -> Result<Uuid, String> {
state
.user
.lock()
.unwrap()
.as_ref()
.map(|u| u.id)
.ok_or_else(|| "not authenticated".into())
}
/// Upload host peer info to server for P2P connection
async fn upload_host_info(
server_url: &str,
token: &str,
room_id: Uuid,
peer_info: &PeerInfo,
) {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id);
let nat_type_str = match peer_info.nat_type {
funmc_shared::protocol::NatType::None => "none",
funmc_shared::protocol::NatType::FullCone => "full_cone",
funmc_shared::protocol::NatType::RestrictedCone => "restricted_cone",
funmc_shared::protocol::NatType::PortRestrictedCone => "port_restricted",
funmc_shared::protocol::NatType::Symmetric => "symmetric",
funmc_shared::protocol::NatType::Unknown => "unknown",
};
let _ = client
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({
"public_addr": peer_info.public_addr,
"local_addrs": peer_info.local_addrs,
"nat_type": nat_type_str,
}))
.send()
.await;
}
/// House side: open a QUIC listener, accept connections from peers (direct or relayed),
/// forward each to the local MC server (default 25565).
#[tauri::command]
pub async fn start_hosting(
room_id: String,
room_name: Option<String>,
mc_port: Option<u16>,
state: State<'_, AppState>,
app: AppHandle,
) -> Result<NetworkSessionDto, String> {
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
let room_name = room_name.unwrap_or_else(|| format!("Room {}", &room_id[..8]));
let mc_server_port = mc_port.unwrap_or(25565);
let mc_addr = format!("127.0.0.1:{}", mc_server_port).parse().unwrap();
let token = auth_token(&state)?;
let user_id = my_user_id(&state)?;
let quic_port = find_quic_port().await.map_err(|e| e.to_string())?;
let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap();
let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?;
let endpoint = Arc::new(endpoint);
// Build and upload peer info for P2P connections
let my_peer_info = build_my_peer_info(user_id, quic_port).await;
tracing::info!("Host public addr: {}", my_peer_info.public_addr);
upload_host_info(&state.get_server_url(), &token, room_uuid, &my_peer_info).await;
let session = NetworkSession::new(room_uuid, quic_port, true, "hosting");
let cancel = session.cancel.clone();
let stats = session.stats.clone();
// Store session
*state.network_session.write().await = Some(session);
// Accept incoming QUIC connections in background
let ep2 = endpoint.clone();
let cancel2 = cancel.clone();
let stats2 = stats.clone();
tokio::spawn(async move {
while let Some(inc) = ep2.accept().await {
let mc_addr2 = mc_addr;
let cancel3 = cancel2.clone();
let stats3 = stats2.clone();
tokio::spawn(async move {
match inc.await {
Ok(conn) => {
tracing::info!("Host: accepted QUIC connection from {}", conn.remote_address());
{
let mut s = stats3.lock().await;
s.connected = true;
s.session_type = "p2p".into();
}
let _ = start_host_proxy(mc_addr2, conn, cancel3).await;
}
Err(e) => tracing::warn!("Host: QUIC incoming error: {}", e),
}
});
}
});
// Broadcast LAN discovery so MC client can find the server
let cancel_lan = cancel.clone();
let room_name2 = room_name.clone();
let local_port = quic_port;
tokio::spawn(async move {
let _ = broadcast_lan_server(&room_name2, local_port, cancel_lan).await;
});
// Emit event
let _ = app.emit("network:status_changed", serde_json::json!({
"type": "hosting",
"port": quic_port,
}));
Ok(NetworkSessionDto {
room_id,
local_port: quic_port,
session_type: "hosting".into(),
})
}
/// Fetch host's peer info from the server
async fn fetch_host_peer_info(
server_url: &str,
token: &str,
room_id: Uuid,
) -> Option<PeerInfo> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/host-info", server_url, room_id);
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.ok()?;
if resp.status().is_success() {
resp.json::<PeerInfo>().await.ok()
} else {
None
}
}
/// Client side: attempt P2P → relay fallback → start local MC proxy
#[tauri::command]
pub async fn join_room_network(
room_id: String,
host_user_id: Option<String>,
state: State<'_, AppState>,
app: AppHandle,
) -> Result<ConnectionInfo, String> {
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
let host_uuid = host_user_id
.as_ref()
.and_then(|id| Uuid::parse_str(id).ok());
let token = auth_token(&state)?;
let user_id = my_user_id(&state)?;
let mc_local_port = find_mc_port().await;
let quic_port = find_quic_port().await.map_err(|e| e.to_string())?;
let bind_addr = format!("0.0.0.0:{}", quic_port).parse().unwrap();
let endpoint = open_endpoint(bind_addr).map_err(|e| e.to_string())?;
let endpoint = Arc::new(endpoint);
// Build our own peer info (STUN discovery)
let my_peer_info = build_my_peer_info(user_id, quic_port).await;
tracing::info!("My public addr: {}", my_peer_info.public_addr);
// Emit status update
let _ = app.emit("network:status_changed", serde_json::json!({
"type": "connecting",
"status": "尝试 P2P 直连...",
}));
// Try P2P connection first if we have host info
let mut p2p_conn: Option<quinn::Connection> = None;
if let Some(host_info) = fetch_host_peer_info(&state.get_server_url(), &token, room_uuid).await {
tracing::info!("Got host peer info, attempting P2P to {}", host_info.public_addr);
match attempt_p2p(user_id, host_info, endpoint.clone(), quic_port).await {
Ok(conn) => {
tracing::info!("P2P connection established!");
p2p_conn = Some(conn);
}
Err(e) => {
tracing::warn!("P2P failed: {}, falling back to relay", e);
}
}
} else {
tracing::info!("No host peer info available, using relay directly");
}
// Use P2P connection or fall back to relay
let (conn, session_type) = if let Some(p2p) = p2p_conn {
(p2p, "p2p")
} else {
// Update status
let _ = app.emit("network:status_changed", serde_json::json!({
"type": "connecting",
"status": "连接中继服务器...",
}));
// Try to find the best relay node
let relay_node = fetch_best_node(&state.get_server_url(), &token).await;
match relay_node {
Ok(node) => {
tracing::info!("Using relay node: {} ({})", node.name, node.url);
match connect_relay(&node.url, room_uuid, &token).await {
Ok(c) => (c, "relay"),
Err(e) => {
tracing::warn!("Relay failed: {}. No connection available.", e);
return Err(format!("Could not connect: {}", e));
}
}
}
Err(_) => {
return Err("No relay nodes available".into());
}
}
};
let session = NetworkSession::new(room_uuid, mc_local_port, false, session_type);
let cancel = session.cancel.clone();
let stats = session.stats.clone();
{
let mut s = stats.lock().await;
s.connected = true;
s.session_type = session_type.to_string();
}
*state.network_session.write().await = Some(session);
// Start MC proxy in background
let conn2 = conn.clone();
let cancel2 = cancel.clone();
tokio::spawn(async move {
let _ = start_client_proxy(mc_local_port, conn2, cancel2).await;
});
let connect_addr = format!("127.0.0.1:{}", mc_local_port);
let _ = app.emit("network:status_changed", serde_json::json!({
"type": session_type,
"port": mc_local_port,
"connect_addr": connect_addr,
}));
Ok(ConnectionInfo {
room_id,
local_port: mc_local_port,
connect_addr,
session_type: session_type.to_string(),
})
}
/// Get live connection statistics
#[tauri::command]
pub async fn get_connection_stats(
state: State<'_, AppState>,
) -> Result<ConnectionStats, String> {
let guard = state.network_session.read().await;
match guard.as_ref() {
Some(s) => Ok(s.stats.lock().await.clone()),
None => Ok(ConnectionStats {
session_type: "none".into(),
latency_ms: 0,
bytes_sent: 0,
bytes_received: 0,
connected: false,
}),
}
}
/// Stop all active network sessions
#[tauri::command]
pub async fn stop_network(state: State<'_, AppState>) -> Result<(), String> {
let mut guard = state.network_session.write().await;
if let Some(s) = guard.take() {
s.cancel.notify_waiters();
tracing::info!("Network session stopped");
}
Ok(())
}

View File

@@ -0,0 +1,111 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayNodeDto {
pub id: String,
pub name: String,
pub url: String,
pub region: String,
pub is_active: bool,
pub priority: i32,
pub last_ping_ms: Option<i32>,
}
fn auth_header(state: &AppState) -> Result<String, String> {
state
.get_access_token()
.map(|t| format!("Bearer {}", t))
.ok_or_else(|| "not authenticated".into())
}
#[tauri::command]
pub async fn list_relay_nodes(state: State<'_, AppState>) -> Result<Vec<RelayNodeDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes", state.get_server_url());
let nodes = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<RelayNodeDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(nodes)
}
#[tauri::command]
pub async fn add_relay_node(
name: String,
url: String,
region: Option<String>,
priority: Option<i32>,
state: State<'_, AppState>,
) -> Result<RelayNodeDto, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let api_url = format!("{}/api/v1/relay/nodes", state.get_server_url());
let resp = client
.post(&api_url)
.header("Authorization", token)
.json(&serde_json::json!({ "name": name, "url": url, "region": region, "priority": priority }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed").to_string());
}
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let node_id = body["id"].as_str().unwrap_or("").to_string();
// Return a DTO with what we know
Ok(RelayNodeDto {
id: node_id,
name,
url,
region: region.unwrap_or_else(|| "auto".into()),
is_active: true,
priority: priority.unwrap_or(0),
last_ping_ms: None,
})
}
#[tauri::command]
pub async fn remove_relay_node(
node_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes/{}", state.get_server_url(), node_id);
client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn report_relay_ping(
node_id: String,
ping_ms: i32,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes/{}/ping", state.get_server_url(), node_id);
client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({ "ping_ms": ping_ms }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -0,0 +1,187 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use uuid::Uuid;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomDto {
pub id: Uuid,
pub name: String,
pub owner_id: Uuid,
pub owner_username: String,
pub max_players: i32,
pub current_players: i64,
pub is_public: bool,
pub has_password: bool,
pub game_version: String,
pub status: String,
}
fn auth_header(state: &AppState) -> Result<String, String> {
state
.get_access_token()
.map(|t| format!("Bearer {}", t))
.ok_or_else(|| "not authenticated".into())
}
#[tauri::command]
pub async fn list_rooms(state: State<'_, AppState>) -> Result<Vec<RoomDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms", state.get_server_url());
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<RoomDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[tauri::command]
pub async fn create_room(
name: String,
max_players: Option<i32>,
is_public: Option<bool>,
password: Option<String>,
game_version: Option<String>,
state: State<'_, AppState>,
) -> Result<Uuid, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms", state.get_server_url());
let resp = client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({
"name": name,
"max_players": max_players,
"is_public": is_public,
"password": password,
"game_version": game_version,
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to create room").to_string());
}
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let id = body["id"]
.as_str()
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or("invalid room id")?;
Ok(id)
}
#[tauri::command]
pub async fn join_room(
room_id: Uuid,
password: Option<String>,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/join", state.get_server_url(), room_id);
let resp = client
.post(&url)
.header("Authorization", token)
.json(&serde_json::json!({ "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to join").to_string());
}
Ok(())
}
#[tauri::command]
pub async fn leave_room(room_id: Uuid, state: State<'_, AppState>) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/leave", state.get_server_url(), room_id);
client
.post(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomMemberDto {
pub user_id: Uuid,
pub username: String,
pub role: String,
pub is_online: bool,
}
#[tauri::command]
pub async fn get_room_members(room_id: Uuid, state: State<'_, AppState>) -> Result<Vec<RoomMemberDto>, String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}/members", state.get_server_url(), room_id);
let resp = client
.get(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<RoomMemberDto>>()
.await
.map_err(|e| e.to_string())?;
Ok(resp)
}
#[tauri::command]
pub async fn kick_room_member(
room_id: String,
target_user_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/rooms/{}/members/{}",
state.get_server_url(),
room_id,
target_user_id
);
let resp = client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to kick member").to_string());
}
Ok(())
}
#[tauri::command]
pub async fn close_room(room_id: String, state: State<'_, AppState>) -> Result<(), String> {
let token = auth_header(&state)?;
let client = reqwest::Client::new();
let url = format!("{}/api/v1/rooms/{}", state.get_server_url(), room_id);
let resp = client
.delete(&url)
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let err: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(err["error"].as_str().unwrap_or("failed to close room").to_string());
}
Ok(())
}

View File

@@ -0,0 +1,72 @@
use tauri::{AppHandle, State};
use crate::network::signaling::SignalingClient;
use crate::state::AppState;
static mut SIGNALING_CLIENT: Option<SignalingClient> = None;
#[tauri::command]
pub async fn connect_signaling(
state: State<'_, AppState>,
app: AppHandle,
) -> Result<(), String> {
let token = state
.get_access_token()
.ok_or_else(|| "not authenticated".to_string())?;
let client = SignalingClient::connect(&state.get_server_url(), &token, app)
.await
.map_err(|e| e.to_string())?;
unsafe {
SIGNALING_CLIENT = Some(client);
}
tracing::info!("Signaling WebSocket connected");
Ok(())
}
#[tauri::command]
pub async fn disconnect_signaling() -> Result<(), String> {
unsafe {
SIGNALING_CLIENT = None;
}
tracing::info!("Signaling WebSocket disconnected");
Ok(())
}
#[tauri::command]
pub async fn send_signaling_message(message: String) -> Result<(), String> {
let msg: funmc_shared::protocol::SignalingMessage =
serde_json::from_str(&message).map_err(|e| e.to_string())?;
unsafe {
if let Some(client) = &SIGNALING_CLIENT {
client.send(msg);
Ok(())
} else {
Err("signaling not connected".into())
}
}
}
#[tauri::command]
pub async fn send_chat_message(room_id: String, content: String) -> Result<(), String> {
use uuid::Uuid;
use funmc_shared::protocol::SignalingMessage;
let room_uuid = Uuid::parse_str(&room_id).map_err(|e| e.to_string())?;
let msg = SignalingMessage::SendChat {
room_id: room_uuid,
content,
};
unsafe {
if let Some(client) = &SIGNALING_CLIENT {
client.send(msg);
Ok(())
} else {
Err("signaling not connected".into())
}
}
}

21
client/src/config.rs Normal file
View File

@@ -0,0 +1,21 @@
/// FunMC 客户端配置常量
pub const APP_NAME: &str = "FunMC";
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const APP_AUTHOR: &str = "魔幻方";
pub const DEFAULT_API_SERVER: &str = "https://funmc.com";
pub const DEFAULT_RELAY_HOST: &str = "funmc.com";
pub const DEFAULT_RELAY_PORT: u16 = 7900;
pub const BACKUP_RELAY_PORT: u16 = 7901;
pub const MC_DEFAULT_PORT: u16 = 25565;
pub const MC_PORT_RANGE: std::ops::Range<u16> = 25565..25576;
pub const QUIC_PORT_RANGE: std::ops::Range<u16> = 34000..34100;
pub const STUN_SERVER: &str = "stun.l.google.com:19302";
pub const P2P_TIMEOUT_SECS: u64 = 8;
pub const RELAY_TIMEOUT_SECS: u64 = 10;
pub const PING_TIMEOUT_SECS: u64 = 3;

16
client/src/db.rs Normal file
View File

@@ -0,0 +1,16 @@
use anyhow::Result;
use sqlx::SqlitePool;
use tauri::{AppHandle, Manager};
pub async fn init(app: &AppHandle) -> Result<()> {
let app_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&app_dir)?;
let db_path = app_dir.join("funmc.db");
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&db_url).await?;
sqlx::migrate!("./migrations/sqlite").run(&pool).await?;
app.manage(pool);
Ok(())
}

73
client/src/lib.rs Normal file
View File

@@ -0,0 +1,73 @@
pub mod commands;
pub mod config;
pub mod db;
pub mod network;
pub mod state;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::state::AppState;
pub fn run() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "funmc_client_lib=debug".into()),
),
)
.with(tracing_subscriber::fmt::layer())
.init();
let app_state = AppState::new();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.manage(app_state)
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = db::init(&handle).await {
tracing::error!("DB init error: {}", e);
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::auth::login,
commands::auth::register,
commands::auth::logout,
commands::auth::get_current_user,
commands::config::set_server_url,
commands::config::get_server_url,
commands::config::fetch_server_config,
commands::config::get_cached_server_config,
commands::friends::list_friends,
commands::friends::list_requests,
commands::friends::send_friend_request,
commands::friends::accept_friend_request,
commands::friends::remove_friend,
commands::rooms::list_rooms,
commands::rooms::create_room,
commands::rooms::join_room,
commands::rooms::leave_room,
commands::rooms::get_room_members,
commands::rooms::kick_room_member,
commands::rooms::close_room,
commands::network::start_hosting,
commands::network::join_room_network,
commands::network::get_connection_stats,
commands::network::stop_network,
commands::relay_nodes::list_relay_nodes,
commands::relay_nodes::add_relay_node,
commands::relay_nodes::remove_relay_node,
commands::relay_nodes::report_relay_ping,
commands::signaling::connect_signaling,
commands::signaling::disconnect_signaling,
commands::signaling::send_signaling_message,
commands::signaling::send_chat_message,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
client/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Tauri entry point
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
funmc_client_lib::run();
}

View File

@@ -0,0 +1,88 @@
/// Minecraft LAN server discovery spoofing
///
/// MC uses UDP multicast 224.0.2.60:4445 to advertise LAN servers.
/// We listen on this address and also broadcast fake "LAN server" packets
/// so that Minecraft clients see the FunMC room as a local server.
///
/// Broadcast format: "[MOTD]<name>[/MOTD][AD]<port>[/AD]"
use anyhow::Result;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::sync::Notify;
const MC_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 2, 60);
const MC_MULTICAST_PORT: u16 = 4445;
const BROADCAST_INTERVAL: Duration = Duration::from_secs(1);
/// Start broadcasting a fake LAN server announcement
pub async fn broadcast_lan_server(
server_name: &str,
local_port: u16,
cancel: Arc<Notify>,
) -> Result<()> {
let sock = UdpSocket::bind("0.0.0.0:0").await?;
sock.set_multicast_ttl_v4(1)?;
let motd = format!("[MOTD]{}[/MOTD][AD]{}[/AD]", server_name, local_port);
let target = SocketAddr::new(IpAddr::V4(MC_MULTICAST_ADDR), MC_MULTICAST_PORT);
tracing::info!("Broadcasting MC LAN server: {} on port {}", server_name, local_port);
loop {
tokio::select! {
_ = cancel.notified() => {
tracing::info!("LAN broadcast stopped");
break;
}
_ = tokio::time::sleep(BROADCAST_INTERVAL) => {
if let Err(e) = sock.send_to(motd.as_bytes(), target).await {
tracing::warn!("LAN broadcast send error: {}", e);
}
}
}
}
Ok(())
}
/// Listen for MC LAN server announcements from the multicast group
/// Returns parsed (name, port) pairs
pub async fn listen_lan_servers(
cancel: Arc<Notify>,
tx: tokio::sync::mpsc::Sender<(String, u16)>,
) -> Result<()> {
let sock = UdpSocket::bind(("0.0.0.0", MC_MULTICAST_PORT)).await?;
sock.join_multicast_v4(MC_MULTICAST_ADDR, Ipv4Addr::UNSPECIFIED)?;
sock.set_multicast_loop_v4(false)?;
let mut buf = [0u8; 1024];
loop {
tokio::select! {
_ = cancel.notified() => break,
result = sock.recv_from(&mut buf) => {
if let Ok((n, _)) = result {
let text = String::from_utf8_lossy(&buf[..n]);
if let Some(parsed) = parse_motd(&text) {
let _ = tx.send(parsed).await;
}
}
}
}
}
Ok(())
}
fn parse_motd(text: &str) -> Option<(String, u16)> {
let name = extract_between(text, "[MOTD]", "[/MOTD]")?;
let port_str = extract_between(text, "[AD]", "[/AD]")?;
let port = port_str.parse::<u16>().ok()?;
Some((name.to_string(), port))
}
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
let s = text.find(start)? + start.len();
let e = text[s..].find(end)? + s;
Some(&text[s..e])
}

View File

@@ -0,0 +1,144 @@
/// Minecraft TCP → QUIC tunnel proxy
use anyhow::Result;
use quinn::Connection;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Notify;
use std::sync::Arc;
pub async fn start_client_proxy(
local_port: u16,
conn: Connection,
cancel: Arc<Notify>,
) -> Result<()> {
let listener = TcpListener::bind(("127.0.0.1", local_port)).await?;
tracing::info!("MC proxy listening on 127.0.0.1:{}", local_port);
loop {
tokio::select! {
_ = cancel.notified() => {
tracing::info!("MC proxy shutting down");
break;
}
accept = listener.accept() => {
match accept {
Ok((tcp_stream, _peer)) => {
let conn2 = conn.clone();
tokio::spawn(async move {
if let Err(e) = proxy_tcp_to_quic(tcp_stream, conn2).await {
tracing::debug!("proxy_tcp_to_quic: {}", e);
}
});
}
Err(e) => {
tracing::error!("MC proxy accept error: {}", e);
break;
}
}
}
}
}
Ok(())
}
async fn proxy_tcp_to_quic(tcp: TcpStream, conn: Connection) -> Result<()> {
let (mut qs, mut qr) = conn.open_bi().await?;
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let t1 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = tcp_r.read(&mut buf).await?;
if n == 0 { break; }
qs.write_all(&buf[..n]).await?;
}
let _ = qs.finish();
Ok::<_, anyhow::Error>(())
});
let t2 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = qr.read(&mut buf).await?.unwrap_or(0);
if n == 0 { break; }
tcp_w.write_all(&buf[..n]).await?;
}
Ok::<_, anyhow::Error>(())
});
let _ = tokio::try_join!(t1, t2);
Ok(())
}
pub async fn start_host_proxy(
mc_server_addr: SocketAddr,
conn: Connection,
cancel: Arc<Notify>,
) -> Result<()> {
tracing::info!("Host proxy forwarding QUIC streams to MC server at {}", mc_server_addr);
loop {
tokio::select! {
_ = cancel.notified() => break,
stream = conn.accept_bi() => {
match stream {
Ok((qs, qr)) => {
tokio::spawn(async move {
if let Err(e) = proxy_quic_to_mc(qs, qr, mc_server_addr).await {
tracing::debug!("proxy_quic_to_mc: {}", e);
}
});
}
Err(e) => {
tracing::warn!("host proxy stream accept error: {}", e);
break;
}
}
}
}
}
Ok(())
}
async fn proxy_quic_to_mc(
mut qs: quinn::SendStream,
mut qr: quinn::RecvStream,
mc_addr: SocketAddr,
) -> Result<()> {
let tcp = TcpStream::connect(mc_addr).await?;
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let t1 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = qr.read(&mut buf).await?.unwrap_or(0);
if n == 0 { break; }
tcp_w.write_all(&buf[..n]).await?;
}
Ok::<_, anyhow::Error>(())
});
let t2 = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = tcp_r.read(&mut buf).await?;
if n == 0 { break; }
qs.write_all(&buf[..n]).await?;
}
let _ = qs.finish();
Ok::<_, anyhow::Error>(())
});
let _ = tokio::try_join!(t1, t2);
Ok(())
}
pub async fn find_mc_port() -> u16 {
for port in 25565u16..=25575 {
if TcpListener::bind(("127.0.0.1", port)).await.is_ok() {
return port;
}
}
25565
}

View File

@@ -0,0 +1,9 @@
pub mod lan_discovery;
pub mod minecraft_proxy;
pub mod nat;
pub mod p2p;
pub mod quic;
pub mod relay;
pub mod relay_selector;
pub mod session;
pub mod signaling;

149
client/src/network/nat.rs Normal file
View File

@@ -0,0 +1,149 @@
/// NAT type detection via STUN (RFC 5389)
/// Uses stun.l.google.com:19302 to discover public IP and port
use anyhow::{anyhow, Result};
use std::net::SocketAddr;
use std::time::Duration;
use tokio::net::UdpSocket;
const STUN_SERVER: &str = "stun.l.google.com:19302";
const STUN_BINDING_REQUEST: u16 = 0x0001;
const STUN_MAGIC_COOKIE: u32 = 0x2112A442;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum NatType {
None, // Direct internet connection
FullCone, // Any external host can send to our mapped addr
RestrictedCone, // External must receive from us first (IP)
PortRestricted, // External must receive from us first (IP+port)
Symmetric, // Different mapping per destination (hardest to pierce)
Unknown,
}
#[derive(Debug, Clone)]
pub struct StunResult {
pub public_addr: SocketAddr,
pub nat_type: NatType,
}
/// Build a minimal STUN Binding Request packet
fn build_binding_request(transaction_id: &[u8; 12]) -> Vec<u8> {
let mut pkt = Vec::with_capacity(20);
pkt.extend_from_slice(&STUN_BINDING_REQUEST.to_be_bytes());
pkt.extend_from_slice(&0u16.to_be_bytes()); // length
pkt.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
pkt.extend_from_slice(transaction_id);
pkt
}
/// Parse XOR-MAPPED-ADDRESS or MAPPED-ADDRESS from STUN response
fn parse_mapped_address(data: &[u8]) -> Option<SocketAddr> {
if data.len() < 20 {
return None;
}
let msg_len = u16::from_be_bytes([data[2], data[3]]) as usize;
let mut pos = 20;
while pos + 4 <= 20 + msg_len {
let attr_type = u16::from_be_bytes([data[pos], data[pos + 1]]);
let attr_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if pos + attr_len > data.len() {
break;
}
let attr_data = &data[pos..pos + attr_len];
match attr_type {
// XOR-MAPPED-ADDRESS (0x0020)
0x0020 => {
if attr_data.len() >= 8 && attr_data[1] == 0x01 {
let xport = u16::from_be_bytes([attr_data[2], attr_data[3]])
^ (STUN_MAGIC_COOKIE >> 16) as u16;
let xip = u32::from_be_bytes([attr_data[4], attr_data[5], attr_data[6], attr_data[7]])
^ STUN_MAGIC_COOKIE;
let ip = std::net::Ipv4Addr::from(xip);
return Some(SocketAddr::from((ip, xport)));
}
}
// MAPPED-ADDRESS (0x0001)
0x0001 => {
if attr_data.len() >= 8 && attr_data[1] == 0x01 {
let port = u16::from_be_bytes([attr_data[2], attr_data[3]]);
let ip = std::net::Ipv4Addr::new(attr_data[4], attr_data[5], attr_data[6], attr_data[7]);
return Some(SocketAddr::from((ip, port)));
}
}
_ => {}
}
pos += attr_len;
// 4-byte alignment
if attr_len % 4 != 0 {
pos += 4 - (attr_len % 4);
}
}
None
}
/// Send one STUN binding request and return the mapped address
pub async fn stun_query(sock: &UdpSocket, stun_addr: &str) -> Result<SocketAddr> {
let tid: [u8; 12] = rand::random();
let pkt = build_binding_request(&tid);
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(stun_addr).await?.collect();
let server = addrs.into_iter().next().ok_or_else(|| anyhow!("STUN server DNS failed"))?;
sock.send_to(&pkt, server).await?;
let mut buf = [0u8; 512];
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(anyhow!("STUN timeout"));
}
match tokio::time::timeout(remaining, sock.recv_from(&mut buf)).await {
Ok(Ok((n, _))) => {
if let Some(addr) = parse_mapped_address(&buf[..n]) {
return Ok(addr);
}
}
_ => return Err(anyhow!("STUN failed")),
}
}
}
/// Perform basic NAT detection: get public IP and classify NAT type
pub async fn detect_nat() -> Result<StunResult> {
// Bind a local UDP socket
let sock = UdpSocket::bind("0.0.0.0:0").await?;
// Query primary STUN server
let mapped1 = stun_query(&sock, STUN_SERVER).await?;
// Query again to same server to check if mapping is stable
let mapped2 = stun_query(&sock, STUN_SERVER).await?;
let nat_type = if mapped1 == mapped2 {
// Same port from same destination = Full Cone or Restricted (simplified)
// Deep detection would require a second STUN server, omitted for now
NatType::FullCone
} else {
NatType::Symmetric
};
Ok(StunResult {
public_addr: mapped1,
nat_type,
})
}
/// Get local LAN addresses
pub fn get_local_addrs(port: u16) -> Vec<SocketAddr> {
let mut addrs = Vec::new();
if let Ok(ifaces) = local_ip_address::list_afinet_netifas() {
for (_, ip) in ifaces {
if !ip.is_loopback() {
addrs.push(SocketAddr::new(ip, port));
}
}
}
addrs
}

129
client/src/network/p2p.rs Normal file
View File

@@ -0,0 +1,129 @@
/// P2P UDP hole punching using QUIC (quinn)
///
/// Algorithm:
/// 1. Both peers bind a UDP socket and discover their public address via STUN
/// 2. Exchange PeerInfo (public addr + local addrs) through the signaling server
/// 3. Send simultaneous UDP probes to pierce NAT holes
/// 4. Attempt QUIC connection to peer's public + local addresses
/// 5. First successful connection wins
use anyhow::{anyhow, Result};
use funmc_shared::protocol::PeerInfo;
use quinn::{Connection, Endpoint};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use crate::network::nat::{detect_nat, get_local_addrs, NatType};
pub const QUIC_P2P_PORT_RANGE: std::ops::Range<u16> = 34000..34100;
/// Find a free UDP port in our P2P range
pub async fn find_quic_port() -> Result<u16> {
for port in QUIC_P2P_PORT_RANGE {
if tokio::net::UdpSocket::bind(("0.0.0.0", port)).await.is_ok() {
return Ok(port);
}
}
// Fall back to OS-assigned
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
Ok(sock.local_addr()?.port())
}
/// Attempt P2P hole punch. Returns the QUIC Connection and endpoint on success.
pub async fn attempt_p2p(
my_user_id: Uuid,
peer_info: PeerInfo,
endpoint: Arc<Endpoint>,
my_port: u16,
) -> Result<Connection> {
// Collect all candidate addresses to try
let mut candidates: Vec<SocketAddr> = Vec::new();
// Parse peer's public address
if let Ok(addr) = peer_info.public_addr.parse::<SocketAddr>() {
candidates.push(addr);
}
for local_addr_str in &peer_info.local_addrs {
if let Ok(addr) = local_addr_str.parse::<SocketAddr>() {
candidates.push(addr);
}
}
tracing::info!("P2P: trying {} candidates for peer {}", candidates.len(), peer_info.user_id);
// Try all candidates concurrently with a 5 second total timeout
let ep = endpoint.clone();
let result = timeout(Duration::from_secs(8), async move {
let mut handles = Vec::new();
for addr in candidates {
let ep2 = ep.clone();
handles.push(tokio::spawn(async move {
// Send a few UDP probes first to open NAT hole
for _ in 0..3 {
let _ = ep2.connect(addr, "funmc");
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Attempt real QUIC connection
match ep2.connect(addr, "funmc") {
Ok(connecting) => {
match timeout(Duration::from_secs(3), connecting).await {
Ok(Ok(conn)) => Some(conn),
_ => None,
}
}
Err(_) => None,
}
}));
}
for handle in handles {
if let Ok(Some(conn)) = handle.await {
return Some(conn);
}
}
None
}).await;
match result {
Ok(Some(conn)) => {
tracing::info!("P2P connection established with {}", peer_info.user_id);
Ok(conn)
}
_ => {
tracing::warn!("P2P failed with {}, will use relay", peer_info.user_id);
Err(anyhow!("P2P hole punch failed"))
}
}
}
/// Collect our own PeerInfo for signaling exchange
pub async fn build_my_peer_info(user_id: Uuid, port: u16) -> PeerInfo {
let (public_addr, nat_type) = match detect_nat().await {
Ok(r) => (r.public_addr.to_string(), r.nat_type),
Err(_) => (format!("0.0.0.0:{}", port), crate::network::nat::NatType::Unknown),
};
let nat_type_shared = match nat_type {
NatType::None => funmc_shared::protocol::NatType::None,
NatType::FullCone => funmc_shared::protocol::NatType::FullCone,
NatType::RestrictedCone => funmc_shared::protocol::NatType::RestrictedCone,
NatType::PortRestricted => funmc_shared::protocol::NatType::PortRestrictedCone,
NatType::Symmetric => funmc_shared::protocol::NatType::Symmetric,
NatType::Unknown => funmc_shared::protocol::NatType::Unknown,
};
let local_addrs: Vec<String> = get_local_addrs(port)
.into_iter()
.map(|a| a.to_string())
.collect();
PeerInfo {
user_id,
public_addr,
local_addrs,
nat_type: nat_type_shared,
}
}

View File

@@ -0,0 +1,99 @@
/// QUIC endpoint management using quinn
/// Provides a shared QUIC endpoint for both P2P and relay connections
use anyhow::Result;
use quinn::{ClientConfig, Endpoint, ServerConfig};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use std::net::SocketAddr;
use std::sync::Arc;
/// Build a self-signed TLS certificate for QUIC
pub fn make_self_signed() -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let cert = rcgen::generate_simple_self_signed(vec!["funmc".to_string()])?;
let cert_der = CertificateDer::from(cert.cert);
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()));
Ok((vec![cert_der], key_der))
}
/// Create a QUIC ServerConfig with a self-signed cert
pub fn make_server_config() -> Result<ServerConfig> {
let (certs, key) = make_self_signed()?;
let mut tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
tls.alpn_protocols = vec![b"funmc".to_vec()];
let mut sc = ServerConfig::with_crypto(Arc::new(
quinn::crypto::rustls::QuicServerConfig::try_from(tls)?,
));
// Tune transport params for game traffic
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into()?));
transport.keep_alive_interval(Some(std::time::Duration::from_secs(5)));
sc.transport_config(Arc::new(transport));
Ok(sc)
}
/// Create a QUIC ClientConfig that accepts any self-signed cert (P2P peers)
pub fn make_client_config_insecure() -> ClientConfig {
let crypto = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyCert))
.with_no_client_auth();
let mut cc = ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap(),
));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(std::time::Duration::from_secs(30).try_into().unwrap()));
transport.keep_alive_interval(Some(std::time::Duration::from_secs(5)));
cc.transport_config(Arc::new(transport));
cc
}
/// Open a QUIC endpoint bound to the given address
pub fn open_endpoint(bind_addr: SocketAddr) -> Result<Endpoint> {
let sc = make_server_config()?;
let mut ep = Endpoint::server(sc, bind_addr)?;
ep.set_default_client_config(make_client_config_insecure());
Ok(ep)
}
// ---- TLS cert verifier that accepts anything (P2P peers use self-signed) ----
#[derive(Debug)]
struct AcceptAnyCert;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &rustls::pki_types::ServerName,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}

View File

@@ -0,0 +1,68 @@
/// QUIC relay client — used when P2P hole punch fails
use anyhow::{anyhow, Result};
use quinn::Connection;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use crate::network::quic::open_endpoint;
use crate::network::relay_selector::DEFAULT_RELAY_PORT;
/// Connect to a relay server's QUIC endpoint
pub async fn connect_relay(
relay_url: &str,
room_id: Uuid,
access_token: &str,
) -> Result<Connection> {
let (host, port) = parse_relay_url(relay_url);
let relay_addr: SocketAddr = {
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(format!("{}:{}", host, port))
.await?
.collect();
addrs.into_iter().next().ok_or_else(|| anyhow!("relay DNS failed"))?
};
let ep = open_endpoint("0.0.0.0:0".parse()?)?;
tracing::info!("Connecting to relay at {}", relay_addr);
let connecting = ep.connect(relay_addr, &host)?;
let conn = timeout(Duration::from_secs(10), connecting).await
.map_err(|_| anyhow!("relay connection timed out"))?
.map_err(|e| anyhow!("relay QUIC error: {}", e))?;
// Send auth handshake on a unidirectional stream using write_chunk
let mut stream = conn.open_uni().await?;
let handshake = serde_json::json!({
"token": access_token,
"room_id": room_id.to_string(),
});
let msg = serde_json::to_vec(&handshake)?;
let len = (msg.len() as u32).to_be_bytes();
stream.write_chunk(bytes::Bytes::copy_from_slice(&len)).await
.map_err(|e| anyhow!("{}", e))?;
stream.write_chunk(bytes::Bytes::from(msg)).await
.map_err(|e| anyhow!("{}", e))?;
let _ = stream.finish();
let _ = stream.stopped().await;
tracing::info!("Relay handshake sent for room {}", room_id);
Ok(conn)
}
fn parse_relay_url(url: &str) -> (String, u16) {
let cleaned = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("quic://");
if let Some((host, port_str)) = cleaned.rsplit_once(':') {
if let Ok(port) = port_str.parse::<u16>() {
return (host.to_string(), port);
}
}
(cleaned.to_string(), DEFAULT_RELAY_PORT)
}

View File

@@ -0,0 +1,118 @@
/// Relay node selection — client-side multi-node support
/// Fetches relay nodes from server, measures latency, picks lowest RTT
use anyhow::Result;
use std::net::ToSocketAddrs;
use std::time::{Duration, Instant};
pub const DEFAULT_RELAY_PORT: u16 = 7900;
pub const BACKUP_RELAY_PORT: u16 = 7901;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RelayNode {
pub id: String,
pub name: String,
pub url: String,
pub region: String,
pub priority: i32,
pub last_ping_ms: Option<i32>,
}
impl RelayNode {
pub fn from_server_url(server_url: &str) -> Self {
let relay_url = extract_relay_url(server_url);
Self {
id: "server".into(),
name: "服务器节点".into(),
url: relay_url,
region: "auto".into(),
priority: 100,
last_ping_ms: None,
}
}
pub fn from_relay_url(relay_url: &str) -> Self {
Self {
id: "configured".into(),
name: "配置节点".into(),
url: relay_url.to_string(),
region: "auto".into(),
priority: 100,
last_ping_ms: None,
}
}
pub fn parse_addr(&self) -> Option<std::net::SocketAddr> {
self.url.to_socket_addrs().ok()?.next()
}
}
fn extract_relay_url(server_url: &str) -> String {
let host = server_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(':')
.next()
.unwrap_or("localhost");
format!("{}:{}", host, DEFAULT_RELAY_PORT)
}
/// Fetch available relay nodes from server and return sorted by latency
pub async fn fetch_best_node(server_url: &str, token: &str) -> Result<RelayNode> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/relay/nodes", server_url);
let default_node = RelayNode::from_server_url(server_url);
let nodes: Vec<RelayNode> = match client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.timeout(Duration::from_secs(5))
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or_else(|_| vec![default_node.clone()]),
Err(_) => vec![default_node.clone()],
};
let nodes = if nodes.is_empty() { vec![default_node.clone()] } else { nodes };
let mut best: Option<(RelayNode, u128)> = None;
let mut handles = vec![];
for node in nodes.clone() {
handles.push(tokio::spawn(async move {
let rtt = ping_relay_node(&node).await;
(node, rtt)
}));
}
for handle in handles {
if let Ok((node, Some(rtt))) = handle.await {
if best.is_none() || rtt < best.as_ref().unwrap().1 {
best = Some((node, rtt));
}
}
}
Ok(best
.map(|(n, _)| n)
.unwrap_or_else(|| nodes.into_iter().next().unwrap_or(default_node)))
}
async fn ping_relay_node(node: &RelayNode) -> Option<u128> {
let addr = node.parse_addr()?;
let start = Instant::now();
let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
socket.connect(addr).await.ok()?;
let ping_data = b"FUNMC_PING";
socket.send(ping_data).await.ok()?;
let mut buf = [0u8; 32];
match tokio::time::timeout(Duration::from_secs(3), socket.recv(&mut buf)).await {
Ok(Ok(_)) => Some(start.elapsed().as_millis()),
_ => None,
}
}

View File

@@ -0,0 +1,42 @@
// Network session state held in AppState
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConnectionStats {
pub session_type: String,
pub latency_ms: u32,
pub bytes_sent: u64,
pub bytes_received: u64,
pub connected: bool,
}
pub struct NetworkSession {
pub room_id: Uuid,
pub local_port: u16,
pub is_host: bool,
pub session_type: String, // "p2p" | "relay"
pub stats: Arc<Mutex<ConnectionStats>>,
/// Cancel token: drop to stop all tasks
pub cancel: Arc<tokio::sync::Notify>,
}
impl NetworkSession {
pub fn new(room_id: Uuid, local_port: u16, is_host: bool, session_type: &str) -> Self {
Self {
room_id,
local_port,
is_host,
session_type: session_type.to_string(),
stats: Arc::new(Mutex::new(ConnectionStats {
session_type: session_type.to_string(),
latency_ms: 0,
bytes_sent: 0,
bytes_received: 0,
connected: false,
})),
cancel: Arc::new(tokio::sync::Notify::new()),
}
}
}

View File

@@ -0,0 +1,73 @@
/// WebSocket signaling client
/// Connects to the server's /api/v1/ws endpoint and handles incoming messages
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use funmc_shared::protocol::SignalingMessage;
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message};
pub struct SignalingClient {
pub tx: mpsc::UnboundedSender<SignalingMessage>,
}
impl SignalingClient {
/// Spawn signaling connection in background, forward incoming events to Tauri
pub async fn connect(server_url: &str, token: &str, app: AppHandle) -> Result<Self> {
let ws_url = server_url
.replace("https://", "wss://")
.replace("http://", "ws://");
let ws_url = format!("{}/api/v1/ws?token={}", ws_url, token);
let (ws_stream, _) = connect_async(&ws_url).await?;
let (mut ws_tx, mut ws_rx) = ws_stream.split();
let (tx, mut rx) = mpsc::unbounded_channel::<SignalingMessage>();
// Forward outgoing messages to WebSocket
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if ws_tx.send(Message::Text(json)).await.is_err() {
break;
}
}
}
});
// Receive incoming messages and emit Tauri events
tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await {
if let Message::Text(text) = msg {
if let Ok(signal) = serde_json::from_str::<SignalingMessage>(&text) {
let event_name = match &signal {
SignalingMessage::FriendRequest { .. } => "signaling:friend_request",
SignalingMessage::FriendAccepted { .. } => "signaling:friend_accepted",
SignalingMessage::RoomInvite { .. } => "signaling:room_invite",
SignalingMessage::MemberJoined { .. } => "signaling:member_joined",
SignalingMessage::MemberLeft { .. } => "signaling:member_left",
SignalingMessage::Kicked { .. } => "signaling:kicked",
SignalingMessage::RoomClosed { .. } => "signaling:room_closed",
SignalingMessage::UserOnline { .. } => "signaling:user_online",
SignalingMessage::UserOffline { .. } => "signaling:user_offline",
SignalingMessage::ChatMessage { .. } => "signaling:chat_message",
SignalingMessage::Offer { .. }
| SignalingMessage::Answer { .. }
| SignalingMessage::IceCandidate { .. } => "signaling:network",
_ => continue,
};
let payload = serde_json::to_value(&signal).unwrap_or_default();
let _ = app.emit(event_name, payload);
}
}
}
});
Ok(Self { tx })
}
pub fn send(&self, msg: SignalingMessage) {
let _ = self.tx.send(msg);
}
}

77
client/src/state.rs Normal file
View File

@@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::network::session::NetworkSession;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentUser {
pub id: Uuid,
pub username: String,
pub email: String,
pub avatar_seed: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tokens {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServerConfig {
pub server_name: String,
pub server_url: String,
pub relay_url: String,
#[serde(default)]
pub version: String,
}
pub struct AppState {
pub server_url: Mutex<String>,
pub server_config: Mutex<Option<ServerConfig>>,
pub user: Mutex<Option<CurrentUser>>,
pub tokens: Mutex<Option<Tokens>>,
pub network_session: Arc<RwLock<Option<NetworkSession>>>,
}
impl AppState {
pub fn new() -> Self {
let server_url = std::env::var("FUNMC_SERVER")
.unwrap_or_else(|_| "http://localhost:3000".into());
Self {
server_url: Mutex::new(server_url),
server_config: Mutex::new(None),
user: Mutex::new(None),
tokens: Mutex::new(None),
network_session: Arc::new(RwLock::new(None)),
}
}
pub fn get_server_url(&self) -> String {
self.server_url.lock().unwrap().clone()
}
pub fn set_server_url(&self, url: String) {
*self.server_url.lock().unwrap() = url;
}
pub fn get_access_token(&self) -> Option<String> {
self.tokens.lock().ok()?.as_ref().map(|t| t.access_token.clone())
}
pub fn set_auth(&self, user: CurrentUser, tokens: Tokens) {
*self.user.lock().unwrap() = Some(user);
*self.tokens.lock().unwrap() = Some(tokens);
}
pub fn clear_auth(&self) {
*self.user.lock().unwrap() = None;
*self.tokens.lock().unwrap() = None;
}
pub fn get_user_id(&self) -> Option<Uuid> {
self.user.lock().ok()?.as_ref().map(|u| u.id)
}
}

98
client/tauri.conf.json Normal file
View File

@@ -0,0 +1,98 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FunMC",
"version": "0.1.0",
"identifier": "com.funmc.app",
"build": {
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ui && npm run dev",
"beforeBuildCommand": "cd ui && npm run build"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "FunMC - Minecraft 联机助手",
"width": 1100,
"height": 700,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"decorations": true,
"center": true,
"transparent": false
}
],
"security": {
"csp": null
}
},
"ios": {
"developmentTeam": null,
"minimumSystemVersion": "13.0"
},
"android": {
"minSdkVersion": 24,
"targetSdkVersion": 34
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"publisher": "魔幻方",
"copyright": "© 2024 魔幻方. All rights reserved.",
"category": "Game",
"shortDescription": "Minecraft 联机助手",
"longDescription": "FunMC 是一款由魔幻方开发的 Minecraft 联机工具,支持 P2P 直连和中继服务器,让 Minecraft 联机变得简单。",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": null,
"wix": null,
"nsis": {
"installerIcon": "icons/icon.ico",
"headerImage": null,
"sidebarImage": null,
"installMode": "currentUser",
"languages": ["SimpChinese", "English"],
"displayLanguageSelector": true
}
},
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null,
"minimumSystemVersion": "10.13"
},
"linux": {
"appimage": {
"bundleMediaFramework": true
},
"deb": {
"depends": []
},
"rpm": {
"depends": []
}
}
},
"plugins": {
"shell": {
"all": false,
"open": true
},
"notification": {
"all": true
}
}
}

19
client/ui/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunMC</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
client/ui/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "funmc-client-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"zustand": "^4.5.2",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,218 @@
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import { useState } from 'react'
const navItems = [
{
to: '/',
label: '大厅',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
),
},
{
to: '/friends',
label: '好友',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
},
{
to: '/settings',
label: '设置',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M22 12h-2M4 12H2M12 22v-2M12 4V2"/>
</svg>
),
},
]
export function AppLayout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
const [showUserMenu, setShowUserMenu] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
const avatarColor = user?.avatar_seed
? `hsl(${parseInt(user.avatar_seed.slice(0, 8), 16) % 360}, 60%, 50%)`
: '#4ade80'
const isInRoom = location.pathname.startsWith('/room/')
return (
<div className="flex flex-col md:flex-row h-screen bg-bg-primary overflow-hidden">
{/* Desktop Sidebar - hidden on mobile */}
<aside className="hidden md:flex w-56 flex-shrink-0 bg-bg-secondary border-r border-border flex-col">
{/* Logo */}
<div className="px-4 py-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-accent-green/20 border border-accent-green/40 flex items-center justify-center">
<svg className="w-4 h-4 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="font-bold text-text-primary text-lg">FunMC</span>
</div>
</div>
{/* Nav */}
<nav className="flex-1 p-3 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
isActive
? 'bg-accent-green/15 text-accent-green border border-accent-green/20'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
{item.icon}
{item.label}
</NavLink>
))}
</nav>
{/* User profile */}
<div className="p-3 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
style={{ backgroundColor: avatarColor }}
>
{user?.username?.[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{user?.username}</p>
<p className="text-xs text-text-muted">线</p>
</div>
<button
onClick={handleLogout}
title="退出登录"
className="text-text-muted hover:text-accent-red transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
{/* Brand footer */}
<div className="px-4 py-3 border-t border-border bg-bg-tertiary/50">
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-xs text-text-muted"></span>
</div>
<p className="text-center text-[10px] text-text-muted/60 mt-1">v0.1.0</p>
</div>
</aside>
{/* Mobile Header - shown only on mobile */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-bg-secondary border-b border-border safe-area-top">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-accent-green/20 border border-accent-green/40 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="font-bold text-text-primary">FunMC</span>
</div>
{/* User avatar button */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold"
style={{ backgroundColor: avatarColor }}
>
{user?.username?.[0]?.toUpperCase()}
</div>
</button>
{/* User dropdown menu */}
{showUserMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
<div className="absolute right-0 top-full mt-2 w-48 bg-bg-secondary border border-border rounded-lg shadow-xl z-50 py-2 animate-fade-in">
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-text-primary">{user?.username}</p>
<p className="text-xs text-text-muted">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-accent-red hover:bg-bg-tertiary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
退
</button>
</div>
</>
)}
</div>
</header>
{/* Main content */}
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
{/* Mobile Bottom Navigation - hidden when in room or on desktop */}
{!isInRoom && (
<nav className="md:hidden flex items-center justify-around bg-bg-secondary border-t border-border safe-area-bottom py-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'text-accent-green'
: 'text-text-muted'
}`
}
>
{item.icon}
<span className="text-xs font-medium">{item.label}</span>
</NavLink>
))}
</nav>
)}
</div>
)
}
export default AppLayout

View File

@@ -0,0 +1,110 @@
import { cn, generateAvatarColor, getInitials } from '../lib/utils';
interface AvatarProps {
seed: string;
name: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showOnline?: boolean;
isOnline?: boolean;
}
export function Avatar({
seed,
name,
size = 'md',
className,
showOnline = false,
isOnline = false,
}: AvatarProps) {
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
xl: 'w-12 h-12 text-lg',
};
const dotSizeClasses = {
sm: 'w-1.5 h-1.5 -bottom-0 -right-0',
md: 'w-2 h-2 -bottom-0.5 -right-0.5',
lg: 'w-2.5 h-2.5 -bottom-0.5 -right-0.5',
xl: 'w-3 h-3 -bottom-1 -right-1',
};
const bgColor = generateAvatarColor(seed);
const initials = getInitials(name);
return (
<div className={cn('relative flex-shrink-0', className)}>
<div
className={cn(
'rounded-full flex items-center justify-center font-semibold text-white',
sizeClasses[size]
)}
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
{showOnline && (
<span
className={cn(
'absolute rounded-full border-2 border-bg-secondary',
dotSizeClasses[size],
isOnline ? 'bg-accent-green shadow-[0_0_6px_#4ade80]' : 'bg-text-muted'
)}
/>
)}
</div>
);
}
interface AvatarGroupProps {
users: Array<{ seed: string; name: string }>;
max?: number;
size?: 'sm' | 'md' | 'lg';
}
export function AvatarGroup({ users, max = 4, size = 'md' }: AvatarGroupProps) {
const displayed = users.slice(0, max);
const remaining = users.length - max;
const overlapClasses = {
sm: '-ml-2',
md: '-ml-3',
lg: '-ml-4',
};
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
};
return (
<div className="flex items-center">
{displayed.map((user, index) => (
<div
key={user.seed}
className={cn(
'rounded-full border-2 border-bg-secondary',
index > 0 && overlapClasses[size]
)}
style={{ zIndex: displayed.length - index }}
>
<Avatar seed={user.seed} name={user.name} size={size} />
</div>
))}
{remaining > 0 && (
<div
className={cn(
'rounded-full bg-bg-tertiary flex items-center justify-center font-medium text-text-secondary border-2 border-bg-secondary',
overlapClasses[size],
sizeClasses[size]
)}
>
+{remaining}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { useConfigStore } from '../stores/configStore'
type ConnectionState = 'connected' | 'connecting' | 'disconnected' | 'error'
interface ConnectionStatusProps {
showDetails?: boolean
}
export function ConnectionStatus({ showDetails = false }: ConnectionStatusProps) {
const { config } = useConfigStore()
const [status, setStatus] = useState<ConnectionState>('connecting')
const [latency, setLatency] = useState<number | null>(null)
useEffect(() => {
const checkConnection = async () => {
if (!config?.server_url) {
setStatus('disconnected')
return
}
try {
const start = performance.now()
const response = await fetch(`${config.server_url}/api/v1/health`, {
signal: AbortSignal.timeout(5000),
})
const elapsed = Math.round(performance.now() - start)
if (response.ok) {
setStatus('connected')
setLatency(elapsed)
} else {
setStatus('error')
setLatency(null)
}
} catch {
setStatus('disconnected')
setLatency(null)
}
}
checkConnection()
const interval = setInterval(checkConnection, 30000)
return () => clearInterval(interval)
}, [config?.server_url])
const statusConfig = {
connected: {
color: 'bg-green-500',
text: '已连接',
icon: '🟢',
},
connecting: {
color: 'bg-yellow-500 animate-pulse',
text: '连接中',
icon: '🟡',
},
disconnected: {
color: 'bg-red-500',
text: '未连接',
icon: '🔴',
},
error: {
color: 'bg-orange-500',
text: '连接异常',
icon: '🟠',
},
}
const { color, text, icon } = statusConfig[status]
if (!showDetails) {
return (
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-xs text-gray-400">{text}</span>
</div>
)
}
return (
<div className="flex items-center gap-3 px-3 py-2 bg-gray-800/50 rounded-lg">
<span className="text-sm">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{config?.server_name || 'FunMC'}</p>
<p className="text-xs text-gray-400 truncate">{config?.server_url}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-400">{text}</p>
{latency !== null && (
<p className={`text-xs font-mono ${
latency < 100 ? 'text-green-400' :
latency < 300 ? 'text-yellow-400' : 'text-red-400'
}`}>
{latency}ms
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { useRoomStore } from '../stores/roomStore';
import { useToast } from './Toast';
const VERSIONS = ['1.21.4', '1.21.3', '1.21', '1.20.4', '1.20.1', '1.19.4', '1.18.2', '1.16.5', '1.12.2'];
export function CreateRoomModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const { createRoom, loading } = useRoomStore();
const { showToast } = useToast();
const [form, setForm] = useState({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) { showToast('请输入房间名称', 'error'); return; }
try {
await createRoom({ name: form.name.trim(), password: form.password || undefined, max_players: form.max_players, game_version: form.game_version, is_public: form.is_public });
showToast('房间创建成功', 'success');
onClose();
setForm({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true });
} catch (err) { showToast(err instanceof Error ? err.message : '创建失败', 'error'); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="创建房间">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"> <span className="text-accent-red">*</span></label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" placeholder="输入房间名称" maxLength={50} autoFocus />
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"> <span className="text-text-muted">()</span></label>
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input w-full" placeholder="留空表示无密码" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"></label>
<select value={form.max_players} onChange={(e) => setForm({ ...form, max_players: parseInt(e.target.value) })} className="input w-full">
{[2, 4, 6, 8, 10, 12, 16, 20].map((n) => <option key={n} value={n}>{n} </option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"></label>
<select value={form.game_version} onChange={(e) => setForm({ ...form, game_version: e.target.value })} className="input w-full">
{VERSIONS.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_public" checked={form.is_public} onChange={(e) => setForm({ ...form, is_public: e.target.checked })} className="w-4 h-4 rounded" />
<label htmlFor="is_public" className="text-sm text-text-secondary"></label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button type="button" onClick={onClose} className="btn-ghost"></button>
<button type="submit" className="btn-primary" disabled={loading}>{loading ? '创建中...' : '创建房间'}</button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,98 @@
import { cn } from '../lib/utils';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-text-muted opacity-40">{icon}</div>}
<h3 className="text-sm font-medium text-text-primary mb-1">{title}</h3>
{description && <p className="text-xs text-text-muted max-w-xs">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
export function NoRoomsState({ onCreate }: { onCreate?: () => void }) {
return (
<EmptyState
icon={
<svg
className="w-12 h-12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
}
title="暂无公开房间"
description="创建第一个房间开始游戏,或等待其他玩家创建"
action={
onCreate && (
<button onClick={onCreate} className="btn-primary">
</button>
)
}
/>
);
}
export function NoFriendsState() {
return (
<EmptyState
icon={
<svg
className="w-12 h-12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
}
title="还没有好友"
description="在右侧输入用户名添加好友,一起联机游戏"
/>
);
}
export function NoRequestsState() {
return (
<EmptyState
icon={
<svg
className="w-10 h-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
}
title="暂无好友请求"
description="当有人想添加你为好友时,请求会显示在这里"
/>
);
}

View File

@@ -0,0 +1,59 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full text-center">
<div className="text-5xl mb-4">😵</div>
<h2 className="text-xl font-bold text-white mb-2"></h2>
<p className="text-gray-400 text-sm mb-6">
</p>
<div className="bg-gray-700/50 rounded-lg p-3 mb-6 text-left">
<code className="text-xs text-red-400 break-all">
{this.state.error?.message || '未知错误'}
</code>
</div>
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
</button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,121 @@
import { Avatar } from './Avatar';
import { cn } from '../lib/utils';
import { Friend, FriendRequest } from '../stores/friendStore';
interface FriendCardProps {
friend: Friend;
onRemove?: () => void;
onInvite?: () => void;
className?: string;
}
export function FriendCard({ friend, onRemove, onInvite, className }: FriendCardProps) {
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-tertiary transition-colors group',
className
)}
>
<Avatar
seed={friend.avatar_seed}
name={friend.username}
size="md"
showOnline
isOnline={friend.is_online}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{friend.username}</p>
<p className="text-xs text-text-muted">{friend.is_online ? '在线' : '离线'}</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onInvite && friend.is_online && (
<button
onClick={onInvite}
className="p-1.5 rounded text-text-muted hover:text-accent-green hover:bg-accent-green/10 transition-colors"
title="邀请加入房间"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="8.5" cy="7" r="4" />
<line x1="20" y1="8" x2="20" y2="14" />
<line x1="23" y1="11" x2="17" y2="11" />
</svg>
</button>
)}
{onRemove && (
<button
onClick={onRemove}
className="p-1.5 rounded text-text-muted hover:text-accent-red hover:bg-accent-red/10 transition-colors"
title="删除好友"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
</button>
)}
</div>
</div>
);
}
interface FriendRequestCardProps {
request: FriendRequest;
onAccept: () => void;
onReject?: () => void;
className?: string;
}
export function FriendRequestCard({
request,
onAccept,
onReject,
className,
}: FriendRequestCardProps) {
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg bg-bg-tertiary',
className
)}
>
<Avatar seed={request.avatar_seed} name={request.username} size="md" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{request.username}</p>
<p className="text-xs text-text-muted"></p>
</div>
<div className="flex items-center gap-2">
{onReject && (
<button
onClick={onReject}
className="btn-ghost py-1 px-2 text-xs"
>
</button>
)}
<button onClick={onAccept} className="btn-primary py-1 px-3 text-xs">
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { useRoomStore, Room } from '../stores/roomStore';
import { useToast } from './Toast';
export function JoinRoomModal({ isOpen, onClose, room }: { isOpen: boolean; onClose: () => void; room: Room | null }) {
const { joinRoom, loading } = useRoomStore();
const { showToast } = useToast();
const [password, setPassword] = useState('');
if (!room) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (room.has_password && !password) { showToast('请输入房间密码', 'error'); return; }
try {
await joinRoom(room.id, password || undefined);
showToast('加入房间成功', 'success');
onClose();
setPassword('');
} catch (err) { showToast(err instanceof Error ? err.message : '加入失败', 'error'); }
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="加入房间">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="card bg-bg-tertiary">
<h4 className="font-medium text-text-primary">{room.name}</h4>
<p className="text-sm text-text-muted mt-1">: {room.owner_username}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
<span>: {room.current_players}/{room.max_players}</span>
<span>: {room.game_version}</span>
</div>
</div>
{room.has_password && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5"></label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input w-full" placeholder="输入房间密码" autoFocus />
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button type="button" onClick={onClose} className="btn-ghost"></button>
<button type="submit" className="btn-primary" disabled={loading}>{loading ? '加入中...' : '加入房间'}</button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,68 @@
import { cn } from '../lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-2',
lg: 'w-8 h-8 border-3',
};
return (
<div
className={cn(
'animate-spin rounded-full border-accent-green/20 border-t-accent-green',
sizeClasses[size],
className
)}
/>
);
}
interface LoadingOverlayProps {
message?: string;
}
export function LoadingOverlay({ message = '加载中...' }: LoadingOverlayProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-text-secondary">{message}</p>
</div>
</div>
);
}
interface LoadingCardProps {
className?: string;
}
export function LoadingCard({ className }: LoadingCardProps) {
return (
<div className={cn('card animate-pulse', className)}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-bg-tertiary" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 rounded bg-bg-tertiary" />
<div className="h-3 w-16 rounded bg-bg-tertiary" />
</div>
</div>
</div>
);
}
export function LoadingPage() {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-text-muted">...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
text?: string
className?: string
}
export function LoadingSpinner({ size = 'md', text, className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-3',
lg: 'w-12 h-12 border-4',
}
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div
className={`${sizeClasses[size]} border-gray-600 border-t-green-500 rounded-full animate-spin`}
/>
{text && <p className="mt-3 text-sm text-gray-400">{text}</p>}
</div>
)
}
export function FullPageLoading({ text = '加载中...' }: { text?: string }) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-gray-400">{text}</p>
</div>
</div>
)
}
export function CardLoading() {
return (
<div className="animate-pulse">
<div className="h-4 bg-gray-700 rounded w-3/4 mb-3"></div>
<div className="h-3 bg-gray-700 rounded w-1/2 mb-2"></div>
<div className="h-3 bg-gray-700 rounded w-2/3"></div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
}
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
}
return () => document.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
}[size];
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={(e) => e.target === overlayRef.current && onClose()}
>
<div
className={`w-full ${sizeClasses} bg-bg-secondary border border-border rounded-xl shadow-2xl animate-scale-in`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 id="modal-title" className="text-lg font-semibold text-text-primary">
{title}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors"
aria-label="关闭"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5">
{children}
</div>
</div>
</div>
);
}
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'default';
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = '确认',
cancelText = '取消',
variant = 'default',
}: ConfirmModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-sm text-text-secondary mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary">
{cancelText}
</button>
<button
onClick={() => {
onConfirm();
onClose();
}}
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
>
{confirmText}
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { cn, formatBytes, formatDuration } from '../lib/utils';
interface ConnectionStats {
is_connected: boolean;
connection_type: 'p2p' | 'relay' | 'none';
local_address: string | null;
remote_address: string | null;
bytes_sent: number;
bytes_received: number;
latency_ms: number;
connected_since: string | null;
packets_sent: number;
packets_received: number;
packets_lost: number;
}
export function NetworkStats({ className, compact = false }: { className?: string; compact?: boolean }) {
const [stats, setStats] = useState<ConnectionStats | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
const data = await invoke<ConnectionStats>('get_connection_stats');
setStats(data);
} catch {}
};
fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, []);
if (!stats) {
return <div className={cn('card animate-pulse', className)}><div className="h-4 w-24 bg-bg-tertiary rounded" /></div>;
}
if (compact) {
return (
<div className={cn('flex items-center gap-3', className)}>
<ConnectionBadge type={stats.connection_type} />
{stats.is_connected && <span className="text-xs text-text-muted">{stats.latency_ms}ms</span>}
</div>
);
}
return (
<div className={cn('card', className)}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-text-primary"></h3>
<ConnectionBadge type={stats.connection_type} />
</div>
{stats.is_connected ? (
<div className="grid grid-cols-2 gap-3">
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{stats.latency_ms}ms</p></div>
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{formatBytes(stats.bytes_sent)}</p></div>
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{formatBytes(stats.bytes_received)}</p></div>
<div><p className="text-xs text-text-muted"></p><p className="text-sm font-medium">{stats.connected_since ? formatDuration(Date.now() - new Date(stats.connected_since).getTime()) : '-'}</p></div>
</div>
) : <p className="text-text-muted text-sm"></p>}
</div>
);
}
function ConnectionBadge({ type }: { type: string }) {
const styles: Record<string, string> = {
p2p: 'bg-accent-green/15 text-accent-green',
relay: 'bg-accent-orange/15 text-accent-orange',
none: 'bg-text-muted/15 text-text-muted',
};
const labels: Record<string, string> = { p2p: 'P2P 直连', relay: '中继', none: '未连接' };
return <span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', styles[type])}>{labels[type]}</span>;
}

View File

@@ -0,0 +1,144 @@
import { cn } from '../lib/utils';
import { Room } from '../stores/roomStore';
interface RoomCardProps {
room: Room;
onClick?: () => void;
className?: string;
}
export function RoomCard({ room, onClick, className }: RoomCardProps) {
const statusColors: Record<string, string> = {
open: 'bg-accent-green/15 text-accent-green border-accent-green/20',
in_game: 'bg-accent-orange/15 text-accent-orange border-accent-orange/20',
closed: 'bg-text-muted/15 text-text-muted border-text-muted/20',
};
const statusLabels: Record<string, string> = {
open: '开放',
in_game: '游戏中',
closed: '已关闭',
};
return (
<div
className={cn(
'card-hover group',
onClick && 'cursor-pointer',
className
)}
onClick={onClick}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary truncate group-hover:text-accent-green transition-colors">
{room.name}
</h3>
{room.has_password && (
<svg
className="w-3.5 h-3.5 text-text-muted flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)}
</div>
<p className="text-text-muted text-xs mt-1">: {room.owner_username}</p>
</div>
<div className="flex-shrink-0">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border',
statusColors[room.status] ?? statusColors.closed
)}
>
{statusLabels[room.status] ?? room.status}
</span>
</div>
</div>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>
{room.current_players}/{room.max_players}
</span>
</div>
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span>{room.game_version}</span>
</div>
{!room.is_public && (
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
<span></span>
</div>
)}
</div>
</div>
);
}
interface RoomCardSkeletonProps {
className?: string;
}
export function RoomCardSkeleton({ className }: RoomCardSkeletonProps) {
return (
<div className={cn('card animate-pulse', className)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 bg-bg-tertiary rounded" />
<div className="h-3 w-20 bg-bg-tertiary rounded" />
</div>
<div className="h-5 w-12 bg-bg-tertiary rounded-full" />
</div>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
<div className="h-3 w-12 bg-bg-tertiary rounded" />
<div className="h-3 w-10 bg-bg-tertiary rounded" />
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
interface Toast {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).slice(2, 9);
setToasts((prev) => [...prev, { ...toast, id }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
const toast = {
success: (message: string) => context.addToast({ type: 'success', message }),
error: (message: string) => context.addToast({ type: 'error', message }),
info: (message: string) => context.addToast({ type: 'info', message }),
warning: (message: string) => context.addToast({ type: 'warning', message }),
};
return toast;
}
function ToastContainer() {
const context = useContext(ToastContext);
if (!context) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{context.toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => context.removeToast(toast.id)} />
))}
</div>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, toast.duration || 4000);
return () => clearTimeout(timer);
}, [toast.duration, onClose]);
const bgColor = {
success: 'bg-accent-green/15 border-accent-green/30 text-accent-green',
error: 'bg-accent-red/15 border-accent-red/30 text-accent-red',
info: 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue',
warning: 'bg-accent-orange/15 border-accent-orange/30 text-accent-orange',
}[toast.type];
const icon = {
success: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
),
info: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
}[toast.type];
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm shadow-lg animate-slide-up ${bgColor}`}
role="alert"
>
{icon}
<span className="text-sm font-medium text-text-primary">{toast.message}</span>
<button
onClick={onClose}
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors"
aria-label="关闭"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
export { AppLayout } from './AppLayout';
export { Avatar, AvatarGroup } from './Avatar';
export { ConnectionStatus } from './ConnectionStatus';
export { CreateRoomModal } from './CreateRoomModal';
export { EmptyState, NoRoomsState, NoFriendsState, NoRequestsState, NoSearchResultsState } from './EmptyState';
export { FriendCard, FriendRequestCard } from './FriendCard';
export { JoinRoomModal } from './JoinRoomModal';
export { Loading, LoadingOverlay, LoadingCard, LoadingSpinner } from './Loading';
export { Modal, ConfirmModal } from './Modal';
export { NetworkStats } from './NetworkStats';
export { RoomCard, RoomCardSkeleton } from './RoomCard';
export { ToastProvider, useToast } from './Toast';

View File

@@ -0,0 +1,6 @@
{
"server_url": "",
"server_name": "",
"relay_url": "",
"version": "0.1.0"
}

View File

@@ -0,0 +1 @@
export { useSignalingEvents, useNetworkStatus, useFriendEvents, useRoomEvents } from './useWebSocket';

View File

@@ -0,0 +1,147 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useAuthStore } from '../stores/authStore';
type SignalingEvent =
| 'friend:request_received'
| 'friend:request_accepted'
| 'friend:online'
| 'friend:offline'
| 'room:invite_received'
| 'room:member_joined'
| 'room:member_left'
| 'network:signaling'
| 'network:status_changed';
interface EventHandler<T = unknown> {
event: SignalingEvent;
handler: (payload: T) => void;
}
export function useSignalingEvents(handlers: EventHandler[]) {
useEffect(() => {
const unsubscribes: (() => void)[] = [];
for (const { event, handler } of handlers) {
listen(event, (e) => handler(e.payload)).then((unsub) => {
unsubscribes.push(unsub);
});
}
return () => {
unsubscribes.forEach((unsub) => unsub());
};
}, [handlers]);
}
export function useNetworkStatus() {
const [status, setStatus] = useState<{
type: 'p2p' | 'relay' | 'disconnected';
port?: number;
connectAddr?: string;
}>({ type: 'disconnected' });
useEffect(() => {
const unsub = listen<{ type: string; port?: number; connect_addr?: string }>(
'network:status_changed',
(event) => {
setStatus({
type: event.payload.type as 'p2p' | 'relay' | 'disconnected',
port: event.payload.port,
connectAddr: event.payload.connect_addr,
});
}
);
return () => {
unsub.then((fn) => fn());
};
}, []);
return status;
}
export function useFriendEvents(
onRequest?: (from: string, username: string) => void,
onAccepted?: (from: string, username: string) => void,
onOnline?: (userId: string) => void,
onOffline?: (userId: string) => void
) {
useEffect(() => {
const unsubscribes: Promise<() => void>[] = [];
if (onRequest) {
unsubscribes.push(
listen<{ from: string; username: string }>('friend:request_received', (e) => {
onRequest(e.payload.from, e.payload.username);
})
);
}
if (onAccepted) {
unsubscribes.push(
listen<{ from: string; username: string }>('friend:request_accepted', (e) => {
onAccepted(e.payload.from, e.payload.username);
})
);
}
if (onOnline) {
unsubscribes.push(
listen<{ user_id: string }>('friend:online', (e) => {
onOnline(e.payload.user_id);
})
);
}
if (onOffline) {
unsubscribes.push(
listen<{ user_id: string }>('friend:offline', (e) => {
onOffline(e.payload.user_id);
})
);
}
return () => {
unsubscribes.forEach((p) => p.then((fn) => fn()));
};
}, [onRequest, onAccepted, onOnline, onOffline]);
}
export function useRoomEvents(
onInvite?: (from: string, roomId: string, roomName: string) => void,
onMemberJoined?: (roomId: string, userId: string, username: string) => void,
onMemberLeft?: (roomId: string, userId: string) => void
) {
useEffect(() => {
const unsubscribes: Promise<() => void>[] = [];
if (onInvite) {
unsubscribes.push(
listen<{ from: string; room_id: string; room_name: string }>('room:invite_received', (e) => {
onInvite(e.payload.from, e.payload.room_id, e.payload.room_name);
})
);
}
if (onMemberJoined) {
unsubscribes.push(
listen<{ room_id: string; user_id: string; username: string }>('room:member_joined', (e) => {
onMemberJoined(e.payload.room_id, e.payload.user_id, e.payload.username);
})
);
}
if (onMemberLeft) {
unsubscribes.push(
listen<{ room_id: string; user_id: string }>('room:member_left', (e) => {
onMemberLeft(e.payload.room_id, e.payload.user_id);
})
);
}
return () => {
unsubscribes.forEach((p) => p.then((fn) => fn()));
};
}, [onInvite, onMemberJoined, onMemberLeft]);
}

207
client/ui/src/index.css Normal file
View File

@@ -0,0 +1,207 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
background-color: #0f1117;
color: #e2e8f0;
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
}
/* Safe area for mobile devices (notch, home indicator) */
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-left {
padding-left: env(safe-area-inset-left, 0);
}
.safe-area-right {
padding-right: env(safe-area-inset-right, 0);
}
/* Touch-friendly tap targets */
@media (pointer: coarse) {
button, a, input, select, textarea {
min-height: 44px;
}
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #363a50;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4f6a;
}
/* 禁止拖拽 */
img, a {
-webkit-user-drag: none;
user-drag: none;
}
}
@layer components {
.btn-primary {
@apply px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-semibold text-sm
hover:bg-green-300 active:scale-95 transition-all duration-150 disabled:opacity-50
disabled:cursor-not-allowed;
}
.btn-secondary {
@apply px-4 py-2 rounded-lg bg-bg-tertiary text-text-primary font-medium text-sm
border border-border hover:bg-bg-hover active:scale-95 transition-all duration-150;
}
.btn-danger {
@apply px-4 py-2 rounded-lg bg-accent-red/20 text-accent-red font-medium text-sm
border border-accent-red/30 hover:bg-accent-red/30 active:scale-95 transition-all duration-150;
}
.btn-ghost {
@apply px-4 py-2 rounded-lg text-text-secondary font-medium text-sm
hover:bg-bg-tertiary hover:text-text-primary active:scale-95 transition-all duration-150;
}
.input-field {
@apply w-full px-3 py-2 rounded-lg bg-bg-tertiary border border-border text-text-primary
text-sm placeholder:text-text-muted focus:outline-none focus:border-accent-green/60
focus:ring-1 focus:ring-accent-green/30 transition-all duration-150;
}
.card {
@apply bg-bg-secondary rounded-xl border border-border p-4;
}
.card-hover {
@apply bg-bg-secondary rounded-xl border border-border p-4
hover:border-accent-green/30 hover:bg-bg-tertiary/50 transition-all duration-200 cursor-pointer;
}
.badge-online {
@apply w-2 h-2 rounded-full bg-accent-green shadow-[0_0_6px_#4ade80];
}
.badge-offline {
@apply w-2 h-2 rounded-full bg-text-muted;
}
.badge-status {
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-status-open {
@apply badge-status bg-accent-green/15 text-accent-green border border-accent-green/20;
}
.badge-status-ingame {
@apply badge-status bg-accent-orange/15 text-accent-orange border border-accent-orange/20;
}
.badge-status-closed {
@apply badge-status bg-text-muted/15 text-text-muted border border-text-muted/20;
}
/* 加载动画 */
.loading-spinner {
@apply animate-spin rounded-full border-2 border-accent-green/20 border-t-accent-green;
}
/* 工具提示 */
.tooltip {
@apply absolute z-50 px-2 py-1 text-xs font-medium text-text-primary
bg-bg-tertiary border border-border rounded-lg shadow-lg
animate-fade-in;
}
/* 连接状态指示器 */
.connection-indicator {
@apply inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium;
}
.connection-p2p {
@apply connection-indicator bg-accent-green/15 text-accent-green;
}
.connection-relay {
@apply connection-indicator bg-accent-orange/15 text-accent-orange;
}
.connection-disconnected {
@apply connection-indicator bg-text-muted/15 text-text-muted;
}
}
@layer utilities {
/* 动画 */
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
.animate-slide-down {
animation: slideDown 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
.animate-pulse-green {
animation: pulseGreen 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulseGreen {
0%, 100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(74, 222, 128, 0); }
}
/* Minecraft 风格像素边框 */
.pixel-border {
box-shadow:
inset -2px -2px 0 0 #1a1d27,
inset 2px 2px 0 0 #4a4f6a;
}
}

View File

@@ -0,0 +1,93 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}小时${minutes % 60}分钟`;
}
if (minutes > 0) {
return `${minutes}分钟${seconds % 60}`;
}
return `${seconds}`;
}
export function formatRelativeTime(date: Date | string | number): string {
const now = new Date();
const then = new Date(date);
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return '刚刚';
if (diffMin < 60) return `${diffMin}分钟前`;
if (diffHour < 24) return `${diffHour}小时前`;
if (diffDay < 7) return `${diffDay}天前`;
return then.toLocaleDateString('zh-CN');
}
export function generateAvatarColor(seed: string): string {
const hash = seed.split('').reduce((acc, char) => {
return char.charCodeAt(0) + ((acc << 5) - acc);
}, 0);
const h = Math.abs(hash) % 360;
return `hsl(${h}, 60%, 50%)`;
}
export function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
export function copyToClipboard(text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), wait);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}

124
client/ui/src/main.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import { createMemoryRouter, RouterProvider, redirect, Navigate } from 'react-router-dom'
import './index.css'
import LoginPage from './pages/Login'
import RegisterPage from './pages/Register'
import DashboardPage from './pages/Dashboard'
import FriendsPage from './pages/Friends'
import RoomPage from './pages/Room'
import SettingsPage from './pages/Settings'
import ServerSetupPage from './pages/ServerSetup'
import AppLayout from './components/AppLayout'
import { ToastProvider } from './components/Toast'
import { useAuthStore } from './stores/authStore'
import { useConfigStore } from './stores/configStore'
function requireAuth() {
const { user } = useAuthStore.getState()
if (!user) return redirect('/login')
return null
}
function requireGuest() {
const { user } = useAuthStore.getState()
if (user) return redirect('/dashboard')
return null
}
function requireConfig() {
const { config } = useConfigStore.getState()
if (!config || !config.server_url) return redirect('/setup')
return null
}
const router = createMemoryRouter([
{
path: '/setup',
element: <ServerSetupPage />,
},
{
path: '/login',
loader: () => {
requireConfig()
return requireGuest()
},
element: <LoginPage />,
},
{
path: '/register',
loader: () => {
requireConfig()
return requireGuest()
},
element: <RegisterPage />,
},
{
path: '/',
loader: requireAuth,
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'friends', element: <FriendsPage /> },
{ path: 'room/:roomId', element: <RoomPage /> },
{ path: 'settings', element: <SettingsPage /> },
],
},
{ path: '*', element: <Navigate to="/login" replace /> },
], { initialEntries: ['/setup'] })
function App() {
const { init: initAuth } = useAuthStore()
const { initConfig, config } = useConfigStore()
const [ready, setReady] = useState(false)
useEffect(() => {
const initialize = async () => {
await initConfig()
const { config: currentConfig } = useConfigStore.getState()
if (currentConfig && currentConfig.server_url) {
await initAuth()
const { user } = useAuthStore.getState()
if (user) {
router.navigate('/dashboard', { replace: true })
} else {
router.navigate('/login', { replace: true })
}
} else {
router.navigate('/setup', { replace: true })
}
setReady(true)
}
initialize()
}, [])
if (!ready) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="text-4xl mb-4">🎮</div>
<div className="text-white text-xl font-bold">FunMC</div>
<div className="text-gray-400 text-sm mt-2">...</div>
</div>
</div>
)
}
return (
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,232 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRoomStore, Room } from '../stores/roomStore'
import { useAuthStore } from '../stores/authStore'
function RoomCard({ room, onJoin }: { room: Room; onJoin: (room: Room) => void }) {
const statusColors: Record<string, string> = {
open: 'text-accent-green',
in_game: 'text-accent-orange',
closed: 'text-text-muted',
}
const statusLabels: Record<string, string> = {
open: '开放',
in_game: '游戏中',
closed: '已关闭',
}
return (
<div className="card hover:border-border-DEFAULT hover:bg-bg-tertiary transition-colors duration-150 cursor-pointer group"
onClick={() => onJoin(room)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary truncate">{room.name}</h3>
{room.has_password && (
<svg className="w-3.5 h-3.5 text-text-muted flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
)}
</div>
<p className="text-text-muted text-xs mt-1">: {room.owner_username}</p>
</div>
<div className="text-right flex-shrink-0">
<span className={`text-xs font-medium ${statusColors[room.status] ?? 'text-text-muted'}`}>
{statusLabels[room.status] ?? room.status}
</span>
</div>
</div>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
{room.current_players}/{room.max_players}
</div>
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
{room.game_version}
</div>
</div>
</div>
)
}
export default function DashboardPage() {
const { rooms, loading, fetchRooms, joinRoom, createRoom } = useRoomStore()
const { user } = useAuthStore()
const navigate = useNavigate()
const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('')
const [createVersion, setCreateVersion] = useState('1.20')
const [createMax, setCreateMax] = useState('10')
const [createPw, setCreatePw] = useState('')
const [createPublic, setCreatePublic] = useState(true)
const [createError, setCreateError] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
fetchRooms()
const id = setInterval(fetchRooms, 10000)
return () => clearInterval(id)
}, [])
const handleJoin = async (room: Room) => {
try {
let password: string | undefined
if (room.has_password) {
password = window.prompt(`房间 "${room.name}" 需要密码:`) ?? undefined
if (password === undefined) return
}
await joinRoom(room.id, password)
navigate(`/room/${room.id}`)
} catch (e: any) {
alert(`加入失败: ${e}`)
}
}
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setCreateError('')
setCreating(true)
try {
const roomId = await createRoom({
name: createName,
maxPlayers: parseInt(createMax) || 10,
isPublic: createPublic,
password: createPw || undefined,
gameVersion: createVersion,
})
await joinRoom(roomId)
navigate(`/room/${roomId}`)
} catch (e: any) {
setCreateError(String(e))
setCreating(false)
}
}
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-muted text-xs mt-0.5"></p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fetchRooms()}
className="btn-secondary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button
onClick={() => setShowCreate(true)}
className="btn-primary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
</div>
{/* Room grid */}
<div className="flex-1 overflow-y-auto p-6">
{loading && rooms.length === 0 ? (
<div className="flex items-center justify-center h-32 text-text-muted text-sm">
...
</div>
) : rooms.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-text-muted">
<svg className="w-12 h-12 mb-3 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{rooms.map((room) => (
<RoomCard key={room.id} room={room} onJoin={handleJoin} />
))}
</div>
)}
</div>
{/* Create room modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="card w-full max-w-sm animate-slide-up">
<div className="flex items-center justify-between mb-5">
<h2 className="font-semibold text-text-primary"></h2>
<button onClick={() => setShowCreate(false)} className="text-text-muted hover:text-text-primary">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" placeholder="我的房间" value={createName}
onChange={(e) => setCreateName(e.target.value)} required />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" placeholder="1.20" value={createVersion}
onChange={(e) => setCreateVersion(e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" type="number" min="2" max="20" value={createMax}
onChange={(e) => setCreateMax(e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs text-text-secondary mb-1"></label>
<input className="input-field" type="password" placeholder="留空表示无密码" value={createPw}
onChange={(e) => setCreatePw(e.target.value)} />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={createPublic}
onChange={(e) => setCreatePublic(e.target.checked)}
className="w-4 h-4 rounded accent-accent-green" />
<span className="text-sm text-text-secondary"></span>
</label>
{createError && (
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-xs">
{createError}
</div>
)}
<div className="flex gap-2 pt-1">
<button type="button" className="btn-secondary flex-1" onClick={() => setShowCreate(false)}></button>
<button type="submit" className="btn-primary flex-1" disabled={creating}>
{creating ? '创建中...' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { useFriendStore, Friend } from '../stores/friendStore'
function Avatar({ seed, username, size = 8 }: { seed: string; username: string; size?: number }) {
const color = `hsl(${parseInt(seed.slice(0, 8), 16) % 360}, 60%, 50%)`
return (
<div
className={`w-${size} h-${size} rounded-full flex items-center justify-center text-white font-bold flex-shrink-0`}
style={{ backgroundColor: color, fontSize: size < 10 ? '0.75rem' : '1rem' }}
>
{username[0]?.toUpperCase()}
</div>
)
}
function FriendItem({ friend, onRemove }: { friend: Friend; onRemove: (id: string) => void }) {
return (
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-tertiary transition-colors group">
<div className="relative">
<Avatar seed={friend.avatar_seed} username={friend.username} size={8} />
<span className={`absolute -bottom-0.5 -right-0.5 ${friend.is_online ? 'badge-online' : 'badge-offline'}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{friend.username}</p>
<p className="text-xs text-text-muted">{friend.is_online ? '在线' : '离线'}</p>
</div>
<button
onClick={() => onRemove(friend.id)}
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-accent-red transition-all"
title="删除好友"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
)
}
export default function FriendsPage() {
const { friends, requests, loading, fetchFriends, fetchRequests, sendRequest, acceptRequest, removeFriend } = useFriendStore()
const [tab, setTab] = useState<'friends' | 'requests'>('friends')
const [addUsername, setAddUsername] = useState('')
const [addError, setAddError] = useState('')
const [addSuccess, setAddSuccess] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => {
fetchFriends()
fetchRequests()
}, [])
const handleSendRequest = async (e: React.FormEvent) => {
e.preventDefault()
setAddError('')
setAddSuccess('')
setSending(true)
try {
await sendRequest(addUsername)
setAddSuccess(`已向 ${addUsername} 发送好友请求`)
setAddUsername('')
} catch (e: any) {
setAddError(String(e))
} finally {
setSending(false)
}
}
const onlineCount = friends.filter((f) => f.is_online).length
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border">
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-muted text-xs mt-0.5">{onlineCount} 线 · {friends.length} </p>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left: friend list */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex gap-1 px-4 pt-4">
{(['friends', 'requests'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
tab === t
? 'bg-bg-tertiary text-text-primary'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{t === 'friends' ? '好友列表' : `好友请求 ${requests.length > 0 ? `(${requests.length})` : ''}`}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{tab === 'friends' ? (
loading ? (
<p className="text-text-muted text-sm text-center py-8">...</p>
) : friends.length === 0 ? (
<div className="text-center py-12 text-text-muted">
<svg className="w-10 h-10 mx-auto mb-3 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
friends.map((f) => (
<FriendItem
key={f.id}
friend={f}
onRemove={(id) => { if (confirm(`确定删除好友 ${f.username}`)) removeFriend(id) }}
/>
))
)
) : requests.length === 0 ? (
<p className="text-text-muted text-sm text-center py-8"></p>
) : (
requests.map((req) => (
<div key={req.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-bg-tertiary">
<Avatar seed={req.avatar_seed} username={req.username} size={8} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{req.username}</p>
<p className="text-xs text-text-muted"></p>
</div>
<button
onClick={() => acceptRequest(req.id).then(fetchFriends)}
className="btn-primary py-1 px-3 text-xs"
>
</button>
</div>
))
)}
</div>
</div>
{/* Right: add friend */}
<div className="w-64 border-l border-border p-4 flex-shrink-0">
<h3 className="text-sm font-semibold text-text-primary mb-3"></h3>
<form onSubmit={handleSendRequest} className="space-y-2">
<input
className="input-field"
placeholder="输入用户名"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
required
/>
{addError && <p className="text-xs text-accent-red">{addError}</p>}
{addSuccess && <p className="text-xs text-accent-green">{addSuccess}</p>}
<button type="submit" disabled={sending} className="btn-primary w-full">
{sending ? '发送中...' : '发送请求'}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
export default function LoginPage() {
const { login, loading } = useAuthStore()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await login(username, password)
navigate('/dashboard')
} catch (e: any) {
setError(String(e))
}
}
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent-green/10 border border-accent-green/30 mb-4">
<svg className="w-8 h-8 text-accent-green" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary">FunMC</h1>
<p className="text-text-secondary text-sm mt-1">Minecraft </p>
</div>
{/* Card */}
<div className="card">
<h2 className="text-lg font-semibold text-text-primary mb-6"></h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="text"
className="input-field"
placeholder="输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="password"
className="input-field"
placeholder="输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full mt-2"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p className="text-center text-text-muted text-sm mt-4">
{' '}
<Link to="/register" className="text-accent-green hover:underline">
</Link>
</p>
</div>
{/* Brand footer */}
<div className="mt-8 text-center">
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-xs text-text-muted"></span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
export default function RegisterPage() {
const { register, loading } = useAuthStore()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirm) {
setError('两次密码不一致')
return
}
if (password.length < 8) {
setError('密码至少 8 位')
return
}
try {
await register(username, email, password)
navigate('/dashboard')
} catch (e: any) {
setError(String(e))
}
}
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent-green/10 border border-accent-green/30 mb-4">
<svg className="w-8 h-8 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary">FunMC</h1>
<p className="text-text-secondary text-sm mt-1"></p>
</div>
<div className="card">
<h2 className="text-lg font-semibold text-text-primary mb-6"></h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="text"
className="input-field"
placeholder="3-32 个字符"
value={username}
onChange={(e) => setUsername(e.target.value)}
minLength={3}
maxLength={32}
required
autoFocus
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="email"
className="input-field"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="password"
className="input-field"
placeholder="至少 8 位"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5"></label>
<input
type="password"
className="input-field"
placeholder="再次输入密码"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full mt-2"
>
{loading ? '注册中...' : '创建账号'}
</button>
</form>
<p className="text-center text-text-muted text-sm mt-4">
{' '}
<Link to="/login" className="text-accent-green hover:underline">
</Link>
</p>
</div>
{/* Brand footer */}
<div className="mt-8 text-center">
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-xs text-text-muted"></span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,430 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useRoomStore } from '../stores/roomStore'
import { useNetworkStore } from '../stores/networkStore'
import { useAuthStore } from '../stores/authStore'
import { useChatStore } from '../stores/chatStore'
import { useToast } from '../components/Toast'
export default function RoomPage() {
const { roomId } = useParams<{ roomId: string }>()
const navigate = useNavigate()
const { currentRoom, rooms, members, leaveRoom, setCurrentRoom, fetchMembers } = useRoomStore()
const { session, connectAddr, startHosting, joinNetwork, stopNetwork, stats, refreshStats } = useNetworkStore()
const { user } = useAuthStore()
const { messages, sendMessage, clearMessages, subscribeToChat } = useChatStore()
const { showToast } = useToast()
const [connecting, setConnecting] = useState(false)
const [copied, setCopied] = useState(false)
const [chatInput, setChatInput] = useState('')
const [showChat, setShowChat] = useState(true)
const [kickingUser, setKickingUser] = useState<string | null>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const room = currentRoom ?? rooms.find((r) => r.id === roomId)
useEffect(() => {
if (roomId) {
fetchMembers(roomId)
const membersInterval = setInterval(() => fetchMembers(roomId), 5000)
return () => clearInterval(membersInterval)
}
}, [roomId])
useEffect(() => {
const id = setInterval(refreshStats, 2000)
return () => clearInterval(id)
}, [])
useEffect(() => {
let unlisten: (() => void) | null = null
subscribeToChat().then((fn) => {
unlisten = fn
})
return () => {
unlisten?.()
clearMessages()
}
}, [])
useEffect(() => {
const unlistenKicked = listen<{ room_id: string; reason: string }>('signaling:kicked', (event) => {
if (event.payload.room_id === roomId) {
showToast(event.payload.reason || '你已被踢出房间', 'error')
navigate('/dashboard')
}
})
const unlistenClosed = listen<{ room_id: string }>('signaling:room_closed', (event) => {
if (event.payload.room_id === roomId) {
showToast('房间已关闭', 'info')
navigate('/dashboard')
}
})
return () => {
unlistenKicked.then((fn) => fn())
unlistenClosed.then((fn) => fn())
}
}, [roomId])
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}
}, [messages])
useEffect(() => {
if (!room && !currentRoom) {
navigate('/dashboard')
}
}, [room])
const isOwner = room?.owner_id === user?.id
const handleConnect = async () => {
if (!roomId || !room) return
setConnecting(true)
try {
if (isOwner) {
await startHosting(roomId, room.name)
} else {
await joinNetwork(roomId, room.owner_id)
}
} catch (e: any) {
alert(`连接失败: ${e}`)
} finally {
setConnecting(false)
}
}
const handleLeave = async () => {
if (!roomId) return
await stopNetwork()
await leaveRoom(roomId)
setCurrentRoom(null)
navigate('/dashboard')
}
const handleCopy = () => {
const addr = connectAddr ?? '127.0.0.1:25565'
navigator.clipboard.writeText(addr)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleSendChat = async (e: React.FormEvent) => {
e.preventDefault()
if (!roomId || !chatInput.trim()) return
try {
await sendMessage(roomId, chatInput.trim())
setChatInput('')
} catch (e: any) {
console.error('Failed to send message:', e)
}
}
const handleKickMember = async (targetUserId: string, username: string) => {
if (!roomId) return
if (!confirm(`确定要踢出 ${username} 吗?`)) return
setKickingUser(targetUserId)
try {
await invoke('kick_room_member', { roomId, targetUserId })
showToast(`已踢出 ${username}`, 'success')
fetchMembers(roomId)
} catch (e: any) {
showToast(`踢出失败: ${e}`, 'error')
} finally {
setKickingUser(null)
}
}
const handleCloseRoom = async () => {
if (!roomId) return
if (!confirm('确定要关闭房间吗?所有成员将被踢出。')) return
try {
await invoke('close_room', { roomId })
showToast('房间已关闭', 'success')
navigate('/dashboard')
} catch (e: any) {
showToast(`关闭失败: ${e}`, 'error')
}
}
const sessionTypeLabel = stats?.session_type === 'p2p' ? 'P2P 直连' : stats?.session_type === 'relay' ? '中继' : '未连接'
const sessionTypeColor = stats?.session_type === 'p2p' ? 'text-accent-green' : stats?.session_type === 'relay' ? 'text-accent-orange' : 'text-text-muted'
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-text-primary">{room?.name ?? '房间'}</h1>
<p className="text-text-muted text-xs mt-0.5">
{room?.game_version} · {room?.current_players}/{room?.max_players}
{isOwner && <span className="ml-2 text-accent-green"></span>}
</p>
</div>
<button onClick={handleLeave} className="btn-danger flex items-center gap-2">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Network status card */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-4"></h2>
{!session ? (
<div className="space-y-3">
<p className="text-text-secondary text-sm">
{isOwner
? '点击"开始托管"让 Minecraft 服务器接受来自此房间成员的连接。'
: '点击"连接"获取本地代理地址,然后在 Minecraft 中添加该服务器。'}
</p>
<button
onClick={handleConnect}
disabled={connecting}
className="btn-primary flex items-center gap-2"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
{connecting ? '连接中...' : isOwner ? '开始托管' : '连接'}
</button>
</div>
) : (
<div className="space-y-4">
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className={`text-sm font-semibold ${sessionTypeColor}`}>{sessionTypeLabel}</p>
</div>
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className="text-sm font-semibold text-text-primary">
{stats?.connected ? `${stats.latency_ms} ms` : '—'}
</p>
</div>
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className="text-sm font-mono text-text-primary">
{stats ? formatBytes(stats.bytes_sent) : '—'}
</p>
</div>
<div className="bg-bg-tertiary rounded-lg p-3">
<p className="text-xs text-text-muted mb-1"></p>
<p className="text-sm font-mono text-text-primary">
{stats ? formatBytes(stats.bytes_received) : '—'}
</p>
</div>
</div>
{/* MC connect address */}
{!isOwner && connectAddr && (
<div>
<p className="text-xs text-text-muted mb-2"> Minecraft </p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm font-mono text-accent-green">
{connectAddr}
</code>
<button onClick={handleCopy} className="btn-secondary px-3 py-2 text-xs">
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
)}
{isOwner && (
<div className="px-3 py-2 bg-accent-green/10 border border-accent-green/20 rounded-lg">
<p className="text-xs text-accent-green">
Minecraft FunMC
</p>
</div>
)}
</div>
)}
</div>
{/* Room members */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-3">
({members.length})
</h2>
<div className="space-y-2">
{members.length === 0 ? (
<p className="text-text-muted text-sm">...</p>
) : (
members.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between py-2 px-3 bg-bg-tertiary rounded-lg"
>
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-8 h-8 rounded-full bg-accent-green/20 flex items-center justify-center text-accent-green text-sm font-medium">
{member.username.charAt(0).toUpperCase()}
</div>
<span
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-bg-tertiary ${
member.is_online ? 'bg-accent-green' : 'bg-text-muted'
}`}
/>
</div>
<div>
<p className="text-sm text-text-primary font-medium">
{member.username}
{member.user_id === user?.id && (
<span className="ml-1 text-text-muted">()</span>
)}
</p>
<p className="text-xs text-text-muted">
{member.role === 'owner' ? '房主' : '成员'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs ${
member.is_online ? 'text-accent-green' : 'text-text-muted'
}`}
>
{member.is_online ? '在线' : '离线'}
</span>
{isOwner && member.user_id !== user?.id && (
<button
onClick={() => handleKickMember(member.user_id, member.username)}
disabled={kickingUser === member.user_id}
className="text-xs text-accent-red hover:text-red-400 disabled:opacity-50"
title="踢出"
>
{kickingUser === member.user_id ? '...' : '踢出'}
</button>
)}
</div>
</div>
))
)}
</div>
{isOwner && (
<div className="mt-4 pt-4 border-t border-border">
<button
onClick={handleCloseRoom}
className="text-xs text-accent-red hover:text-red-400"
>
</button>
</div>
)}
</div>
{/* Room info */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-3"></h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{room?.owner_username}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{room?.game_version}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">{room?.current_players}/{room?.max_players}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary capitalize">{room?.status}</span>
</div>
</div>
</div>
{/* Chat */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-text-primary"></h2>
<button
onClick={() => setShowChat(!showChat)}
className="text-text-muted hover:text-text-primary text-xs"
>
{showChat ? '收起' : '展开'}
</button>
</div>
{showChat && (
<>
<div
ref={chatContainerRef}
className="h-48 overflow-y-auto bg-bg-tertiary rounded-lg p-3 mb-3 space-y-2"
>
{messages.length === 0 ? (
<p className="text-text-muted text-xs text-center py-8">
</p>
) : (
messages
.filter((msg) => msg.room_id === roomId)
.map((msg, i) => (
<div key={i} className="text-sm">
<span className="text-accent-green font-medium">
{msg.username}
{msg.from === user?.id && ' (你)'}
</span>
<span className="text-text-muted mx-1">:</span>
<span className="text-text-primary">{msg.content}</span>
<span className="text-text-muted text-xs ml-2">
{new Date(msg.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
))
)}
</div>
<form onSubmit={handleSendChat} className="flex gap-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="输入消息..."
className="input-field flex-1"
maxLength={500}
/>
<button
type="submit"
disabled={!chatInput.trim()}
className="btn-primary px-4"
>
</button>
</form>
</>
)}
</div>
</div>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useConfigStore } from '../stores/configStore'
export default function ServerSetupPage() {
const navigate = useNavigate()
const { setCustomServer, config, loading, error } = useConfigStore()
const [serverUrl, setServerUrl] = useState('')
const [manualMode, setManualMode] = useState(false)
const handleAutoConnect = async () => {
if (config && config.server_url) {
navigate('/login')
}
}
const handleManualConnect = async () => {
if (!serverUrl.trim()) return
let url = serverUrl.trim()
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url
}
await setCustomServer(url)
const { config: newConfig } = useConfigStore.getState()
if (newConfig && newConfig.server_url) {
navigate('/login')
}
}
const hasEmbeddedConfig = config && config.server_url && config.server_url !== 'http://localhost:3000'
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="text-6xl mb-4">🎮</div>
<h1 className="text-3xl font-bold text-white mb-2">FunMC</h1>
<p className="text-gray-400">Minecraft </p>
</div>
<div className="bg-gray-800/50 backdrop-blur rounded-2xl p-6 border border-gray-700/50">
{hasEmbeddedConfig ? (
<>
<div className="text-center mb-6">
<div className="inline-flex items-center gap-2 bg-green-500/20 text-green-400 px-4 py-2 rounded-full text-sm">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
</div>
</div>
<div className="bg-gray-700/50 rounded-xl p-4 mb-6">
<p className="text-sm text-gray-400 mb-1"></p>
<p className="text-white font-medium">{config?.server_name || 'FunMC Server'}</p>
<p className="text-sm text-gray-400 mt-3 mb-1"></p>
<p className="text-green-400 font-mono text-sm">{config?.server_url}</p>
</div>
<button
onClick={handleAutoConnect}
className="w-full py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors"
>
</button>
<button
onClick={() => setManualMode(true)}
className="w-full mt-3 py-3 bg-gray-700 text-gray-300 rounded-xl font-medium hover:bg-gray-600 transition-colors"
>
使
</button>
</>
) : manualMode || !hasEmbeddedConfig ? (
<>
<h2 className="text-lg font-semibold text-white mb-4 text-center">
</h2>
<div className="mb-6">
<label className="block text-sm text-gray-400 mb-2">
</label>
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="例如: funmc.com:3000 或 192.168.1.100:3000"
className="w-full px-4 py-3 bg-gray-700/50 border border-gray-600 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-green-500 transition-colors"
onKeyDown={(e) => e.key === 'Enter' && handleManualConnect()}
/>
<p className="mt-2 text-xs text-gray-500">
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
onClick={handleManualConnect}
disabled={loading || !serverUrl.trim()}
className="w-full py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '连接中...' : '连接'}
</button>
{hasEmbeddedConfig && (
<button
onClick={() => setManualMode(false)}
className="w-full mt-3 py-3 bg-gray-700 text-gray-300 rounded-xl font-medium hover:bg-gray-600 transition-colors"
>
</button>
)}
</>
) : null}
</div>
<p className="text-center text-gray-500 text-xs mt-6">
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,299 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useRelayNodeStore } from '../stores/relayNodeStore'
import { useConfigStore } from '../stores/configStore'
import { useAuthStore } from '../stores/authStore'
export default function SettingsPage() {
const navigate = useNavigate()
const { nodes, loading, fetchNodes, addNode, removeNode, reportPing } = useRelayNodeStore()
const { config, customServerUrl, setCustomServer, clearCustomServer } = useConfigStore()
const { logout } = useAuthStore()
const [newName, setNewName] = useState('')
const [newUrl, setNewUrl] = useState('')
const [newRegion, setNewRegion] = useState('')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState('')
const [pinging, setPinging] = useState<string | null>(null)
const [showServerChange, setShowServerChange] = useState(false)
const [newServerUrl, setNewServerUrl] = useState('')
const [serverError, setServerError] = useState('')
const [changingServer, setChangingServer] = useState(false)
useEffect(() => {
fetchNodes()
}, [])
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
setAddError('')
setAdding(true)
try {
await addNode(newName, newUrl, newRegion || undefined)
setNewName('')
setNewUrl('')
setNewRegion('')
} catch (e: any) {
setAddError(String(e))
} finally {
setAdding(false)
}
}
const handlePing = async (id: string, url: string) => {
setPinging(id)
try {
const start = performance.now()
await fetch(`${url}/ping`, { signal: AbortSignal.timeout(4000) }).catch(() => {})
const rtt = Math.round(performance.now() - start)
await reportPing(id, rtt)
} finally {
setPinging(null)
}
}
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-text-muted text-xs mt-0.5"></p>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6 max-w-2xl">
{/* Server config */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-1"></h2>
<p className="text-xs text-text-muted mb-4"></p>
<div className="p-3 rounded-lg bg-bg-tertiary border border-border mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">
{config?.server_name || 'FunMC Server'}
</p>
<p className="text-xs text-text-muted font-mono mt-1">
{config?.server_url || '未配置'}
</p>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-accent-green rounded-full animate-pulse"></span>
<span className="text-xs text-accent-green"></span>
</div>
</div>
{customServerUrl && (
<p className="text-xs text-accent-orange mt-2">使</p>
)}
</div>
{showServerChange ? (
<div className="space-y-3">
<input
type="text"
value={newServerUrl}
onChange={(e) => setNewServerUrl(e.target.value)}
placeholder="输入新的服务器地址"
className="input-field"
/>
{serverError && (
<p className="text-xs text-accent-red">{serverError}</p>
)}
<div className="flex gap-2">
<button
onClick={async () => {
setServerError('')
setChangingServer(true)
try {
let url = newServerUrl.trim()
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url
}
await setCustomServer(url)
const { config: newConfig } = useConfigStore.getState()
if (newConfig && newConfig.server_url) {
await logout()
navigate('/login')
} else {
setServerError('无法连接到服务器')
}
} catch (e) {
setServerError(String(e))
} finally {
setChangingServer(false)
}
}}
disabled={changingServer || !newServerUrl.trim()}
className="btn-primary flex-1"
>
{changingServer ? '连接中...' : '切换服务器'}
</button>
<button
onClick={() => {
setShowServerChange(false)
setNewServerUrl('')
setServerError('')
}}
className="btn-secondary"
>
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => setShowServerChange(true)}
className="btn-secondary text-sm"
>
</button>
{customServerUrl && (
<button
onClick={async () => {
clearCustomServer()
await logout()
navigate('/setup')
}}
className="btn-secondary text-sm text-accent-orange"
>
</button>
)}
</div>
)}
</div>
{/* Relay nodes */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-1"></h2>
<p className="text-xs text-text-muted mb-4">
P2P 穿
</p>
{loading ? (
<p className="text-xs text-text-muted">...</p>
) : nodes.length === 0 ? (
<p className="text-xs text-text-muted"></p>
) : (
<div className="space-y-2 mb-4">
{nodes.map((node) => (
<div key={node.id} className="flex items-center gap-2 p-2.5 rounded-lg bg-bg-tertiary border border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">{node.name}</span>
{node.region && node.region !== 'auto' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bg-hover text-text-muted">
{node.region}
</span>
)}
{node.priority > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-green/15 text-accent-green border border-accent-green/20">
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<p className="text-xs text-text-muted font-mono truncate">{node.url}</p>
{node.last_ping_ms != null && (
<span className={`text-xs font-mono flex-shrink-0 ${
node.last_ping_ms < 80 ? 'text-accent-green' :
node.last_ping_ms < 200 ? 'text-accent-orange' : 'text-accent-red'
}`}>
{node.last_ping_ms} ms
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handlePing(node.id, node.url)}
disabled={pinging === node.id}
className="text-xs px-2 py-1 rounded text-text-muted hover:text-accent-blue hover:bg-accent-blue/10 transition-colors"
>
{pinging === node.id ? '测速...' : '测速'}
</button>
<button
onClick={() => removeNode(node.id)}
className="text-text-muted hover:text-accent-red transition-colors p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Add form */}
<form onSubmit={handleAdd} className="border-t border-border pt-4 space-y-2">
<p className="text-xs text-text-secondary font-medium mb-2"></p>
<div className="flex gap-2">
<input
className="input-field w-28"
placeholder="名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
required
/>
<input
className="input-field flex-1"
placeholder="https://relay.example.com"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
type="url"
required
/>
<input
className="input-field w-24"
placeholder="区域"
value={newRegion}
onChange={(e) => setNewRegion(e.target.value)}
/>
<button type="submit" disabled={adding} className="btn-secondary flex-shrink-0">
{adding ? '添加中' : '添加'}
</button>
</div>
{addError && <p className="text-xs text-accent-red">{addError}</p>}
</form>
</div>
{/* About */}
<div className="card">
<h2 className="text-sm font-semibold text-text-primary mb-3"></h2>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary font-mono">0.1.0</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-accent-purple font-medium"></span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<a href="https://funmc.com" target="_blank" rel="noopener" className="text-accent-green hover:underline">funmc.com</a>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">Tauri 2 + React</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted"></span>
<span className="text-text-primary">QUIC (quinn) over UDP</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className="text-sm font-medium text-text-primary"></span>
</div>
<p className="text-xs text-text-muted">© 2024 . All rights reserved.</p>
</div>
</div>
</div>
</div>
)
}

42
client/ui/src/router.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { AppLayout } from './components/AppLayout';
import Login from './pages/Login';
import Register from './pages/Register';
import DashboardPage from './pages/Dashboard';
import RoomPage from './pages/Room';
import FriendsPage from './pages/Friends';
import SettingsPage from './pages/Settings';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuthStore();
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuthStore();
if (token) return <Navigate to="/" replace />;
return <>{children}</>;
}
export const router = createBrowserRouter([
{
path: '/login',
element: <PublicRoute><Login /></PublicRoute>,
},
{
path: '/register',
element: <PublicRoute><Register /></PublicRoute>,
},
{
path: '/',
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'room/:roomId', element: <RoomPage /> },
{ path: 'friends', element: <FriendsPage /> },
{ path: 'settings', element: <SettingsPage /> },
],
},
]);

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface User {
id: string
username: string
email: string
avatar_seed: string
}
interface AuthResult {
user: User
token: string
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
error: string | null
initialized: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, email: string, password: string) => Promise<void>
logout: () => Promise<void>
init: () => Promise<void>
connectSignaling: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: localStorage.getItem('auth_token'),
loading: false,
error: null,
initialized: false,
init: async () => {
if (get().initialized) return
try {
const result = await invoke<AuthResult | null>('get_current_user')
if (result && result.user && result.token) {
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, initialized: true })
// Auto-connect signaling after init
get().connectSignaling().catch(console.error)
} else {
localStorage.removeItem('auth_token')
set({ user: null, token: null, initialized: true })
}
} catch (e) {
console.error('Auth init failed:', e)
localStorage.removeItem('auth_token')
set({ user: null, token: null, initialized: true })
}
},
connectSignaling: async () => {
try {
await invoke('connect_signaling')
} catch (e) {
console.error('Signaling connection failed:', e)
}
},
login: async (username, password) => {
set({ loading: true, error: null })
try {
const result = await invoke<AuthResult>('login', { username, password })
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, loading: false })
// Connect signaling after login
get().connectSignaling().catch(console.error)
} catch (e: any) {
set({ error: String(e), loading: false })
throw e
}
},
register: async (username, email, password) => {
set({ loading: true, error: null })
try {
const result = await invoke<AuthResult>('register', { username, email, password })
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, loading: false })
// Connect signaling after register
get().connectSignaling().catch(console.error)
} catch (e: any) {
set({ error: String(e), loading: false })
throw e
}
},
logout: async () => {
try {
await invoke('disconnect_signaling')
await invoke('logout')
} finally {
localStorage.removeItem('auth_token')
set({ user: null, token: null })
}
},
}))

Some files were not shown because too many files have changed in this diff Show More