feat: FunConnect v1.0.0 - Minecraft联机平台完整版
- server: Node.js TCP中继服务器,支持多节点集群 - web: React管理面板(仪表盘、房间管理、节点管理) - client: Electron桌面客户端(连接、创建/加入房间、本地代理) - deploy: Ubuntu一键部署脚本
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
.DS_Store
|
||||
206
README.md
Normal file
206
README.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# FunMC - Minecraft 联机平台
|
||||
|
||||
一个支持多节点中继的 Minecraft 联机平台,让玩家无需公网IP即可轻松联机。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **TCP 中继代理** - 自动转发 Minecraft 网络流量,支持 Java 版和基岩版
|
||||
- **多节点集群** - 支持添加多台 Ubuntu 中继服务器,自动负载均衡
|
||||
- **房间系统** - 创建/加入房间,支持密码保护
|
||||
- **Web 管理面板** - 现代化 UI,支持任意平台浏览器访问
|
||||
- **实时状态** - WebSocket 实时推送服务器状态
|
||||
- **一键部署** - 提供 Ubuntu 自动安装脚本和 systemd 服务
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Web 管理面板 │
|
||||
│ (React + TailwindCSS) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ HTTP/WebSocket
|
||||
┌──────────────────▼──────────────────────────┐
|
||||
│ 主节点 (Master) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │ REST API │ │ 房间管理 │ │ 节点管理 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ TCP 中继引擎 │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ 心跳 + API
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ 工作节点1 │ │ 工作节点2 │ │ 工作节点N │
|
||||
│ (Ubuntu) │ │ (Ubuntu) │ │ (Ubuntu) │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Node.js 18+
|
||||
- Ubuntu 20.04+ (中继服务器)
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 安装服务端依赖
|
||||
cd server
|
||||
npm install
|
||||
|
||||
# 2. 创建配置文件
|
||||
cp .env.example .env
|
||||
# 编辑 .env 设置 IS_MASTER=true
|
||||
|
||||
# 3. 启动服务端
|
||||
npm run dev
|
||||
|
||||
# 4. 安装 Web 客户端依赖
|
||||
cd ../web
|
||||
npm install
|
||||
|
||||
# 5. 启动 Web 客户端
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173 打开管理面板。
|
||||
|
||||
### Ubuntu 服务器部署
|
||||
|
||||
#### 部署主节点
|
||||
|
||||
```bash
|
||||
# 上传项目文件到服务器后
|
||||
sudo bash deploy/install.sh master relay-master
|
||||
```
|
||||
|
||||
#### 部署工作节点
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh worker relay-node-2
|
||||
# 按提示输入主节点 URL
|
||||
```
|
||||
|
||||
### 添加新节点
|
||||
|
||||
1. 在新的 Ubuntu 服务器上部署工作节点
|
||||
2. 在 Web 管理面板「节点管理」→「添加节点」中注册
|
||||
3. 节点会自动加入集群并接收心跳检查
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 房主(开服方)
|
||||
|
||||
1. 启动本地 Minecraft 服务器(或开启局域网模式)
|
||||
2. 在 Web 面板创建房间,记录房间号
|
||||
3. 运行 FunMC 客户端连接中继服务器,注册为房主
|
||||
4. 将房间号分享给好友
|
||||
|
||||
### 玩家(加入方)
|
||||
|
||||
1. 获取房间号
|
||||
2. 在 Minecraft 中添加服务器,地址填写 `中继服务器IP:25565`
|
||||
3. 使用 FunMC 客户端连接中继服务器,输入房间号加入
|
||||
|
||||
## 桌面客户端
|
||||
|
||||
FunConnect 桌面客户端基于 Electron 构建,提供图形化界面连接中继服务器。
|
||||
|
||||
### 功能
|
||||
- **连接服务器** - 输入中继服务器地址一键连接
|
||||
- **浏览房间** - 查看所有在线联机房间
|
||||
- **创建房间** - 将本地MC服务器共享给好友
|
||||
- **加入房间** - 输入房间号,自动建立本地代理,MC中添加 `127.0.0.1:25566` 即可联机
|
||||
- **自定义窗口** - 无边框设计,现代化MC风格UI
|
||||
|
||||
### 运行客户端
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 打包客户端
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm run dist
|
||||
# 输出到 client/release/ 目录
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
FunConnect/
|
||||
├── server/ # 中继服务端
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # 主入口
|
||||
│ │ ├── config.ts # 配置管理
|
||||
│ │ ├── logger.ts # 日志系统
|
||||
│ │ ├── relay.ts # TCP 中继引擎
|
||||
│ │ ├── room.ts # 房间管理
|
||||
│ │ ├── node-manager.ts # 多节点管理
|
||||
│ │ ├── api.ts # REST API 路由
|
||||
│ │ └── websocket.ts # WebSocket 实时通信
|
||||
│ ├── .env.example # 配置示例
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
├── web/ # Web 管理面板
|
||||
│ ├── src/
|
||||
│ │ ├── main.tsx # 入口
|
||||
│ │ ├── App.tsx # 路由和布局
|
||||
│ │ ├── api.ts # API 客户端
|
||||
│ │ ├── index.css # TailwindCSS 样式
|
||||
│ │ └── pages/
|
||||
│ │ ├── Dashboard.tsx # 仪表盘
|
||||
│ │ ├── Rooms.tsx # 房间列表
|
||||
│ │ ├── CreateRoom.tsx # 创建房间
|
||||
│ │ ├── Nodes.tsx # 节点管理
|
||||
│ │ └── AddNode.tsx # 添加节点
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── client/ # Electron 桌面客户端
|
||||
│ ├── src/main/
|
||||
│ │ ├── index.ts # Electron 主进程
|
||||
│ │ ├── preload.ts # 预加载脚本
|
||||
│ │ ├── api-client.ts # API 客户端
|
||||
│ │ ├── relay-client.ts # TCP 中继连接
|
||||
│ │ └── local-proxy.ts # 本地代理服务器
|
||||
│ ├── renderer/
|
||||
│ │ ├── index.html # 客户端界面
|
||||
│ │ ├── style.css # 样式
|
||||
│ │ └── app.js # 交互逻辑
|
||||
│ └── package.json
|
||||
├── deploy/
|
||||
│ └── install.sh # Ubuntu 一键部署脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 健康检查 |
|
||||
| GET | `/api/stats` | 服务器统计 |
|
||||
| GET | `/api/rooms` | 房间列表 |
|
||||
| POST | `/api/rooms` | 创建房间 |
|
||||
| DELETE | `/api/rooms/:id` | 删除房间 |
|
||||
| GET | `/api/nodes` | 节点列表(主节点) |
|
||||
| POST | `/api/nodes/register` | 注册节点(主节点) |
|
||||
| DELETE | `/api/nodes/:id` | 移除节点(主节点) |
|
||||
| GET | `/api/nodes/best` | 获取最佳节点(主节点) |
|
||||
| GET | `/api/cluster/rooms` | 集群房间列表(主节点) |
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **服务端**: Node.js, TypeScript, Express, WebSocket
|
||||
- **Web 面板**: React 18, Vite, TailwindCSS, Lucide Icons
|
||||
- **部署**: Ubuntu, systemd, Bash
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
4478
client/package-lock.json
generated
Normal file
4478
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
client/package.json
Normal file
45
client/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "funconnect",
|
||||
"version": "1.0.0",
|
||||
"description": "FunMC Minecraft 联机客户端",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "FunMC",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build:ts": "tsc",
|
||||
"start": "npm run build:ts && electron .",
|
||||
"dev": "tsc && electron .",
|
||||
"pack": "npm run build:ts && electron-builder --dir",
|
||||
"dist": "npm run build:ts && electron-builder"
|
||||
},
|
||||
"build": {
|
||||
"appId": "cn.funmc.connect",
|
||||
"productName": "FunConnect",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"renderer/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "renderer/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"electron-store": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"electron": "^28.1.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
278
client/renderer/app.js
Normal file
278
client/renderer/app.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// FunConnect Client - Renderer Logic
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
let isConnected = false;
|
||||
let currentServerUrl = '';
|
||||
|
||||
// ===== Window Controls =====
|
||||
$('#btn-min').addEventListener('click', () => window.funmc.minimize());
|
||||
$('#btn-max').addEventListener('click', () => window.funmc.maximize());
|
||||
$('#btn-close').addEventListener('click', () => window.funmc.close());
|
||||
|
||||
// ===== Navigation =====
|
||||
$$('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const page = item.dataset.page;
|
||||
$$('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
$$('.page').forEach(p => p.classList.remove('active'));
|
||||
$(`#page-${page}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Connect Page =====
|
||||
$('#btn-connect').addEventListener('click', async () => {
|
||||
const url = $('#server-url').value.trim();
|
||||
if (!url) {
|
||||
showMsg('#connect-msg', '请输入服务器地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-connect');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 连接中...';
|
||||
showMsg('#connect-msg', '', '');
|
||||
|
||||
const result = await window.funmc.connectServer(url);
|
||||
|
||||
if (result.success) {
|
||||
isConnected = true;
|
||||
currentServerUrl = url;
|
||||
updateServerStatus(true);
|
||||
showMsg('#connect-msg', '连接成功!', 'success');
|
||||
showServerInfo(result.data);
|
||||
} else {
|
||||
isConnected = false;
|
||||
updateServerStatus(false);
|
||||
showMsg('#connect-msg', '连接失败: ' + result.error, 'error');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '连接服务器';
|
||||
});
|
||||
|
||||
function showServerInfo(data) {
|
||||
const grid = $('#server-info-grid');
|
||||
grid.innerHTML = '';
|
||||
const items = [
|
||||
{ label: '节点名称', value: data.nodeName || 'N/A' },
|
||||
{ label: '节点ID', value: data.nodeId || 'N/A' },
|
||||
{ label: '运行模式', value: data.isMaster ? '主节点' : '工作节点' },
|
||||
{ label: '运行状态', value: data.status === 'ok' ? '正常' : '异常' },
|
||||
{ label: '运行时间', value: formatUptime(data.uptime || 0) },
|
||||
{ label: '版本', value: 'v1.0.0' },
|
||||
];
|
||||
items.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'info-item';
|
||||
div.innerHTML = `<div class="label">${item.label}</div><div class="value">${item.value}</div>`;
|
||||
grid.appendChild(div);
|
||||
});
|
||||
$('#server-info').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function updateServerStatus(online) {
|
||||
const dot = $('#server-status .status-dot');
|
||||
const text = $('#server-status .status-text');
|
||||
if (online) {
|
||||
dot.className = 'status-dot online';
|
||||
text.textContent = '已连接';
|
||||
} else {
|
||||
dot.className = 'status-dot offline';
|
||||
text.textContent = '未连接';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rooms Page =====
|
||||
$('#btn-refresh-rooms').addEventListener('click', loadRooms);
|
||||
|
||||
async function loadRooms() {
|
||||
if (!isConnected) {
|
||||
$('#rooms-list').innerHTML = '<div class="empty-state"><span class="empty-icon">🔗</span><p>请先连接服务器</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#rooms-list').innerHTML = '<div class="empty-state"><span class="spinner"></span><p>加载中...</p></div>';
|
||||
|
||||
const result = await window.funmc.listRooms();
|
||||
if (!result.success) {
|
||||
$('#rooms-list').innerHTML = `<div class="empty-state"><span class="empty-icon">❌</span><p>${result.error}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rooms = result.data.rooms || [];
|
||||
if (rooms.length === 0) {
|
||||
$('#rooms-list').innerHTML = '<div class="empty-state"><span class="empty-icon">🎮</span><p>暂无在线房间</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#rooms-list').innerHTML = rooms.map(room => `
|
||||
<div class="room-card" data-id="${room.id}">
|
||||
<div class="room-left">
|
||||
<div class="room-icon">🎮</div>
|
||||
<div>
|
||||
<div class="room-name">${escapeHtml(room.name)} ${room.password ? '🔒' : ''}</div>
|
||||
<div class="room-meta">房间号: ${room.id} | 房主: ${escapeHtml(room.hostName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="room-right">
|
||||
<span class="room-players">👥 ${room.currentPlayers}/${room.maxPlayers}</span>
|
||||
<span class="room-version">${room.gameEdition === 'java' ? 'Java' : '基岩'} ${room.gameVersion}</span>
|
||||
<button class="room-btn-join" onclick="quickJoin('${room.id}')">加入</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function quickJoin(roomId) {
|
||||
// Navigate to join page and fill in room ID
|
||||
$$('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelector('[data-page="join"]').classList.add('active');
|
||||
$$('.page').forEach(p => p.classList.remove('active'));
|
||||
$('#page-join').classList.add('active');
|
||||
$('#join-room-id').value = roomId;
|
||||
}
|
||||
|
||||
// ===== Host Page =====
|
||||
$('#btn-host').addEventListener('click', async () => {
|
||||
if (!isConnected) {
|
||||
showMsg('#host-msg', '请先连接服务器', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = $('#host-name').value.trim();
|
||||
const port = parseInt($('#host-port').value);
|
||||
const version = $('#host-version').value.trim();
|
||||
const edition = $('#host-edition').value;
|
||||
const maxPlayers = parseInt($('#host-maxplayers').value);
|
||||
const password = $('#host-password').value;
|
||||
|
||||
if (!name) {
|
||||
showMsg('#host-msg', '请输入房间名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-host');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 创建中...';
|
||||
showMsg('#host-msg', '', '');
|
||||
|
||||
const result = await window.funmc.hostRoom({
|
||||
serverUrl: currentServerUrl,
|
||||
roomName: name,
|
||||
localMcPort: port,
|
||||
gameVersion: version,
|
||||
gameEdition: edition,
|
||||
maxPlayers: maxPlayers,
|
||||
password: password || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showMsg('#host-msg', '', '');
|
||||
$('#host-room-id').textContent = result.data.room.id;
|
||||
$('#host-result').classList.remove('hidden');
|
||||
} else {
|
||||
showMsg('#host-msg', '创建失败: ' + result.error, 'error');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '创建房间';
|
||||
});
|
||||
|
||||
$('#btn-copy-room').addEventListener('click', () => {
|
||||
const roomId = $('#host-room-id').textContent;
|
||||
navigator.clipboard.writeText(roomId).then(() => {
|
||||
$('#btn-copy-room').textContent = '已复制!';
|
||||
setTimeout(() => { $('#btn-copy-room').textContent = '复制房间号'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Join Page =====
|
||||
$('#btn-join').addEventListener('click', async () => {
|
||||
const roomId = $('#join-room-id').value.trim();
|
||||
const host = $('#join-host').value.trim();
|
||||
const port = parseInt($('#join-port').value);
|
||||
const localPort = parseInt($('#join-local-port').value);
|
||||
|
||||
if (!roomId) {
|
||||
showMsg('#join-msg', '请输入房间号', 'error');
|
||||
return;
|
||||
}
|
||||
if (!host) {
|
||||
showMsg('#join-msg', '请输入中继服务器地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('#btn-join');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 连接中...';
|
||||
showMsg('#join-msg', '', '');
|
||||
|
||||
const result = await window.funmc.joinRoom({
|
||||
serverHost: host,
|
||||
serverPort: port,
|
||||
roomId: roomId,
|
||||
localPort: localPort,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showMsg('#join-msg', '', '');
|
||||
$('#join-active-port').textContent = String(localPort);
|
||||
$('#join-status').classList.remove('hidden');
|
||||
$('#btn-join').classList.add('hidden');
|
||||
$('#btn-leave').classList.remove('hidden');
|
||||
} else {
|
||||
showMsg('#join-msg', '加入失败: ' + result.error, 'error');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '加入房间';
|
||||
});
|
||||
|
||||
$('#btn-leave').addEventListener('click', async () => {
|
||||
await window.funmc.disconnect();
|
||||
$('#join-status').classList.add('hidden');
|
||||
$('#btn-join').classList.remove('hidden');
|
||||
$('#btn-leave').classList.add('hidden');
|
||||
showMsg('#join-msg', '已断开连接', 'success');
|
||||
});
|
||||
|
||||
// ===== Relay Status Events =====
|
||||
window.funmc.onRelayStatus((data) => {
|
||||
if (data.status === 'disconnected') {
|
||||
$('#join-status').classList.add('hidden');
|
||||
$('#btn-join').classList.remove('hidden');
|
||||
$('#btn-leave').classList.add('hidden');
|
||||
showMsg('#join-msg', '连接已断开', 'error');
|
||||
} else if (data.status === 'error') {
|
||||
showMsg('#join-msg', '连接错误: ' + data.error, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Auto-refresh rooms when navigating =====
|
||||
document.querySelector('[data-page="rooms"]').addEventListener('click', () => {
|
||||
if (isConnected) loadRooms();
|
||||
});
|
||||
|
||||
// ===== Utilities =====
|
||||
function showMsg(selector, text, type) {
|
||||
const el = $(selector);
|
||||
el.textContent = text;
|
||||
el.className = 'msg' + (type ? ' ' + type : '');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}时${m}分${s}秒`;
|
||||
if (m > 0) return `${m}分${s}秒`;
|
||||
return `${s}秒`;
|
||||
}
|
||||
234
client/renderer/index.html
Normal file
234
client/renderer/index.html
Normal file
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'">
|
||||
<title>FunConnect</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Title Bar -->
|
||||
<div class="titlebar" id="titlebar">
|
||||
<div class="titlebar-drag">
|
||||
<span class="titlebar-icon">⛏</span>
|
||||
<span class="titlebar-title">FunConnect</span>
|
||||
</div>
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" id="btn-min">─</button>
|
||||
<button class="tb-btn" id="btn-max">□</button>
|
||||
<button class="tb-btn tb-close" id="btn-close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">F</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-name">FunConnect</span>
|
||||
<span class="logo-sub">MC联机客户端</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-status" id="server-status">
|
||||
<span class="status-dot offline"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-item active" data-page="connect">
|
||||
<span class="nav-icon">🔗</span> 连接服务器
|
||||
</li>
|
||||
<li class="nav-item" data-page="rooms">
|
||||
<span class="nav-icon">🎮</span> 房间列表
|
||||
</li>
|
||||
<li class="nav-item" data-page="host">
|
||||
<span class="nav-icon">🏠</span> 创建房间
|
||||
</li>
|
||||
<li class="nav-item" data-page="join">
|
||||
<span class="nav-icon">🚀</span> 加入房间
|
||||
</li>
|
||||
<li class="nav-item" data-page="about">
|
||||
<span class="nav-icon">ℹ️</span> 关于
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">v1.0.0</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="content">
|
||||
<!-- Connect Page -->
|
||||
<div class="page active" id="page-connect">
|
||||
<h2 class="page-title">连接服务器</h2>
|
||||
<p class="page-desc">输入FunMC中继服务器地址来开始联机</p>
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input type="text" id="server-url" class="input" placeholder="http://your-server:3000" value="http://localhost:3000">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-connect">连接服务器</button>
|
||||
<div class="msg" id="connect-msg"></div>
|
||||
<div class="server-info hidden" id="server-info">
|
||||
<h3>服务器信息</h3>
|
||||
<div class="info-grid" id="server-info-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rooms Page -->
|
||||
<div class="page" id="page-rooms">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">房间列表</h2>
|
||||
<p class="page-desc">当前在线的联机房间</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="btn-refresh-rooms">刷新</button>
|
||||
</div>
|
||||
<div id="rooms-list" class="rooms-grid">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">🎮</span>
|
||||
<p>请先连接服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Host Page -->
|
||||
<div class="page" id="page-host">
|
||||
<h2 class="page-title">创建房间</h2>
|
||||
<p class="page-desc">将你的本地Minecraft服务器共享给好友</p>
|
||||
<div class="card">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>房间名称</label>
|
||||
<input type="text" id="host-name" class="input" placeholder="我的联机房间">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>本地MC端口</label>
|
||||
<input type="number" id="host-port" class="input" value="25565">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>游戏版本</label>
|
||||
<input type="text" id="host-version" class="input" value="1.20.4">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>版本类型</label>
|
||||
<select id="host-edition" class="input">
|
||||
<option value="java">Java版</option>
|
||||
<option value="bedrock">基岩版</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>最大人数</label>
|
||||
<input type="number" id="host-maxplayers" class="input" value="10" min="2" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>房间密码(可选)</label>
|
||||
<input type="password" id="host-password" class="input" placeholder="留空则不设密码">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-host">创建房间</button>
|
||||
<div class="msg" id="host-msg"></div>
|
||||
<div class="room-result hidden" id="host-result">
|
||||
<div class="result-title">房间创建成功!</div>
|
||||
<div class="result-room-id" id="host-room-id"></div>
|
||||
<p class="result-hint">将此房间号发给好友,他们可以通过「加入房间」连接</p>
|
||||
<button class="btn btn-secondary" id="btn-copy-room">复制房间号</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Join Page -->
|
||||
<div class="page" id="page-join">
|
||||
<h2 class="page-title">加入房间</h2>
|
||||
<p class="page-desc">输入房间号加入好友的联机房间</p>
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label>房间号</label>
|
||||
<input type="text" id="join-room-id" class="input" placeholder="输入房间号">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>中继服务器地址</label>
|
||||
<input type="text" id="join-host" class="input" placeholder="relay.funmc.cn" value="localhost">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>中继端口</label>
|
||||
<input type="number" id="join-port" class="input" value="25565">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>本地监听端口</label>
|
||||
<input type="number" id="join-local-port" class="input" value="25566">
|
||||
<span class="form-hint">在Minecraft中添加服务器地址 127.0.0.1:25566 即可连接</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-join">加入房间</button>
|
||||
<button class="btn btn-danger hidden" id="btn-leave">断开连接</button>
|
||||
<div class="msg" id="join-msg"></div>
|
||||
<div class="join-status hidden" id="join-status">
|
||||
<div class="status-card connected">
|
||||
<span class="status-emoji">✅</span>
|
||||
<div>
|
||||
<div class="status-title">已连接</div>
|
||||
<div class="status-detail">在Minecraft中添加服务器: <strong>127.0.0.1:<span id="join-active-port">25566</span></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Page -->
|
||||
<div class="page" id="page-about">
|
||||
<h2 class="page-title">关于 FunConnect</h2>
|
||||
<div class="card">
|
||||
<div class="about-header">
|
||||
<div class="about-logo">F</div>
|
||||
<div>
|
||||
<h3>FunConnect v1.0.0</h3>
|
||||
<p>Minecraft 联机客户端</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-section">
|
||||
<h4>使用说明</h4>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<span class="step-num">1</span>
|
||||
<div>
|
||||
<strong>连接服务器</strong>
|
||||
<p>输入FunMC中继服务器地址并连接</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-num">2</span>
|
||||
<div>
|
||||
<strong>创建或加入房间</strong>
|
||||
<p>房主创建房间后分享房间号,玩家输入房间号加入</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-num">3</span>
|
||||
<div>
|
||||
<strong>开始游戏</strong>
|
||||
<p>在Minecraft中添加服务器地址 127.0.0.1:端口 即可联机</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-section">
|
||||
<h4>技术信息</h4>
|
||||
<p>基于 Electron + Node.js TCP 中继技术</p>
|
||||
<p>支持 Java版 和 基岩版 Minecraft</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
436
client/renderer/style.css
Normal file
436
client/renderer/style.css
Normal file
@@ -0,0 +1,436 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #1a1a2e;
|
||||
--bg-darker: #16213e;
|
||||
--bg-accent: #0f3460;
|
||||
--green: #4CAF50;
|
||||
--green-hover: #45a049;
|
||||
--red: #e94560;
|
||||
--gold: #FFD700;
|
||||
--blue: #4fc3f7;
|
||||
--purple: #b388ff;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #8a8a9a;
|
||||
--border: rgba(15, 52, 96, 0.4);
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Titlebar */
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
background: #111128;
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.titlebar-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.titlebar-icon { font-size: 14px; }
|
||||
.titlebar-title { font-size: 12px; color: var(--text-dim); }
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.tb-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tb-btn:hover { background: rgba(255,255,255,0.1); }
|
||||
.tb-close:hover { background: var(--red); color: #fff; }
|
||||
|
||||
/* App Layout */
|
||||
.app {
|
||||
display: flex;
|
||||
height: calc(100vh - 36px);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-darker);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--green), #2e7d32);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.logo-name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--green);
|
||||
display: block;
|
||||
}
|
||||
.logo-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sidebar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.status-dot.offline { background: var(--red); }
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 12px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(15, 52, 96, 0.4);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
.nav-icon { font-size: 16px; }
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.content::-webkit-scrollbar { width: 6px; }
|
||||
.content::-webkit-scrollbar-track { background: transparent; }
|
||||
.content::-webkit-scrollbar-thumb { background: var(--bg-accent); border-radius: 3px; }
|
||||
|
||||
/* Pages */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; animation: fadeIn 0.2s ease; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.page-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.form-hint { font-size: 11px; color: var(--text-dim); margin-top: 4px; display: block; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input:focus { border-color: var(--green); }
|
||||
.input::placeholder { color: #555; }
|
||||
select.input { cursor: pointer; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn-primary { background: var(--green); color: #fff; }
|
||||
.btn-primary:hover { background: var(--green-hover); }
|
||||
.btn-secondary { background: var(--bg-accent); color: var(--text); }
|
||||
.btn-secondary:hover { background: #1a4a7a; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-danger:hover { background: #d63050; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Messages */
|
||||
.msg {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.msg.error { color: var(--red); }
|
||||
.msg.success { color: var(--green); }
|
||||
|
||||
/* Server Info */
|
||||
.server-info { margin-top: 20px; }
|
||||
.server-info h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--green);
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.info-item {
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.info-item .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-item .value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Rooms Grid */
|
||||
.rooms-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.room-card {
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.room-card:hover { border-color: rgba(76, 175, 80, 0.4); }
|
||||
.room-left { display: flex; align-items: center; gap: 14px; }
|
||||
.room-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.room-name { font-weight: 600; font-size: 14px; }
|
||||
.room-meta { font-size: 11px; color: var(--text-dim); margin-top: 3px; }
|
||||
.room-right { display: flex; align-items: center; gap: 10px; }
|
||||
.room-players {
|
||||
font-size: 12px;
|
||||
color: var(--blue);
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.room-version {
|
||||
font-size: 12px;
|
||||
color: var(--purple);
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.room-btn-join {
|
||||
padding: 6px 14px;
|
||||
background: var(--green);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.room-btn-join:hover { background: var(--green-hover); }
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty-icon { font-size: 48px; display: block; margin-bottom: 12px; }
|
||||
|
||||
/* Room Result */
|
||||
.room-result {
|
||||
margin-top: 20px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.result-title { font-size: 18px; font-weight: 700; color: var(--green); margin-bottom: 12px; }
|
||||
.result-room-id {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 22px;
|
||||
color: var(--gold);
|
||||
letter-spacing: 2px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.result-hint { font-size: 12px; color: var(--text-dim); margin-bottom: 16px; }
|
||||
|
||||
/* Join Status */
|
||||
.join-status { margin-top: 16px; }
|
||||
.status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
.status-emoji { font-size: 28px; }
|
||||
.status-title { font-weight: 600; font-size: 14px; color: var(--green); }
|
||||
.status-detail { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||||
.status-detail strong { color: var(--gold); }
|
||||
|
||||
/* About */
|
||||
.about-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.about-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--green), #2e7d32);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
.about-header h3 { font-size: 18px; }
|
||||
.about-header p { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
|
||||
.about-section { margin-top: 20px; }
|
||||
.about-section h4 { font-size: 14px; margin-bottom: 12px; color: var(--green); }
|
||||
.about-section p { font-size: 13px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
|
||||
.steps { display: flex; flex-direction: column; gap: 12px; }
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.step-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step strong { font-size: 13px; display: block; margin-bottom: 3px; }
|
||||
.step p { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
/* Utility */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
50
client/src/main/api-client.ts
Normal file
50
client/src/main/api-client.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export class ApiClient {
|
||||
private http: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.http = axios.create({
|
||||
baseURL: baseUrl.replace(/\/$/, '') + '/api',
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async getHealth() {
|
||||
const res = await this.http.get('/health');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const res = await this.http.get('/stats');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getRooms() {
|
||||
const res = await this.http.get('/rooms');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async createRoom(data: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: string;
|
||||
maxPlayers: number;
|
||||
password?: string;
|
||||
}) {
|
||||
const res = await this.http.post('/rooms', data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string) {
|
||||
const res = await this.http.delete(`/rooms/${roomId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getRoom(roomId: string) {
|
||||
const res = await this.http.get(`/rooms/${roomId}`);
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
173
client/src/main/index.ts
Normal file
173
client/src/main/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { RelayClient } from './relay-client';
|
||||
import { LocalProxy } from './local-proxy';
|
||||
import { ApiClient } from './api-client';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let relayClient: RelayClient | null = null;
|
||||
let localProxy: LocalProxy | null = null;
|
||||
let apiClient: ApiClient | null = null;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 650,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: 'FunConnect - Minecraft 联机客户端',
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
backgroundColor: '#1a1a2e',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'));
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (localProxy) {
|
||||
localProxy.stop();
|
||||
localProxy = null;
|
||||
}
|
||||
if (relayClient) {
|
||||
relayClient.disconnect();
|
||||
relayClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
cleanup();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// ===== IPC Handlers =====
|
||||
|
||||
// Window controls
|
||||
ipcMain.handle('window:minimize', () => mainWindow?.minimize());
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
|
||||
else mainWindow?.maximize();
|
||||
});
|
||||
ipcMain.handle('window:close', () => mainWindow?.close());
|
||||
|
||||
// Server connection
|
||||
ipcMain.handle('server:connect', async (_event, serverUrl: string) => {
|
||||
try {
|
||||
apiClient = new ApiClient(serverUrl);
|
||||
const health = await apiClient.getHealth();
|
||||
return { success: true, data: health };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Room operations
|
||||
ipcMain.handle('rooms:list', async () => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const data = await apiClient.getRooms();
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('rooms:create', async (_event, roomData: any) => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const data = await apiClient.createRoom(roomData);
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.response?.data?.error || err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('rooms:delete', async (_event, roomId: string) => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
await apiClient.deleteRoom(roomId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Join room - start local proxy and relay connection
|
||||
ipcMain.handle('rooms:join', async (_event, opts: { serverHost: string; serverPort: number; roomId: string; localPort: number }) => {
|
||||
try {
|
||||
cleanup();
|
||||
|
||||
relayClient = new RelayClient(opts.serverHost, opts.serverPort, opts.roomId);
|
||||
localProxy = new LocalProxy(opts.localPort, relayClient);
|
||||
|
||||
await localProxy.start();
|
||||
|
||||
relayClient.on('connected', () => {
|
||||
mainWindow?.webContents.send('relay:status', { status: 'connected' });
|
||||
});
|
||||
relayClient.on('disconnected', () => {
|
||||
mainWindow?.webContents.send('relay:status', { status: 'disconnected' });
|
||||
});
|
||||
relayClient.on('error', (err: string) => {
|
||||
mainWindow?.webContents.send('relay:status', { status: 'error', error: err });
|
||||
});
|
||||
|
||||
return { success: true, localPort: opts.localPort };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Host room - start relay and local proxy from local MC server
|
||||
ipcMain.handle('rooms:host', async (_event, opts: { serverUrl: string; roomName: string; localMcPort: number; gameVersion: string; gameEdition: string; maxPlayers: number; password?: string }) => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const result = await apiClient.createRoom({
|
||||
name: opts.roomName,
|
||||
hostName: 'FunConnect',
|
||||
hostPort: opts.localMcPort,
|
||||
gameVersion: opts.gameVersion,
|
||||
gameEdition: opts.gameEdition,
|
||||
maxPlayers: opts.maxPlayers,
|
||||
password: opts.password,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.response?.data?.error || err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
ipcMain.handle('relay:disconnect', () => {
|
||||
cleanup();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Stats
|
||||
ipcMain.handle('server:stats', async () => {
|
||||
if (!apiClient) return { success: false, error: '未连接服务器' };
|
||||
try {
|
||||
const data = await apiClient.getStats();
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
77
client/src/main/local-proxy.ts
Normal file
77
client/src/main/local-proxy.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as net from 'net';
|
||||
import { RelayClient } from './relay-client';
|
||||
|
||||
export class LocalProxy {
|
||||
private server: net.Server | null = null;
|
||||
private connections: Set<net.Socket> = new Set();
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private relayClient: RelayClient
|
||||
) {}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = net.createServer((clientSocket) => {
|
||||
this.connections.add(clientSocket);
|
||||
|
||||
// Connect to relay for this MC client
|
||||
const relaySocket = this.relayClient.getSocket();
|
||||
if (!relaySocket || !this.relayClient.isConnected()) {
|
||||
clientSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward MC client data to relay
|
||||
clientSocket.on('data', (data) => {
|
||||
if (this.relayClient.isConnected()) {
|
||||
this.relayClient.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward relay data back to MC client
|
||||
this.relayClient.onData((data) => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on('close', () => {
|
||||
this.connections.delete(clientSocket);
|
||||
});
|
||||
|
||||
clientSocket.on('error', () => {
|
||||
this.connections.delete(clientSocket);
|
||||
});
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
console.log(`[LocalProxy] Listening on 127.0.0.1:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const conn of this.connections) {
|
||||
conn.destroy();
|
||||
}
|
||||
this.connections.clear();
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
getConnectionCount(): number {
|
||||
return this.connections.size;
|
||||
}
|
||||
}
|
||||
25
client/src/main/preload.ts
Normal file
25
client/src/main/preload.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('funmc', {
|
||||
// Window controls
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close'),
|
||||
|
||||
// Server
|
||||
connectServer: (url: string) => ipcRenderer.invoke('server:connect', url),
|
||||
getStats: () => ipcRenderer.invoke('server:stats'),
|
||||
|
||||
// Rooms
|
||||
listRooms: () => ipcRenderer.invoke('rooms:list'),
|
||||
createRoom: (data: any) => ipcRenderer.invoke('rooms:create', data),
|
||||
deleteRoom: (id: string) => ipcRenderer.invoke('rooms:delete', id),
|
||||
joinRoom: (opts: any) => ipcRenderer.invoke('rooms:join', opts),
|
||||
hostRoom: (opts: any) => ipcRenderer.invoke('rooms:host', opts),
|
||||
disconnect: () => ipcRenderer.invoke('relay:disconnect'),
|
||||
|
||||
// Events from main
|
||||
onRelayStatus: (callback: (data: any) => void) => {
|
||||
ipcRenderer.on('relay:status', (_event, data) => callback(data));
|
||||
},
|
||||
});
|
||||
76
client/src/main/relay-client.ts
Normal file
76
client/src/main/relay-client.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as net from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class RelayClient extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private host: string,
|
||||
private port: number,
|
||||
private roomId: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = new net.Socket();
|
||||
|
||||
this.socket.connect(this.port, this.host, () => {
|
||||
this.connected = true;
|
||||
// Send room ID as initial handshake
|
||||
const header = Buffer.alloc(2 + this.roomId.length);
|
||||
header.writeUInt16BE(this.roomId.length, 0);
|
||||
header.write(this.roomId, 2);
|
||||
this.socket!.write(header);
|
||||
this.emit('connected');
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.emit('error', err.message);
|
||||
if (!this.connected) reject(err);
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this.socket.setTimeout(10000, () => {
|
||||
this.socket?.destroy(new Error('Connection timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
write(data: Buffer): boolean {
|
||||
if (!this.socket || !this.connected) return false;
|
||||
return this.socket.write(data);
|
||||
}
|
||||
|
||||
onData(callback: (data: Buffer) => void): void {
|
||||
this.socket?.on('data', callback);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getSocket(): net.Socket | null {
|
||||
return this.socket;
|
||||
}
|
||||
}
|
||||
19
client/tsconfig.json
Normal file
19
client/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "renderer"]
|
||||
}
|
||||
118
deploy/install.sh
Normal file
118
deploy/install.sh
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# FunMC Relay Server - Ubuntu Deployment Script
|
||||
# Usage: bash install.sh [master|worker]
|
||||
|
||||
set -e
|
||||
|
||||
MODE=${1:-master}
|
||||
INSTALL_DIR="/opt/funmc"
|
||||
NODE_NAME=${2:-"relay-node-1"}
|
||||
|
||||
echo "=========================================="
|
||||
echo " FunMC Relay Server Installer"
|
||||
echo " Mode: $MODE"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (sudo bash install.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install Node.js if not present
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "[1/6] Installing Node.js 20.x..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
else
|
||||
echo "[1/6] Node.js already installed: $(node -v)"
|
||||
fi
|
||||
|
||||
# Create installation directory
|
||||
echo "[2/6] Creating installation directory..."
|
||||
mkdir -p $INSTALL_DIR
|
||||
mkdir -p $INSTALL_DIR/logs
|
||||
|
||||
# Copy server files
|
||||
echo "[3/6] Copying server files..."
|
||||
cp -r server/* $INSTALL_DIR/
|
||||
|
||||
# Install dependencies
|
||||
echo "[4/6] Installing dependencies..."
|
||||
cd $INSTALL_DIR
|
||||
npm install --production
|
||||
npm run build
|
||||
|
||||
# Create .env file
|
||||
echo "[5/6] Creating configuration..."
|
||||
if [ "$MODE" = "master" ]; then
|
||||
cat > $INSTALL_DIR/.env << EOF
|
||||
RELAY_PORT=25565
|
||||
API_PORT=3000
|
||||
NODE_NAME=$NODE_NAME
|
||||
IS_MASTER=true
|
||||
SECRET=$(openssl rand -hex 16)
|
||||
MAX_ROOMS=100
|
||||
MAX_PLAYERS_PER_ROOM=20
|
||||
HEARTBEAT_INTERVAL=10000
|
||||
LOG_LEVEL=info
|
||||
PUBLIC_HOST=0.0.0.0
|
||||
EOF
|
||||
else
|
||||
read -p "Enter master node URL (e.g. http://master-ip:3000): " MASTER_URL
|
||||
cat > $INSTALL_DIR/.env << EOF
|
||||
RELAY_PORT=25565
|
||||
API_PORT=3000
|
||||
NODE_NAME=$NODE_NAME
|
||||
IS_MASTER=false
|
||||
MASTER_URL=$MASTER_URL
|
||||
SECRET=your-secret-key
|
||||
MAX_ROOMS=100
|
||||
MAX_PLAYERS_PER_ROOM=20
|
||||
HEARTBEAT_INTERVAL=10000
|
||||
LOG_LEVEL=info
|
||||
PUBLIC_HOST=$(hostname -I | awk '{print $1}')
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create systemd service
|
||||
echo "[6/6] Creating systemd service..."
|
||||
cat > /etc/systemd/system/funmc.service << EOF
|
||||
[Unit]
|
||||
Description=FunMC Minecraft Relay Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=/usr/bin/node dist/index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable funmc
|
||||
systemctl start funmc
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " FunMC Relay Server Installed!"
|
||||
echo "=========================================="
|
||||
echo " Mode: $MODE"
|
||||
echo " Install Dir: $INSTALL_DIR"
|
||||
echo " Relay Port: 25565"
|
||||
echo " API Port: 3000"
|
||||
echo ""
|
||||
echo " Commands:"
|
||||
echo " systemctl status funmc - Check status"
|
||||
echo " systemctl restart funmc - Restart"
|
||||
echo " systemctl stop funmc - Stop"
|
||||
echo " journalctl -u funmc -f - View logs"
|
||||
echo ""
|
||||
echo " Web UI: http://$(hostname -I | awk '{print $1}'):3000"
|
||||
echo "=========================================="
|
||||
31
server/.env.example
Normal file
31
server/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# FunMC Relay Server Configuration
|
||||
|
||||
# Relay TCP port for Minecraft traffic
|
||||
RELAY_PORT=25565
|
||||
|
||||
# HTTP API port
|
||||
API_PORT=3000
|
||||
|
||||
# Node identification
|
||||
NODE_ID=
|
||||
NODE_NAME=relay-node-1
|
||||
|
||||
# Master node configuration
|
||||
IS_MASTER=true
|
||||
MASTER_URL=http://master-host:3000
|
||||
|
||||
# Security
|
||||
SECRET=your-secret-key-here
|
||||
|
||||
# Limits
|
||||
MAX_ROOMS=100
|
||||
MAX_PLAYERS_PER_ROOM=20
|
||||
|
||||
# Heartbeat interval in ms
|
||||
HEARTBEAT_INTERVAL=10000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Public host (for worker nodes to report to master)
|
||||
PUBLIC_HOST=0.0.0.0
|
||||
2006
server/package-lock.json
generated
Normal file
2006
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
server/package.json
Normal file
31
server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "funmc-server",
|
||||
"version": "1.0.0",
|
||||
"description": "FunMC Minecraft Relay Server - Multi-node TCP proxy for Minecraft multiplayer",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"dev:watch": "nodemon --exec ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.16.0",
|
||||
"uuid": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"winston": "^3.11.0",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
211
server/src/api.ts
Normal file
211
server/src/api.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import express, { Request, Response, Router } from 'express';
|
||||
import { RoomManager } from './room';
|
||||
import { NodeManager } from './node-manager';
|
||||
import { RelayEngine } from './relay';
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
|
||||
export function createApiRouter(
|
||||
roomManager: RoomManager,
|
||||
nodeManager: NodeManager,
|
||||
relayEngine: RelayEngine
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// ===== Health & Status =====
|
||||
router.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
nodeId: config.nodeId,
|
||||
nodeName: config.nodeName,
|
||||
isMaster: config.isMaster,
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/stats', (_req: Request, res: Response) => {
|
||||
const nodeStats = config.isMaster ? nodeManager.getStats() : null;
|
||||
res.json({
|
||||
node: {
|
||||
id: config.nodeId,
|
||||
name: config.nodeName,
|
||||
rooms: roomManager.getRoomCount(),
|
||||
players: roomManager.getTotalPlayers(),
|
||||
maxRooms: config.maxRooms,
|
||||
},
|
||||
cluster: nodeStats,
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Room Management =====
|
||||
router.get('/rooms', (_req: Request, res: Response) => {
|
||||
const rooms = roomManager.listRooms();
|
||||
res.json({ rooms, total: rooms.length });
|
||||
});
|
||||
|
||||
router.post('/rooms', (req: Request, res: Response) => {
|
||||
const { name, hostName, hostPort, gameVersion, gameEdition, maxPlayers, password } = req.body;
|
||||
|
||||
if (!name || !hostName || !hostPort || !gameVersion) {
|
||||
res.status(400).json({ error: '缺少必要参数: name, hostName, hostPort, gameVersion' });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = roomManager.createRoom({
|
||||
name,
|
||||
hostName,
|
||||
hostPort: parseInt(hostPort),
|
||||
gameVersion,
|
||||
gameEdition: gameEdition || 'java',
|
||||
maxPlayers: parseInt(maxPlayers) || 10,
|
||||
nodeId: config.nodeId,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
res.status(503).json({ error: '房间数量已达上限' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
room: room.toInfo(),
|
||||
connectInfo: {
|
||||
host: config.isMaster ? 'MASTER_HOST' : config.nodeName,
|
||||
port: config.port,
|
||||
roomId: room.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/rooms/:roomId', (req: Request, res: Response) => {
|
||||
const room = roomManager.getRoom(req.params.roomId);
|
||||
if (!room) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ room: room.toInfo() });
|
||||
});
|
||||
|
||||
router.delete('/rooms/:roomId', (req: Request, res: Response) => {
|
||||
const deleted = roomManager.deleteRoom(req.params.roomId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ message: '房间已删除' });
|
||||
});
|
||||
|
||||
// ===== Node Management (Master only) =====
|
||||
router.get('/nodes', (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可管理节点列表' });
|
||||
return;
|
||||
}
|
||||
const nodes = nodeManager.listNodes();
|
||||
res.json({ nodes, total: nodes.length });
|
||||
});
|
||||
|
||||
router.post('/nodes/register', (req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可注册节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, host, apiPort, relayPort, maxRooms, region } = req.body;
|
||||
if (!name || !host || !apiPort || !relayPort) {
|
||||
res.status(400).json({ error: '缺少必要参数: name, host, apiPort, relayPort' });
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeManager.registerNode({
|
||||
name,
|
||||
host,
|
||||
apiPort: parseInt(apiPort),
|
||||
relayPort: parseInt(relayPort),
|
||||
maxRooms: parseInt(maxRooms) || 100,
|
||||
region: region || 'default',
|
||||
});
|
||||
|
||||
res.status(201).json({ node });
|
||||
});
|
||||
|
||||
router.post('/nodes/:nodeId/heartbeat', (req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可接收心跳' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { roomCount, playerCount } = req.body;
|
||||
const updated = nodeManager.updateNodeHeartbeat(req.params.nodeId, {
|
||||
roomCount: parseInt(roomCount) || 0,
|
||||
playerCount: parseInt(playerCount) || 0,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: '节点不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
router.delete('/nodes/:nodeId', (req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可删除节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = nodeManager.removeNode(req.params.nodeId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: '节点不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ message: '节点已移除' });
|
||||
});
|
||||
|
||||
router.get('/nodes/best', (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可查询最佳节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
const best = nodeManager.getBestNode();
|
||||
if (!best) {
|
||||
res.status(503).json({ error: '无可用节点' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ node: best });
|
||||
});
|
||||
|
||||
// ===== Cluster Room Query (Master aggregates from all nodes) =====
|
||||
router.get('/cluster/rooms', async (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可查询集群房间' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allRooms = roomManager.listRooms();
|
||||
const nodes = nodeManager.listNodes();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.status === 'offline') continue;
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/rooms`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (response.data.rooms) {
|
||||
allRooms.push(...response.data.rooms);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to fetch rooms from node ${node.name}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ rooms: allRooms, total: allRooms.length });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
32
server/src/config.ts
Normal file
32
server/src/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
apiPort: number;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
masterUrl: string;
|
||||
isMaster: boolean;
|
||||
secret: string;
|
||||
maxRooms: number;
|
||||
maxPlayersPerRoom: number;
|
||||
heartbeatInterval: number;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
port: parseInt(process.env.RELAY_PORT || '25565'),
|
||||
apiPort: parseInt(process.env.API_PORT || '3000'),
|
||||
nodeId: process.env.NODE_ID || '',
|
||||
nodeName: process.env.NODE_NAME || 'relay-node-1',
|
||||
masterUrl: process.env.MASTER_URL || '',
|
||||
isMaster: process.env.IS_MASTER === 'true',
|
||||
secret: process.env.SECRET || 'funmc-default-secret',
|
||||
maxRooms: parseInt(process.env.MAX_ROOMS || '100'),
|
||||
maxPlayersPerRoom: parseInt(process.env.MAX_PLAYERS_PER_ROOM || '20'),
|
||||
heartbeatInterval: parseInt(process.env.HEARTBEAT_INTERVAL || '10000'),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
|
||||
export default config;
|
||||
134
server/src/index.ts
Normal file
134
server/src/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import axios from 'axios';
|
||||
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
import { RoomManager } from './room';
|
||||
import { NodeManager } from './node-manager';
|
||||
import { RelayEngine } from './relay';
|
||||
import { createApiRouter } from './api';
|
||||
import { WebSocketHandler } from './websocket';
|
||||
|
||||
async function main() {
|
||||
logger.info('========================================');
|
||||
logger.info(' FunMC Relay Server Starting...');
|
||||
logger.info('========================================');
|
||||
|
||||
if (!config.nodeId) {
|
||||
config.nodeId = uuidv4().substring(0, 8);
|
||||
}
|
||||
|
||||
logger.info(`Node ID: ${config.nodeId}`);
|
||||
logger.info(`Node Name: ${config.nodeName}`);
|
||||
logger.info(`Mode: ${config.isMaster ? 'MASTER' : 'WORKER'}`);
|
||||
logger.info(`Relay Port: ${config.port}`);
|
||||
logger.info(`API Port: ${config.apiPort}`);
|
||||
|
||||
const roomManager = new RoomManager(config.maxRooms);
|
||||
const nodeManager = new NodeManager();
|
||||
const relayEngine = new RelayEngine(roomManager);
|
||||
const wsHandler = new WebSocketHandler(roomManager, nodeManager);
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api', createApiRouter(roomManager, nodeManager, relayEngine));
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
name: 'FunMC Relay Server',
|
||||
version: '1.0.0',
|
||||
nodeId: config.nodeId,
|
||||
nodeName: config.nodeName,
|
||||
isMaster: config.isMaster,
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
wsHandler.attach(httpServer);
|
||||
|
||||
httpServer.listen(config.apiPort, '0.0.0.0', () => {
|
||||
logger.info(`HTTP API server listening on port ${config.apiPort}`);
|
||||
});
|
||||
|
||||
await relayEngine.start(config.port);
|
||||
|
||||
if (!config.isMaster && config.masterUrl) {
|
||||
await registerWithMaster();
|
||||
startHeartbeat(roomManager);
|
||||
}
|
||||
|
||||
if (config.isMaster) {
|
||||
nodeManager.registerNode({
|
||||
name: config.nodeName,
|
||||
host: '127.0.0.1',
|
||||
apiPort: config.apiPort,
|
||||
relayPort: config.port,
|
||||
maxRooms: config.maxRooms,
|
||||
region: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Shutting down...');
|
||||
relayEngine.stop();
|
||||
wsHandler.stop();
|
||||
httpServer.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Shutting down...');
|
||||
relayEngine.stop();
|
||||
wsHandler.stop();
|
||||
httpServer.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
logger.info('FunMC Relay Server is ready!');
|
||||
}
|
||||
|
||||
async function registerWithMaster(): Promise<void> {
|
||||
try {
|
||||
const response = await axios.post(`${config.masterUrl}/api/nodes/register`, {
|
||||
name: config.nodeName,
|
||||
host: getPublicHost(),
|
||||
apiPort: config.apiPort,
|
||||
relayPort: config.port,
|
||||
maxRooms: config.maxRooms,
|
||||
region: 'default',
|
||||
});
|
||||
logger.info(`Registered with master node, assigned ID: ${response.data.node?.id}`);
|
||||
config.nodeId = response.data.node?.id || config.nodeId;
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to register with master: ${err.message}`);
|
||||
logger.warn('Running in standalone mode');
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat(roomManager: RoomManager): void {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await axios.post(`${config.masterUrl}/api/nodes/${config.nodeId}/heartbeat`, {
|
||||
roomCount: roomManager.getRoomCount(),
|
||||
playerCount: roomManager.getTotalPlayers(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Heartbeat failed: ${err.message}`);
|
||||
}
|
||||
}, config.heartbeatInterval);
|
||||
}
|
||||
|
||||
function getPublicHost(): string {
|
||||
return process.env.PUBLIC_HOST || '0.0.0.0';
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error(`Fatal error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
20
server/src/logger.ts
Normal file
20
server/src/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import winston from 'winston';
|
||||
import config from './config';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: config.logLevel,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
],
|
||||
});
|
||||
|
||||
export default logger;
|
||||
154
server/src/node-manager.ts
Normal file
154
server/src/node-manager.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import logger from './logger';
|
||||
import config from './config';
|
||||
|
||||
export interface RelayNode {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
roomCount: number;
|
||||
playerCount: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
lastHeartbeat: Date;
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
||||
export class NodeManager {
|
||||
private nodes: Map<string, RelayNode> = new Map();
|
||||
private heartbeatTimers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor() {
|
||||
if (config.isMaster) {
|
||||
setInterval(() => this.checkNodeHealth(), config.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
registerNode(nodeInfo: {
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
}): RelayNode {
|
||||
const existing = Array.from(this.nodes.values()).find(
|
||||
n => n.host === nodeInfo.host && n.apiPort === nodeInfo.apiPort
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.status = 'online';
|
||||
existing.lastHeartbeat = new Date();
|
||||
existing.name = nodeInfo.name;
|
||||
existing.maxRooms = nodeInfo.maxRooms;
|
||||
existing.region = nodeInfo.region;
|
||||
logger.info(`Node re-registered: ${existing.name} (${existing.id})`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const node: RelayNode = {
|
||||
id: uuidv4().substring(0, 8),
|
||||
name: nodeInfo.name,
|
||||
host: nodeInfo.host,
|
||||
apiPort: nodeInfo.apiPort,
|
||||
relayPort: nodeInfo.relayPort,
|
||||
status: 'online',
|
||||
roomCount: 0,
|
||||
playerCount: 0,
|
||||
maxRooms: nodeInfo.maxRooms,
|
||||
region: nodeInfo.region,
|
||||
lastHeartbeat: new Date(),
|
||||
registeredAt: new Date(),
|
||||
};
|
||||
|
||||
this.nodes.set(node.id, node);
|
||||
logger.info(`New node registered: ${node.name} (${node.id}) at ${node.host}:${node.relayPort}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
removeNode(nodeId: string): boolean {
|
||||
const timer = this.heartbeatTimers.get(nodeId);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.heartbeatTimers.delete(nodeId);
|
||||
|
||||
const removed = this.nodes.delete(nodeId);
|
||||
if (removed) {
|
||||
logger.info(`Node removed: ${nodeId}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
updateNodeHeartbeat(nodeId: string, stats: { roomCount: number; playerCount: number }): boolean {
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
node.lastHeartbeat = new Date();
|
||||
node.roomCount = stats.roomCount;
|
||||
node.playerCount = stats.playerCount;
|
||||
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
|
||||
return true;
|
||||
}
|
||||
|
||||
getNode(nodeId: string): RelayNode | undefined {
|
||||
return this.nodes.get(nodeId);
|
||||
}
|
||||
|
||||
listNodes(): RelayNode[] {
|
||||
return Array.from(this.nodes.values());
|
||||
}
|
||||
|
||||
getAvailableNodes(): RelayNode[] {
|
||||
return Array.from(this.nodes.values()).filter(n => n.status === 'online');
|
||||
}
|
||||
|
||||
getBestNode(): RelayNode | undefined {
|
||||
const available = this.getAvailableNodes();
|
||||
if (available.length === 0) return undefined;
|
||||
|
||||
return available.sort((a, b) => {
|
||||
const aLoad = a.roomCount / a.maxRooms;
|
||||
const bLoad = b.roomCount / b.maxRooms;
|
||||
return aLoad - bLoad;
|
||||
})[0];
|
||||
}
|
||||
|
||||
private async checkNodeHealth(): Promise<void> {
|
||||
const now = new Date();
|
||||
for (const [nodeId, node] of this.nodes) {
|
||||
const timeSinceHeartbeat = now.getTime() - node.lastHeartbeat.getTime();
|
||||
|
||||
if (timeSinceHeartbeat > config.heartbeatInterval * 3) {
|
||||
node.status = 'offline';
|
||||
logger.warn(`Node ${node.name} (${nodeId}) marked offline - no heartbeat for ${Math.round(timeSinceHeartbeat / 1000)}s`);
|
||||
} else {
|
||||
try {
|
||||
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/health`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (response.data.status === 'ok') {
|
||||
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
|
||||
node.lastHeartbeat = new Date();
|
||||
}
|
||||
} catch {
|
||||
if (timeSinceHeartbeat > config.heartbeatInterval * 2) {
|
||||
node.status = 'offline';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } {
|
||||
const nodes = Array.from(this.nodes.values());
|
||||
return {
|
||||
totalNodes: nodes.length,
|
||||
onlineNodes: nodes.filter(n => n.status !== 'offline').length,
|
||||
totalRooms: nodes.reduce((sum, n) => sum + n.roomCount, 0),
|
||||
totalPlayers: nodes.reduce((sum, n) => sum + n.playerCount, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
205
server/src/relay.ts
Normal file
205
server/src/relay.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import net from 'net';
|
||||
import logger from './logger';
|
||||
import { Room, RoomManager } from './room';
|
||||
import config from './config';
|
||||
|
||||
export class RelayEngine {
|
||||
private server: net.Server | null = null;
|
||||
private roomManager: RoomManager;
|
||||
private hostConnections: Map<string, net.Socket> = new Map();
|
||||
private pendingConnections: Map<string, { roomId: string; playerName: string; resolve: (socket: net.Socket) => void }> = new Map();
|
||||
|
||||
constructor(roomManager: RoomManager) {
|
||||
this.roomManager = roomManager;
|
||||
}
|
||||
|
||||
start(port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = net.createServer((clientSocket) => {
|
||||
this.handleConnection(clientSocket);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error(`Relay server error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.server.listen(port, '0.0.0.0', () => {
|
||||
logger.info(`Relay TCP server listening on port ${port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleConnection(clientSocket: net.Socket): void {
|
||||
const remoteAddr = `${clientSocket.remoteAddress}:${clientSocket.remotePort}`;
|
||||
logger.info(`New TCP connection from ${remoteAddr}`);
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
let identified = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!identified) {
|
||||
logger.warn(`Connection from ${remoteAddr} timed out waiting for identification`);
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
clientSocket.once('data', (data) => {
|
||||
clearTimeout(timeout);
|
||||
identified = true;
|
||||
buffer = Buffer.concat([buffer, data]);
|
||||
this.identifyAndRoute(clientSocket, buffer, remoteAddr);
|
||||
});
|
||||
|
||||
clientSocket.on('error', (err) => {
|
||||
logger.debug(`Socket error from ${remoteAddr}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private identifyAndRoute(clientSocket: net.Socket, initialData: Buffer, remoteAddr: string): void {
|
||||
try {
|
||||
const headerStr = initialData.toString('utf8', 0, Math.min(initialData.length, 512));
|
||||
|
||||
if (headerStr.startsWith('FUNMC_HOST:')) {
|
||||
this.handleHostConnection(clientSocket, headerStr, remoteAddr);
|
||||
} else if (headerStr.startsWith('FUNMC_JOIN:')) {
|
||||
this.handlePlayerJoin(clientSocket, headerStr, initialData, remoteAddr);
|
||||
} else {
|
||||
logger.debug(`Unknown protocol from ${remoteAddr}, closing`);
|
||||
clientSocket.destroy();
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Error routing connection from ${remoteAddr}: ${err.message}`);
|
||||
clientSocket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostConnection(hostSocket: net.Socket, header: string, remoteAddr: string): void {
|
||||
const parts = header.split('\n')[0].replace('FUNMC_HOST:', '').split('|');
|
||||
if (parts.length < 2) {
|
||||
hostSocket.write('ERROR:INVALID_FORMAT\n');
|
||||
hostSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = parts[0].trim();
|
||||
const hostPort = parseInt(parts[1].trim());
|
||||
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room) {
|
||||
hostSocket.write('ERROR:ROOM_NOT_FOUND\n');
|
||||
hostSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
room.hostSocket = hostSocket;
|
||||
this.hostConnections.set(roomId, hostSocket);
|
||||
hostSocket.write('OK:HOST_REGISTERED\n');
|
||||
|
||||
logger.info(`Host registered for room ${roomId} from ${remoteAddr}, target port ${hostPort}`);
|
||||
|
||||
hostSocket.on('close', () => {
|
||||
logger.info(`Host disconnected from room ${roomId}`);
|
||||
this.hostConnections.delete(roomId);
|
||||
this.roomManager.deleteRoom(roomId);
|
||||
});
|
||||
|
||||
hostSocket.on('error', (err) => {
|
||||
logger.error(`Host socket error for room ${roomId}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private handlePlayerJoin(playerSocket: net.Socket, header: string, initialData: Buffer, remoteAddr: string): void {
|
||||
const parts = header.split('\n')[0].replace('FUNMC_JOIN:', '').split('|');
|
||||
if (parts.length < 2) {
|
||||
playerSocket.write('ERROR:INVALID_FORMAT\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = parts[0].trim();
|
||||
const playerName = parts[1].trim();
|
||||
const password = parts[2]?.trim();
|
||||
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room) {
|
||||
playerSocket.write('ERROR:ROOM_NOT_FOUND\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.password && room.password !== password) {
|
||||
playerSocket.write('ERROR:WRONG_PASSWORD\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.isFull) {
|
||||
playerSocket.write('ERROR:ROOM_FULL\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const hostSocket = this.hostConnections.get(roomId);
|
||||
if (!hostSocket || hostSocket.destroyed) {
|
||||
playerSocket.write('ERROR:HOST_OFFLINE\n');
|
||||
playerSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const player = room.addPlayer(playerName, playerSocket);
|
||||
playerSocket.write('OK:CONNECTED\n');
|
||||
|
||||
this.setupRelay(roomId, player.id, playerSocket, hostSocket, room);
|
||||
}
|
||||
|
||||
private setupRelay(roomId: string, playerId: string, playerSocket: net.Socket, hostSocket: net.Socket, room: Room): void {
|
||||
const hostAddr = hostSocket.remoteAddress;
|
||||
const hostPort = room.hostPort;
|
||||
|
||||
const targetSocket = new net.Socket();
|
||||
|
||||
targetSocket.connect(hostPort, hostAddr || '127.0.0.1', () => {
|
||||
logger.info(`Relay established: player ${playerId} <-> host ${hostAddr}:${hostPort} in room ${roomId}`);
|
||||
|
||||
playerSocket.pipe(targetSocket);
|
||||
targetSocket.pipe(playerSocket);
|
||||
});
|
||||
|
||||
targetSocket.on('error', (err) => {
|
||||
logger.error(`Target connection error for player ${playerId}: ${err.message}`);
|
||||
playerSocket.destroy();
|
||||
});
|
||||
|
||||
targetSocket.on('close', () => {
|
||||
playerSocket.destroy();
|
||||
room.removePlayer(playerId);
|
||||
});
|
||||
|
||||
playerSocket.on('close', () => {
|
||||
targetSocket.destroy();
|
||||
room.removePlayer(playerId);
|
||||
});
|
||||
|
||||
playerSocket.on('error', (err) => {
|
||||
logger.debug(`Player socket error: ${err.message}`);
|
||||
targetSocket.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
registerHostDirect(roomId: string, hostSocket: net.Socket): void {
|
||||
this.hostConnections.set(roomId, hostSocket);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
logger.info('Relay TCP server stopped');
|
||||
}
|
||||
for (const [, socket] of this.hostConnections) {
|
||||
socket.destroy();
|
||||
}
|
||||
this.hostConnections.clear();
|
||||
}
|
||||
}
|
||||
187
server/src/room.ts
Normal file
187
server/src/room.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import net from 'net';
|
||||
import logger from './logger';
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
socket: net.Socket;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface RoomInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
hostId: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
currentPlayers: number;
|
||||
nodeId: string;
|
||||
createdAt: Date;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class Room {
|
||||
public id: string;
|
||||
public name: string;
|
||||
public hostId: string;
|
||||
public hostName: string;
|
||||
public hostSocket: net.Socket | null = null;
|
||||
public hostPort: number;
|
||||
public gameVersion: string;
|
||||
public gameEdition: 'java' | 'bedrock';
|
||||
public maxPlayers: number;
|
||||
public password?: string;
|
||||
public nodeId: string;
|
||||
public createdAt: Date;
|
||||
public players: Map<string, Player> = new Map();
|
||||
private playerSockets: Map<string, net.Socket> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
nodeId: string;
|
||||
password?: string;
|
||||
}) {
|
||||
this.id = uuidv4().substring(0, 8).toUpperCase();
|
||||
this.name = options.name;
|
||||
this.hostId = uuidv4();
|
||||
this.hostName = options.hostName;
|
||||
this.hostPort = options.hostPort;
|
||||
this.gameVersion = options.gameVersion;
|
||||
this.gameEdition = options.gameEdition;
|
||||
this.maxPlayers = options.maxPlayers;
|
||||
this.nodeId = options.nodeId;
|
||||
this.password = options.password;
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
|
||||
get currentPlayers(): number {
|
||||
return this.players.size;
|
||||
}
|
||||
|
||||
get isFull(): boolean {
|
||||
return this.currentPlayers >= this.maxPlayers;
|
||||
}
|
||||
|
||||
addPlayer(name: string, socket: net.Socket): Player {
|
||||
const player: Player = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
socket,
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
this.players.set(player.id, player);
|
||||
this.playerSockets.set(player.id, socket);
|
||||
logger.info(`Player ${name} joined room ${this.name} (${this.id})`);
|
||||
return player;
|
||||
}
|
||||
|
||||
removePlayer(playerId: string): void {
|
||||
const player = this.players.get(playerId);
|
||||
if (player) {
|
||||
logger.info(`Player ${player.name} left room ${this.name} (${this.id})`);
|
||||
this.players.delete(playerId);
|
||||
this.playerSockets.delete(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
toInfo(): RoomInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
hostId: this.hostId,
|
||||
hostName: this.hostName,
|
||||
hostPort: this.hostPort,
|
||||
gameVersion: this.gameVersion,
|
||||
gameEdition: this.gameEdition,
|
||||
maxPlayers: this.maxPlayers,
|
||||
currentPlayers: this.currentPlayers,
|
||||
nodeId: this.nodeId,
|
||||
createdAt: this.createdAt,
|
||||
password: this.password ? '***' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const [, player] of this.players) {
|
||||
try {
|
||||
player.socket.destroy();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (this.hostSocket) {
|
||||
try {
|
||||
this.hostSocket.destroy();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
this.players.clear();
|
||||
this.playerSockets.clear();
|
||||
logger.info(`Room ${this.name} (${this.id}) destroyed`);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private rooms: Map<string, Room> = new Map();
|
||||
private maxRooms: number;
|
||||
|
||||
constructor(maxRooms: number) {
|
||||
this.maxRooms = maxRooms;
|
||||
}
|
||||
|
||||
createRoom(options: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
nodeId: string;
|
||||
password?: string;
|
||||
}): Room | null {
|
||||
if (this.rooms.size >= this.maxRooms) {
|
||||
logger.warn('Max rooms reached, cannot create new room');
|
||||
return null;
|
||||
}
|
||||
const room = new Room(options);
|
||||
this.rooms.set(room.id, room);
|
||||
logger.info(`Room created: ${room.name} (${room.id}) on node ${room.nodeId}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this.rooms.get(roomId);
|
||||
}
|
||||
|
||||
deleteRoom(roomId: string): boolean {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.destroy();
|
||||
this.rooms.delete(roomId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
listRooms(): RoomInfo[] {
|
||||
return Array.from(this.rooms.values()).map(r => r.toInfo());
|
||||
}
|
||||
|
||||
getRoomCount(): number {
|
||||
return this.rooms.size;
|
||||
}
|
||||
|
||||
getTotalPlayers(): number {
|
||||
let total = 0;
|
||||
for (const [, room] of this.rooms) {
|
||||
total += room.currentPlayers;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
133
server/src/websocket.ts
Normal file
133
server/src/websocket.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import WebSocket from 'ws';
|
||||
import http from 'http';
|
||||
import { RoomManager } from './room';
|
||||
import { NodeManager } from './node-manager';
|
||||
import logger from './logger';
|
||||
import config from './config';
|
||||
|
||||
export class WebSocketHandler {
|
||||
private wss: WebSocket.Server | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
private broadcastInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private roomManager: RoomManager,
|
||||
private nodeManager: NodeManager
|
||||
) {}
|
||||
|
||||
attach(server: http.Server): void {
|
||||
this.wss = new WebSocket.Server({ server, path: '/ws' });
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
this.clients.add(ws);
|
||||
logger.debug(`WebSocket client connected, total: ${this.clients.size}`);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'welcome',
|
||||
data: {
|
||||
nodeId: config.nodeId,
|
||||
nodeName: config.nodeName,
|
||||
isMaster: config.isMaster,
|
||||
},
|
||||
}));
|
||||
|
||||
ws.on('message', (message: string) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
this.handleMessage(ws, msg);
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: '无效的消息格式' } }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(ws);
|
||||
logger.debug(`WebSocket client disconnected, total: ${this.clients.size}`);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logger.debug(`WebSocket error: ${err.message}`);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
this.broadcastInterval = setInterval(() => {
|
||||
this.broadcastStatus();
|
||||
}, 3000);
|
||||
|
||||
logger.info('WebSocket server attached');
|
||||
}
|
||||
|
||||
private handleMessage(ws: WebSocket, msg: any): void {
|
||||
switch (msg.type) {
|
||||
case 'getRooms':
|
||||
ws.send(JSON.stringify({
|
||||
type: 'rooms',
|
||||
data: { rooms: this.roomManager.listRooms() },
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'getNodes':
|
||||
if (config.isMaster) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'nodes',
|
||||
data: { nodes: this.nodeManager.listNodes() },
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'getStats':
|
||||
ws.send(JSON.stringify({
|
||||
type: 'stats',
|
||||
data: {
|
||||
rooms: this.roomManager.getRoomCount(),
|
||||
players: this.roomManager.getTotalPlayers(),
|
||||
nodes: config.isMaster ? this.nodeManager.getStats() : null,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: `未知消息类型: ${msg.type}` } }));
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastStatus(): void {
|
||||
if (this.clients.size === 0) return;
|
||||
|
||||
const status = JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
rooms: this.roomManager.getRoomCount(),
|
||||
players: this.roomManager.getTotalPlayers(),
|
||||
nodes: config.isMaster ? this.nodeManager.getStats() : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(type: string, data: any): void {
|
||||
const message = JSON.stringify({ type, data });
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.broadcastInterval) {
|
||||
clearInterval(this.broadcastInterval);
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
19
server/tsconfig.json
Normal file
19
server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
14
web/index.html
Normal file
14
web/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FunMC - Minecraft 联机平台</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-mc-dark text-white min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2975
web/package-lock.json
generated
Normal file
2975
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/package.json
Normal file
28
web/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "funmc-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
web/public/vite.svg
Normal file
4
web/public/vite.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="#1a1a2e"/>
|
||||
<text x="50" y="65" text-anchor="middle" font-size="50" font-weight="bold" fill="#4CAF50">F</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
96
web/src/App.tsx
Normal file
96
web/src/App.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Gamepad2, Server, Users, LayoutDashboard, Plus, Settings } from 'lucide-react';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Rooms from './pages/Rooms';
|
||||
import Nodes from './pages/Nodes';
|
||||
import CreateRoom from './pages/CreateRoom';
|
||||
import AddNode from './pages/AddNode';
|
||||
import { wsClient } from './api';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ path: '/rooms', label: '房间列表', icon: Gamepad2 },
|
||||
{ path: '/rooms/create', label: '创建房间', icon: Plus },
|
||||
{ path: '/nodes', label: '节点管理', icon: Server },
|
||||
{ path: '/nodes/add', label: '添加节点', icon: Settings },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
wsClient.connect();
|
||||
const offConnect = wsClient.on('connected', () => setWsConnected(true));
|
||||
const offDisconnect = wsClient.on('disconnected', () => setWsConnected(false));
|
||||
return () => {
|
||||
offConnect();
|
||||
offDisconnect();
|
||||
wsClient.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-mc-darker border-r border-mc-accent/20 flex flex-col">
|
||||
<div className="p-6 border-b border-mc-accent/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<Gamepad2 className="w-8 h-8 text-mc-green" />
|
||||
<div>
|
||||
<h1 className="font-minecraft text-sm text-mc-green">FunMC</h1>
|
||||
<p className="text-xs text-gray-400 mt-1">Minecraft 联机平台</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{wsConnected ? '已连接' : '连接中...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-mc-green/20 text-mc-green border border-mc-green/30'
|
||||
: 'text-gray-400 hover:text-white hover:bg-mc-accent/30'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-mc-accent/20">
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
FunMC v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/rooms" element={<Rooms />} />
|
||||
<Route path="/rooms/create" element={<CreateRoom />} />
|
||||
<Route path="/nodes" element={<Nodes />} />
|
||||
<Route path="/nodes/add" element={<AddNode />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
web/src/api.ts
Normal file
182
web/src/api.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
export interface RoomInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
hostId: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: 'java' | 'bedrock';
|
||||
maxPlayers: number;
|
||||
currentPlayers: number;
|
||||
nodeId: string;
|
||||
createdAt: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
roomCount: number;
|
||||
playerCount: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
lastHeartbeat: string;
|
||||
registeredAt: string;
|
||||
}
|
||||
|
||||
export interface ClusterStats {
|
||||
totalNodes: number;
|
||||
onlineNodes: number;
|
||||
totalRooms: number;
|
||||
totalPlayers: number;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
node: {
|
||||
id: string;
|
||||
name: string;
|
||||
rooms: number;
|
||||
players: number;
|
||||
maxRooms: number;
|
||||
};
|
||||
cluster: ClusterStats | null;
|
||||
}
|
||||
|
||||
export const apiService = {
|
||||
async getHealth() {
|
||||
const res = await api.get('/health');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getStats(): Promise<ServerStats> {
|
||||
const res = await api.get('/stats');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getRooms(): Promise<{ rooms: RoomInfo[]; total: number }> {
|
||||
const res = await api.get('/rooms');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async createRoom(data: {
|
||||
name: string;
|
||||
hostName: string;
|
||||
hostPort: number;
|
||||
gameVersion: string;
|
||||
gameEdition: string;
|
||||
maxPlayers: number;
|
||||
password?: string;
|
||||
}) {
|
||||
const res = await api.post('/rooms', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async deleteRoom(roomId: string) {
|
||||
const res = await api.delete(`/rooms/${roomId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getNodes(): Promise<{ nodes: NodeInfo[]; total: number }> {
|
||||
const res = await api.get('/nodes');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async registerNode(data: {
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
}) {
|
||||
const res = await api.post('/nodes/register', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async removeNode(nodeId: string) {
|
||||
const res = await api.delete(`/nodes/${nodeId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getBestNode(): Promise<{ node: NodeInfo }> {
|
||||
const res = await api.get('/nodes/best');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getClusterRooms(): Promise<{ rooms: RoomInfo[]; total: number }> {
|
||||
const res = await api.get('/cluster/rooms');
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners: Map<string, Set<(data: any) => void>> = new Map();
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
connect(url?: string): void {
|
||||
const wsUrl = url || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.emit('connected', {});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this.emit(msg.type, msg.data);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.emit('disconnected', {});
|
||||
this.reconnectTimer = window.setTimeout(() => this.connect(url), 3000);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
on(event: string, callback: (data: any) => void): () => void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
this.listeners.get(event)!.add(callback);
|
||||
return () => this.listeners.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
private emit(event: string, data: any): void {
|
||||
this.listeners.get(event)?.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
send(type: string, data?: any): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type, data }));
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WebSocketClient();
|
||||
54
web/src/index.css
Normal file
54
web/src/index.css
Normal file
@@ -0,0 +1,54 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-mc-dark text-gray-100 antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-mc-darker;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-mc-accent rounded;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-mc-highlight;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-mc-green hover:bg-green-600 text-white font-bold rounded-lg
|
||||
transition-all duration-200 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 bg-mc-accent hover:bg-blue-700 text-white font-bold rounded-lg
|
||||
transition-all duration-200 active:scale-95;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply px-4 py-2 bg-mc-highlight hover:bg-red-600 text-white font-bold rounded-lg
|
||||
transition-all duration-200 active:scale-95;
|
||||
}
|
||||
.card {
|
||||
@apply bg-mc-darker border border-mc-accent/30 rounded-xl p-6 shadow-lg;
|
||||
}
|
||||
.input-field {
|
||||
@apply w-full px-4 py-2 bg-mc-dark border border-mc-accent/50 rounded-lg text-white
|
||||
placeholder-gray-500 focus:outline-none focus:border-mc-green focus:ring-1 focus:ring-mc-green
|
||||
transition-colors duration-200;
|
||||
}
|
||||
.badge-online {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-900 text-green-300;
|
||||
}
|
||||
.badge-offline {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-900 text-red-300;
|
||||
}
|
||||
.badge-busy {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-900 text-yellow-300;
|
||||
}
|
||||
}
|
||||
13
web/src/main.tsx
Normal file
13
web/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>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
188
web/src/pages/AddNode.tsx
Normal file
188
web/src/pages/AddNode.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Server, ArrowLeft, Check } from 'lucide-react';
|
||||
import { apiService } from '../api';
|
||||
|
||||
export default function AddNode() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
host: '',
|
||||
apiPort: '3000',
|
||||
relayPort: '25565',
|
||||
maxRooms: '100',
|
||||
region: 'default',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.host || !form.apiPort || !form.relayPort) {
|
||||
setError('请填写所有必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
await apiService.registerNode({
|
||||
name: form.name,
|
||||
host: form.host,
|
||||
apiPort: parseInt(form.apiPort),
|
||||
relayPort: parseInt(form.relayPort),
|
||||
maxRooms: parseInt(form.maxRooms),
|
||||
region: form.region,
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || '添加节点失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-mc-green/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Check className="w-8 h-8 text-mc-green" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">节点添加成功!</h2>
|
||||
<p className="text-gray-400 mb-6">新的中继节点已成功注册到集群</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => navigate('/nodes')} className="btn-secondary">
|
||||
查看所有节点
|
||||
</button>
|
||||
<button onClick={() => { setSuccess(false); setForm({ ...form, name: '', host: '' }); }} className="btn-primary">
|
||||
继续添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-gray-400 hover:text-white mb-6 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-400/20 flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">添加中继节点</h2>
|
||||
<p className="text-gray-400 text-sm">将新的Ubuntu服务器添加为中继节点</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 mb-4 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">节点名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="relay-node-2"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">服务器地址 *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="192.168.1.100 或 node2.example.com"
|
||||
value={form.host}
|
||||
onChange={e => setForm({ ...form, host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">API端口 *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="3000"
|
||||
value={form.apiPort}
|
||||
onChange={e => setForm({ ...form, apiPort: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">节点HTTP API监听端口</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">中继端口 *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="25565"
|
||||
value={form.relayPort}
|
||||
onChange={e => setForm({ ...form, relayPort: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minecraft TCP中继端口</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">最大房间数</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={form.maxRooms}
|
||||
onChange={e => setForm({ ...form, maxRooms: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">区域标识</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="cn-east, us-west..."
|
||||
value={form.region}
|
||||
onChange={e => setForm({ ...form, region: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-mc-dark/50 rounded-lg p-4 border border-mc-accent/10">
|
||||
<h4 className="font-bold text-sm mb-2">部署提示</h4>
|
||||
<p className="text-gray-400 text-xs leading-relaxed">
|
||||
在添加节点前,请确保目标Ubuntu服务器已部署并运行了FunMC Relay Server。
|
||||
工作节点需要配置 <code className="text-mc-green">IS_MASTER=false</code> 和
|
||||
<code className="text-mc-green"> MASTER_URL</code> 指向主节点地址。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||
{loading ? (
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<Server className="w-4 h-4" />
|
||||
)}
|
||||
{loading ? '添加中...' : '添加节点'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
web/src/pages/CreateRoom.tsx
Normal file
238
web/src/pages/CreateRoom.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Gamepad2, ArrowLeft, Copy, Check } from 'lucide-react';
|
||||
import { apiService } from '../api';
|
||||
|
||||
export default function CreateRoom() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
hostName: '',
|
||||
hostPort: '25565',
|
||||
gameVersion: '1.20.4',
|
||||
gameEdition: 'java',
|
||||
maxPlayers: '10',
|
||||
password: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [createdRoom, setCreatedRoom] = useState<any>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.hostName || !form.hostPort || !form.gameVersion) {
|
||||
setError('请填写所有必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const result = await apiService.createRoom({
|
||||
name: form.name,
|
||||
hostName: form.hostName,
|
||||
hostPort: parseInt(form.hostPort),
|
||||
gameVersion: form.gameVersion,
|
||||
gameEdition: form.gameEdition,
|
||||
maxPlayers: parseInt(form.maxPlayers),
|
||||
password: form.password || undefined,
|
||||
});
|
||||
setCreatedRoom(result);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || '创建房间失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyRoomId() {
|
||||
if (createdRoom?.room?.id) {
|
||||
navigator.clipboard.writeText(createdRoom.room.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdRoom) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-mc-green/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Check className="w-8 h-8 text-mc-green" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">房间创建成功!</h2>
|
||||
<p className="text-gray-400 mb-6">将房间号分享给你的好友即可联机</p>
|
||||
|
||||
<div className="bg-mc-dark rounded-xl p-6 mb-6">
|
||||
<p className="text-gray-400 text-sm mb-2">房间号</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="font-minecraft text-2xl text-mc-gold tracking-wider">
|
||||
{createdRoom.room.id}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyRoomId}
|
||||
className="p-2 hover:bg-mc-accent/30 rounded-lg transition-colors"
|
||||
title="复制房间号"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-5 h-5 text-mc-green" />
|
||||
) : (
|
||||
<Copy className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-mc-dark rounded-xl p-4 mb-6 text-left space-y-2">
|
||||
<InfoRow label="房间名称" value={createdRoom.room.name} />
|
||||
<InfoRow label="游戏版本" value={createdRoom.room.gameVersion} />
|
||||
<InfoRow label="版本类型" value={createdRoom.room.gameEdition === 'java' ? 'Java版' : '基岩版'} />
|
||||
<InfoRow label="最大人数" value={String(createdRoom.room.maxPlayers)} />
|
||||
<InfoRow label="连接地址" value={`${createdRoom.connectInfo.host}:${createdRoom.connectInfo.port}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => navigate('/rooms')} className="btn-secondary">
|
||||
查看所有房间
|
||||
</button>
|
||||
<button onClick={() => { setCreatedRoom(null); setForm({ ...form, name: '' }); }} className="btn-primary">
|
||||
继续创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-gray-400 hover:text-white mb-6 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-mc-green/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-5 h-5 text-mc-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">创建联机房间</h2>
|
||||
<p className="text-gray-400 text-sm">填写以下信息来创建一个新的联机房间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 mb-4 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">房间名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="我的联机房间"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">房主名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="你的游戏名称"
|
||||
value={form.hostName}
|
||||
onChange={e => setForm({ ...form, hostName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">本地服务端口 *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="25565"
|
||||
value={form.hostPort}
|
||||
onChange={e => setForm({ ...form, hostPort: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minecraft服务器监听的端口</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">游戏版本 *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="1.20.4"
|
||||
value={form.gameVersion}
|
||||
onChange={e => setForm({ ...form, gameVersion: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">版本类型</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.gameEdition}
|
||||
onChange={e => setForm({ ...form, gameEdition: e.target.value })}
|
||||
>
|
||||
<option value="java">Java版</option>
|
||||
<option value="bedrock">基岩版</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">最大人数</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
min="2"
|
||||
max="100"
|
||||
value={form.maxPlayers}
|
||||
onChange={e => setForm({ ...form, maxPlayers: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">房间密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="留空则不设密码"
|
||||
value={form.password}
|
||||
onChange={e => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||
{loading ? (
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<Gamepad2 className="w-4 h-4" />
|
||||
)}
|
||||
{loading ? '创建中...' : '创建房间'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-gray-400 text-sm">{label}</span>
|
||||
<span className="text-white text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
web/src/pages/Dashboard.tsx
Normal file
219
web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Server, Users, Gamepad2, Activity, Wifi, WifiOff } from 'lucide-react';
|
||||
import { apiService, wsClient, type ServerStats } from '../api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<ServerStats | null>(null);
|
||||
const [health, setHealth] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const off = wsClient.on('status', (data: any) => {
|
||||
setStats(prev => prev ? {
|
||||
...prev,
|
||||
node: { ...prev.node, rooms: data.rooms, players: data.players },
|
||||
cluster: data.nodes || prev.cluster,
|
||||
} : prev);
|
||||
});
|
||||
return () => off();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [statsData, healthData] = await Promise.all([
|
||||
apiService.getStats(),
|
||||
apiService.getHealth(),
|
||||
]);
|
||||
setStats(statsData);
|
||||
setHealth(healthData);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError('无法连接到服务器: ' + (err.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-mc-green border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card border-red-500/30">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<WifiOff className="w-6 h-6" />
|
||||
<div>
|
||||
<h3 className="font-bold">连接失败</h3>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={loadData} className="btn-primary mt-4">重试</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: '在线房间',
|
||||
value: stats?.node.rooms || 0,
|
||||
max: stats?.node.maxRooms || 100,
|
||||
icon: Gamepad2,
|
||||
color: 'text-green-400',
|
||||
bg: 'bg-green-400/10',
|
||||
},
|
||||
{
|
||||
label: '在线玩家',
|
||||
value: stats?.node.players || 0,
|
||||
icon: Users,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-400/10',
|
||||
},
|
||||
{
|
||||
label: '集群节点',
|
||||
value: stats?.cluster?.onlineNodes || 1,
|
||||
max: stats?.cluster?.totalNodes || 1,
|
||||
icon: Server,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-400/10',
|
||||
},
|
||||
{
|
||||
label: '服务状态',
|
||||
value: health?.status === 'ok' ? '正常' : '异常',
|
||||
icon: Activity,
|
||||
color: health?.status === 'ok' ? 'text-green-400' : 'text-red-400',
|
||||
bg: health?.status === 'ok' ? 'bg-green-400/10' : 'bg-red-400/10',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">仪表盘</h2>
|
||||
<p className="text-gray-400 text-sm mt-1">FunMC 服务器运行状态总览</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Wifi className="w-4 h-4 text-green-400" />
|
||||
<span className="text-gray-400">节点: {stats?.node.name || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statCards.map((card, i) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div key={i} className="card hover:border-mc-accent/60 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{card.label}</p>
|
||||
<p className={`text-3xl font-bold mt-2 ${card.color}`}>
|
||||
{card.value}
|
||||
{card.max !== undefined && (
|
||||
<span className="text-sm text-gray-500 font-normal">/{card.max}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-xl ${card.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-6 h-6 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-mc-green" />
|
||||
服务器信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="节点ID" value={health?.nodeId || 'N/A'} />
|
||||
<InfoRow label="节点名称" value={health?.nodeName || 'N/A'} />
|
||||
<InfoRow label="运行模式" value={health?.isMaster ? '主节点' : '工作节点'} />
|
||||
<InfoRow label="运行时间" value={formatUptime(health?.uptime || 0)} />
|
||||
<InfoRow label="最大房间数" value={String(stats?.node.maxRooms || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats?.cluster && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-purple-400" />
|
||||
集群状态
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="总节点数" value={String(stats.cluster.totalNodes)} />
|
||||
<InfoRow label="在线节点" value={String(stats.cluster.onlineNodes)} />
|
||||
<InfoRow label="总房间数" value={String(stats.cluster.totalRooms)} />
|
||||
<InfoRow label="总玩家数" value={String(stats.cluster.totalPlayers)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Guide */}
|
||||
<div className="card mt-6">
|
||||
<h3 className="text-lg font-bold mb-4">快速入门</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StepCard
|
||||
step={1}
|
||||
title="创建房间"
|
||||
desc="在「创建房间」页面填写房间信息,启动你的Minecraft服务器"
|
||||
/>
|
||||
<StepCard
|
||||
step={2}
|
||||
title="分享房间号"
|
||||
desc="将生成的房间号发给你的好友,他们可以通过房间号连接"
|
||||
/>
|
||||
<StepCard
|
||||
step={3}
|
||||
title="开始游戏"
|
||||
desc="好友使用FunMC客户端连接到中继服务器,即可一起畅玩"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b border-mc-accent/10">
|
||||
<span className="text-gray-400 text-sm">{label}</span>
|
||||
<span className="text-white font-medium text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({ step, title, desc }: { step: number; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="bg-mc-dark/50 rounded-lg p-4 border border-mc-accent/10">
|
||||
<div className="w-8 h-8 rounded-full bg-mc-green/20 text-mc-green font-bold flex items-center justify-center text-sm mb-3">
|
||||
{step}
|
||||
</div>
|
||||
<h4 className="font-bold text-sm mb-1">{title}</h4>
|
||||
<p className="text-gray-400 text-xs">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}时${m}分${s}秒`;
|
||||
if (m > 0) return `${m}分${s}秒`;
|
||||
return `${s}秒`;
|
||||
}
|
||||
161
web/src/pages/Nodes.tsx
Normal file
161
web/src/pages/Nodes.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Server, Trash2, Plus, RefreshCw, Globe, Gamepad2, Users, Activity } from 'lucide-react';
|
||||
import { apiService, type NodeInfo } from '../api';
|
||||
|
||||
export default function Nodes() {
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
}, []);
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiService.getNodes();
|
||||
setNodes(data.nodes);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 403) {
|
||||
setError('当前节点不是主节点,无法管理节点列表');
|
||||
} else {
|
||||
setError('加载节点列表失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(nodeId: string) {
|
||||
if (!confirm('确定要移除这个节点吗?')) return;
|
||||
try {
|
||||
await apiService.removeNode(nodeId);
|
||||
setNodes(nodes.filter(n => n.id !== nodeId));
|
||||
} catch {
|
||||
alert('移除失败');
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online': return <span className="badge-online">在线</span>;
|
||||
case 'offline': return <span className="badge-offline">离线</span>;
|
||||
case 'busy': return <span className="badge-busy">繁忙</span>;
|
||||
default: return <span className="badge-offline">未知</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">节点管理</h2>
|
||||
<p className="text-gray-400 text-sm mt-1">管理中继服务器集群节点</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={loadNodes} className="btn-secondary flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
<Link to="/nodes/add" className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
添加节点
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-mc-green border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="card border-red-500/30 text-red-400 mb-4">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && nodes.length === 0 && !error && (
|
||||
<div className="card text-center py-12">
|
||||
<Server className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-gray-400">暂无节点</h3>
|
||||
<p className="text-gray-500 text-sm mt-2">添加一个中继节点来开始服务</p>
|
||||
<Link to="/nodes/add" className="btn-primary mt-4 inline-flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
添加节点
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{nodes.map(node => (
|
||||
<div key={node.id} className="card hover:border-mc-accent/60 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
node.status === 'online' ? 'bg-green-400/10' :
|
||||
node.status === 'busy' ? 'bg-yellow-400/10' : 'bg-red-400/10'
|
||||
}`}>
|
||||
<Server className={`w-6 h-6 ${
|
||||
node.status === 'online' ? 'text-green-400' :
|
||||
node.status === 'busy' ? 'text-yellow-400' : 'text-red-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-bold text-lg">{node.name}</h3>
|
||||
{statusBadge(node.status)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-0.5">
|
||||
ID: {node.id} | {node.host}:{node.relayPort}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(node.id)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors p-2"
|
||||
title="移除节点"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatBox icon={Gamepad2} label="房间数" value={`${node.roomCount}/${node.maxRooms}`} color="green" />
|
||||
<StatBox icon={Users} label="玩家数" value={String(node.playerCount)} color="blue" />
|
||||
<StatBox icon={Globe} label="区域" value={node.region} color="purple" />
|
||||
<StatBox icon={Activity} label="API端口" value={String(node.apiPort)} color="orange" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>注册时间: {new Date(node.registeredAt).toLocaleString('zh-CN')}</span>
|
||||
<span>最后心跳: {new Date(node.lastHeartbeat).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ icon: Icon, label, value, color }: {
|
||||
icon: any; label: string; value: string; color: string;
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
green: 'text-green-400 bg-green-400/10',
|
||||
blue: 'text-blue-400 bg-blue-400/10',
|
||||
purple: 'text-purple-400 bg-purple-400/10',
|
||||
orange: 'text-orange-400 bg-orange-400/10',
|
||||
};
|
||||
const cls = colorMap[color] || colorMap.green;
|
||||
|
||||
return (
|
||||
<div className="bg-mc-dark/50 rounded-lg p-3 text-center">
|
||||
<Icon className={`w-4 h-4 mx-auto mb-1 ${cls.split(' ')[0]}`} />
|
||||
<p className="text-xs text-gray-400">{label}</p>
|
||||
<p className="text-sm font-bold mt-0.5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
web/src/pages/Rooms.tsx
Normal file
146
web/src/pages/Rooms.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Gamepad2, Users, Clock, Lock, Trash2, Copy, Plus, RefreshCw } from 'lucide-react';
|
||||
import { apiService, type RoomInfo } from '../api';
|
||||
|
||||
export default function Rooms() {
|
||||
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadRooms();
|
||||
}, []);
|
||||
|
||||
async function loadRooms() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiService.getRooms();
|
||||
setRooms(data.rooms);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError('加载房间列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(roomId: string) {
|
||||
if (!confirm('确定要删除这个房间吗?')) return;
|
||||
try {
|
||||
await apiService.deleteRoom(roomId);
|
||||
setRooms(rooms.filter(r => r.id !== roomId));
|
||||
} catch {
|
||||
alert('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function copyRoomId(roomId: string) {
|
||||
navigator.clipboard.writeText(roomId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">房间列表</h2>
|
||||
<p className="text-gray-400 text-sm mt-1">当前活跃的联机房间</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={loadRooms} className="btn-secondary flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
<Link to="/rooms/create" className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
创建房间
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-mc-green border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="card border-red-500/30 text-red-400 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rooms.length === 0 && (
|
||||
<div className="card text-center py-12">
|
||||
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-gray-400">暂无房间</h3>
|
||||
<p className="text-gray-500 text-sm mt-2">创建一个房间来开始联机吧!</p>
|
||||
<Link to="/rooms/create" className="btn-primary mt-4 inline-flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
创建房间
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{rooms.map(room => (
|
||||
<div key={room.id} className="card hover:border-mc-accent/60 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-mc-green/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-5 h-5 text-mc-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold flex items-center gap-2">
|
||||
{room.name}
|
||||
{room.password && <Lock className="w-3 h-3 text-yellow-400" />}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs mt-0.5">
|
||||
房间号: {room.id}
|
||||
<button
|
||||
onClick={() => copyRoomId(room.id)}
|
||||
className="ml-2 text-mc-green hover:text-green-300 transition-colors"
|
||||
title="复制房间号"
|
||||
>
|
||||
<Copy className="w-3 h-3 inline" />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(room.id)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors p-1"
|
||||
title="删除房间"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
<div className="bg-mc-dark/50 rounded-lg p-2 text-center">
|
||||
<Users className="w-4 h-4 text-blue-400 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-400">玩家</p>
|
||||
<p className="text-sm font-bold">{room.currentPlayers}/{room.maxPlayers}</p>
|
||||
</div>
|
||||
<div className="bg-mc-dark/50 rounded-lg p-2 text-center">
|
||||
<Gamepad2 className="w-4 h-4 text-green-400 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-400">版本</p>
|
||||
<p className="text-sm font-bold">{room.gameVersion}</p>
|
||||
</div>
|
||||
<div className="bg-mc-dark/50 rounded-lg p-2 text-center">
|
||||
<Clock className="w-4 h-4 text-purple-400 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-400">版本类型</p>
|
||||
<p className="text-sm font-bold">{room.gameEdition === 'java' ? 'Java' : '基岩'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>房主: {room.hostName}</span>
|
||||
<span>创建于: {new Date(room.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
web/tailwind.config.js
Normal file
25
web/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
mc: {
|
||||
green: '#4CAF50',
|
||||
dark: '#1a1a2e',
|
||||
darker: '#16213e',
|
||||
accent: '#0f3460',
|
||||
highlight: '#e94560',
|
||||
gold: '#FFD700',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
minecraft: ['"Press Start 2P"', 'monospace'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
web/tsconfig.json
Normal file
21
web/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": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
web/vite.config.ts
Normal file
19
web/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user