Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
36
.env.example
Normal 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
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,61 @@
|
|||||||
node_modules/
|
# Rust
|
||||||
dist/
|
/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
logs/
|
.env.local
|
||||||
*.log
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
.DS_Store
|
.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,101 +1,469 @@
|
|||||||
# FunConnect - Minecraft 联机平台
|
# FunMC - Minecraft 联机助手
|
||||||
|
|
||||||
一个支持多节点中继的 Minecraft 联机平台,让玩家无需公网IP即可轻松联机。
|
<div align="center">
|
||||||
|
|
||||||
本仓库包含 **三个独立项目**,覆盖全平台客户端和服务端。
|
**让 Minecraft 联机变得简单**
|
||||||
|
|
||||||
## 支持平台
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](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/` |
|
| POST | /api/v1/auth/register | 用户注册 |
|
||||||
| **macOS** | 桌面客户端 (DMG, x64/arm64) | `client/` |
|
| POST | /api/v1/auth/login | 用户登录 |
|
||||||
| **Linux** | 桌面客户端 (AppImage/deb) | `client/` |
|
| POST | /api/v1/auth/refresh | 刷新令牌 |
|
||||||
| **iOS** | 移动客户端 | `mobile/` |
|
| POST | /api/v1/auth/logout | 退出登录 |
|
||||||
| **Android** | 移动客户端 (APK/AAB) | `mobile/` |
|
|
||||||
| **Ubuntu** | 中继服务器 + Web 管理面板 | `server/` |
|
|
||||||
|
|
||||||
## 项目结构
|
### 房间
|
||||||
|
|
||||||
```
|
| 方法 | 路径 | 描述 |
|
||||||
FunConnect/
|
|------|------|------|
|
||||||
├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本)
|
| GET | /api/v1/rooms | 获取房间列表 |
|
||||||
├── client/ # 桌面客户端(Electron: Windows / macOS / Linux)
|
| POST | /api/v1/rooms | 创建房间 |
|
||||||
└── mobile/ # 移动客户端(React Native + Expo: iOS / Android)
|
| 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
|
| API 服务器 | https://funmc.com | 443 |
|
||||||
npm run dist:mac # 打包 macOS
|
| 中继节点 - 主线路 | funmc.com | 7900 (UDP/QUIC) |
|
||||||
npm run dist:linux # 打包 Linux
|
| 中继节点 - 备用线路 | funmc.com | 7901 (UDP/QUIC) |
|
||||||
```
|
|
||||||
|
|
||||||
📖 [client/README.md](client/README.md)
|
## 🔧 故障排除
|
||||||
|
|
||||||
## 移动客户端 (`mobile/`)
|
### 常见问题
|
||||||
|
|
||||||
React Native + Expo 移动客户端,支持 iOS / Android。
|
**Q: 无法连接到服务器**
|
||||||
|
- 检查网络连接
|
||||||
|
- 确认防火墙允许 FunMC 通过
|
||||||
|
- 尝试使用中继模式
|
||||||
|
|
||||||
- **房间管理** - 浏览/搜索/创建/加入联机房间
|
**Q: P2P 连接失败**
|
||||||
- **设置持久化** - 记住服务器地址和玩家名
|
- 双方都是对称型 NAT 时无法打洞,会自动使用中继
|
||||||
- **深色 UI** - Minecraft 风格暗色主题
|
- 检查路由器 UPnP 设置
|
||||||
|
|
||||||
```bash
|
**Q: Minecraft 无法连接到代理地址**
|
||||||
cd mobile && npm install && npm start # 开发(Expo)
|
- 确认 FunMC 显示"已连接"状态
|
||||||
eas build --platform android --profile preview # 构建 Android APK
|
- 检查代理地址是否正确复制
|
||||||
eas build --platform ios --profile production # 构建 iOS
|
- 尝试重新点击"连接"
|
||||||
```
|
|
||||||
|
|
||||||
📖 [mobile/README.md](mobile/README.md)
|
**Q: 延迟很高**
|
||||||
|
- 尝试选择更近的中继节点
|
||||||
|
- 检查是否成功建立 P2P 连接(显示"P2P 直连")
|
||||||
|
|
||||||
## 架构
|
### 日志位置
|
||||||
|
|
||||||
```
|
- Windows: `%APPDATA%\com.funmc.app\logs`
|
||||||
┌──────────────────┐
|
- macOS: `~/Library/Logs/com.funmc.app`
|
||||||
│ 桌面客户端 │ Windows / macOS / Linux
|
- Linux: `~/.local/share/com.funmc.app/logs`
|
||||||
│ Electron │ TCP 本地代理
|
|
||||||
│ client/ │─────────┐
|
|
||||||
└──────────────────┘ │
|
|
||||||
▼
|
|
||||||
┌──────────────────┐ ┌──────────────────────────┐
|
|
||||||
│ 移动客户端 │ │ 中继服务器 (Ubuntu) │
|
|
||||||
│ React Native │──►│ TCP 中继 + REST API │
|
|
||||||
│ mobile/ │ │ Web 管理面板 (React) │
|
|
||||||
└──────────────────┘ └──────────────────────────┘
|
|
||||||
▲
|
|
||||||
┌──────────────────┐ │
|
|
||||||
│ Minecraft │─────────┘
|
|
||||||
│ 游戏客户端 │ TCP 直连中继
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
admin-panel/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
4
admin-panel/public/favicon.svg
Normal 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
@@ -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
|
||||||
68
admin-panel/src/components/Layout.tsx
Normal 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
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
175
admin-panel/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
248
admin-panel/src/pages/Downloads.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
admin-panel/src/pages/Login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
admin-panel/src/pages/Logs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
admin-panel/src/pages/Rooms.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
admin-panel/src/pages/Settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
admin-panel/src/pages/Users.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
admin-panel/src/stores/adminStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
45
admin-panel/src/stores/authStore.ts
Normal 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',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
23
admin-panel/tailwind.config.js
Normal 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
@@ -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" }]
|
||||||
|
}
|
||||||
10
admin-panel/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
20
admin-panel/vite.config.ts
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
15
client/capabilities/default.json
Normal 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
@@ -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
|
After Width: | Height: | Size: 69 B |
BIN
client/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
client/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
client/icons/icon.icns
Normal file
|
After Width: | Height: | Size: 69 B |
BIN
client/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
42
client/icons/icon.svg
Normal 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 |
5
client/migrations/sqlite/20240101000001_auth_cache.sql
Normal 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
|
||||||
|
);
|
||||||
68
client/migrations/sqlite/20240101000001_local_data.sql
Normal 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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
48
client/src/commands/config.rs
Normal 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())
|
||||||
|
}
|
||||||
118
client/src/commands/friends.rs
Normal 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(())
|
||||||
|
}
|
||||||
7
client/src/commands/mod.rs
Normal 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;
|
||||||
323
client/src/commands/network.rs
Normal 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(())
|
||||||
|
}
|
||||||
111
client/src/commands/relay_nodes.rs
Normal 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(())
|
||||||
|
}
|
||||||
187
client/src/commands/rooms.rs
Normal 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(())
|
||||||
|
}
|
||||||
72
client/src/commands/signaling.rs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
// Tauri entry point
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
funmc_client_lib::run();
|
||||||
|
}
|
||||||
88
client/src/network/lan_discovery.rs
Normal 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])
|
||||||
|
}
|
||||||
144
client/src/network/minecraft_proxy.rs
Normal 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
|
||||||
|
}
|
||||||
9
client/src/network/mod.rs
Normal 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
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
99
client/src/network/quic.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
68
client/src/network/relay.rs
Normal 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)
|
||||||
|
}
|
||||||
118
client/src/network/relay_selector.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
42
client/src/network/session.rs
Normal 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
client/src/network/signaling.rs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
218
client/ui/src/components/AppLayout.tsx
Normal 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
|
||||||
110
client/ui/src/components/Avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
client/ui/src/components/ConnectionStatus.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
client/ui/src/components/CreateRoomModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
client/ui/src/components/EmptyState.tsx
Normal 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="当有人想添加你为好友时,请求会显示在这里"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
client/ui/src/components/ErrorBoundary.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
121
client/ui/src/components/FriendCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
client/ui/src/components/JoinRoomModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
client/ui/src/components/Loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
client/ui/src/components/LoadingSpinner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
client/ui/src/components/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
client/ui/src/components/NetworkStats.tsx
Normal 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>;
|
||||||
|
}
|
||||||
144
client/ui/src/components/RoomCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
client/ui/src/components/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
client/ui/src/components/index.ts
Normal 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';
|
||||||
6
client/ui/src/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"server_url": "",
|
||||||
|
"server_name": "",
|
||||||
|
"relay_url": "",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
1
client/ui/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useSignalingEvents, useNetworkStatus, useFriendEvents, useRoomEvents } from './useWebSocket';
|
||||||
147
client/ui/src/hooks/useWebSocket.ts
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
client/ui/src/lib/utils.ts
Normal 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
@@ -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>
|
||||||
|
)
|
||||||
232
client/ui/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
client/ui/src/pages/Friends.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
client/ui/src/pages/Login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
client/ui/src/pages/Register.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
430
client/ui/src/pages/Room.tsx
Normal 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]}`
|
||||||
|
}
|
||||||
130
client/ui/src/pages/ServerSetup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
299
client/ui/src/pages/Settings.tsx
Normal 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
@@ -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 /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
101
client/ui/src/stores/authStore.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||