feat: FunConnect v1.0.0 - Minecraft联机平台完整版
- server: Node.js TCP中继服务器,支持多节点集群 - web: React管理面板(仪表盘、房间管理、节点管理) - client: Electron桌面客户端(连接、创建/加入房间、本地代理) - deploy: Ubuntu一键部署脚本
This commit is contained in:
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