feat: Web面板迭代优化
- Rooms: 添加搜索/筛选(按名称、房间号、房主、版本类型) - Rooms: 10秒自动刷新房间列表 - Rooms: 删除房间使用确认弹窗替代confirm() - Rooms: 复制房间号显示已复制反馈 - Dashboard: 添加流量统计展示(入站/出站流量) - Dashboard: 15秒自动刷新数据 - API: 添加getTraffic、getRoom、kickPlayer方法
This commit is contained in:
@@ -120,6 +120,28 @@ export const apiService = {
|
|||||||
const res = await api.get('/cluster/rooms');
|
const res = await api.get('/cluster/rooms');
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getTraffic(): Promise<{
|
||||||
|
totalBytesIn: number;
|
||||||
|
totalBytesOut: number;
|
||||||
|
rooms: number;
|
||||||
|
players: number;
|
||||||
|
formattedIn: string;
|
||||||
|
formattedOut: string;
|
||||||
|
}> {
|
||||||
|
const res = await api.get('/traffic');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRoom(roomId: string): Promise<{ room: RoomInfo; players: any[] }> {
|
||||||
|
const res = await api.get(`/rooms/${roomId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async kickPlayer(roomId: string, playerId: string, reason?: string) {
|
||||||
|
const res = await api.post(`/rooms/${roomId}/kick/${playerId}`, { reason });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export class WebSocketClient {
|
export class WebSocketClient {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Server, Users, Gamepad2, Activity, Wifi, WifiOff } from 'lucide-react';
|
import { Server, Users, Gamepad2, Activity, Wifi, WifiOff, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||||
import { apiService, wsClient, type ServerStats } from '../api';
|
import { apiService, wsClient, type ServerStats } from '../api';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<ServerStats | null>(null);
|
const [stats, setStats] = useState<ServerStats | null>(null);
|
||||||
const [health, setHealth] = useState<any>(null);
|
const [health, setHealth] = useState<any>(null);
|
||||||
|
const [traffic, setTraffic] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -17,18 +18,21 @@ export default function Dashboard() {
|
|||||||
cluster: data.nodes || prev.cluster,
|
cluster: data.nodes || prev.cluster,
|
||||||
} : prev);
|
} : prev);
|
||||||
});
|
});
|
||||||
return () => off();
|
const timer = setInterval(loadData, 15000);
|
||||||
|
return () => { off(); clearInterval(timer); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [statsData, healthData] = await Promise.all([
|
const [statsData, healthData, trafficData] = await Promise.all([
|
||||||
apiService.getStats(),
|
apiService.getStats(),
|
||||||
apiService.getHealth(),
|
apiService.getHealth(),
|
||||||
|
apiService.getTraffic().catch(() => null),
|
||||||
]);
|
]);
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
setHealth(healthData);
|
setHealth(healthData);
|
||||||
|
setTraffic(trafficData);
|
||||||
setError('');
|
setError('');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError('无法连接到服务器: ' + (err.message || '未知错误'));
|
setError('无法连接到服务器: ' + (err.message || '未知错误'));
|
||||||
@@ -163,6 +167,38 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Traffic Stats */}
|
||||||
|
{traffic && (
|
||||||
|
<div className="card mt-6">
|
||||||
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-yellow-400" />
|
||||||
|
流量统计
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-mc-dark/50 rounded-lg p-4 text-center">
|
||||||
|
<ArrowDownToLine className="w-5 h-5 text-blue-400 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400">入站流量</p>
|
||||||
|
<p className="text-lg font-bold text-blue-400 mt-1">{traffic.formattedIn}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-mc-dark/50 rounded-lg p-4 text-center">
|
||||||
|
<ArrowUpFromLine className="w-5 h-5 text-green-400 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400">出站流量</p>
|
||||||
|
<p className="text-lg font-bold text-green-400 mt-1">{traffic.formattedOut}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-mc-dark/50 rounded-lg p-4 text-center">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-purple-400 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400">活跃房间</p>
|
||||||
|
<p className="text-lg font-bold text-purple-400 mt-1">{traffic.rooms}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-mc-dark/50 rounded-lg p-4 text-center">
|
||||||
|
<Users className="w-5 h-5 text-orange-400 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400">在线玩家</p>
|
||||||
|
<p className="text-lg font-bold text-orange-400 mt-1">{traffic.players}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Guide */}
|
{/* Quick Guide */}
|
||||||
<div className="card mt-6">
|
<div className="card mt-6">
|
||||||
<h3 className="text-lg font-bold mb-4">快速入门</h3>
|
<h3 className="text-lg font-bold mb-4">快速入门</h3>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Gamepad2, Users, Clock, Lock, Trash2, Copy, Plus, RefreshCw } from 'lucide-react';
|
import { Gamepad2, Users, Clock, Lock, Trash2, Copy, Plus, RefreshCw, Search, X } from 'lucide-react';
|
||||||
import { apiService, type RoomInfo } from '../api';
|
import { apiService, type RoomInfo } from '../api';
|
||||||
|
|
||||||
export default function Rooms() {
|
export default function Rooms() {
|
||||||
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [editionFilter, setEditionFilter] = useState<string>('all');
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadRooms = useCallback(async () => {
|
||||||
loadRooms();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadRooms() {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await apiService.getRooms();
|
const data = await apiService.getRooms();
|
||||||
@@ -23,13 +23,28 @@ export default function Rooms() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRooms();
|
||||||
|
const timer = setInterval(loadRooms, 10000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [loadRooms]);
|
||||||
|
|
||||||
|
const filteredRooms = rooms.filter(room => {
|
||||||
|
const matchSearch = !search ||
|
||||||
|
room.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
room.id.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
room.hostName.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchEdition = editionFilter === 'all' || room.gameEdition === editionFilter;
|
||||||
|
return matchSearch && matchEdition;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleDelete(roomId: string) {
|
async function handleDelete(roomId: string) {
|
||||||
if (!confirm('确定要删除这个房间吗?')) return;
|
|
||||||
try {
|
try {
|
||||||
await apiService.deleteRoom(roomId);
|
await apiService.deleteRoom(roomId);
|
||||||
setRooms(rooms.filter(r => r.id !== roomId));
|
setRooms(rooms.filter(r => r.id !== roomId));
|
||||||
|
setDeleteConfirm(null);
|
||||||
} catch {
|
} catch {
|
||||||
alert('删除失败');
|
alert('删除失败');
|
||||||
}
|
}
|
||||||
@@ -37,14 +52,30 @@ export default function Rooms() {
|
|||||||
|
|
||||||
function copyRoomId(roomId: string) {
|
function copyRoomId(roomId: string) {
|
||||||
navigator.clipboard.writeText(roomId);
|
navigator.clipboard.writeText(roomId);
|
||||||
|
setCopied(roomId);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-8">
|
{/* Delete Confirmation Dialog */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-mc-darker border border-mc-accent/40 rounded-xl p-6 max-w-sm w-full mx-4">
|
||||||
|
<h3 className="text-lg font-bold mb-2">确认删除</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">确定要删除房间 <span className="text-mc-green font-mono">{deleteConfirm}</span> 吗?此操作不可撤销。</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary">取消</button>
|
||||||
|
<button onClick={() => handleDelete(deleteConfirm)} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-colors">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">房间列表</h2>
|
<h2 className="text-2xl font-bold">房间列表</h2>
|
||||||
<p className="text-gray-400 text-sm mt-1">当前活跃的联机房间</p>
|
<p className="text-gray-400 text-sm mt-1">当前活跃的联机房间 · 每10秒自动刷新</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={loadRooms} className="btn-secondary flex items-center gap-2">
|
<button onClick={loadRooms} className="btn-secondary flex items-center gap-2">
|
||||||
@@ -58,6 +89,34 @@ export default function Rooms() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 text-gray-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="搜索房间名、房间号、房主..."
|
||||||
|
className="w-full bg-mc-dark border border-mc-accent/30 rounded-lg pl-10 pr-8 py-2 text-sm text-gray-200 focus:border-mc-green outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={editionFilter}
|
||||||
|
onChange={e => setEditionFilter(e.target.value)}
|
||||||
|
className="bg-mc-dark border border-mc-accent/30 rounded-lg px-3 py-2 text-sm text-gray-200 focus:border-mc-green outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="all">全部版本</option>
|
||||||
|
<option value="java">Java版</option>
|
||||||
|
<option value="bedrock">基岩版</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-32">
|
<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 className="animate-spin w-8 h-8 border-2 border-mc-green border-t-transparent rounded-full" />
|
||||||
@@ -70,7 +129,7 @@ export default function Rooms() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && rooms.length === 0 && (
|
{!loading && rooms.length === 0 && !search && editionFilter === 'all' && (
|
||||||
<div className="card text-center py-12">
|
<div className="card text-center py-12">
|
||||||
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-bold text-gray-400">暂无房间</h3>
|
<h3 className="text-lg font-bold text-gray-400">暂无房间</h3>
|
||||||
@@ -82,8 +141,16 @@ export default function Rooms() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && rooms.length > 0 && filteredRooms.length === 0 && (
|
||||||
|
<div className="card text-center py-8">
|
||||||
|
<Search className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||||
|
<h3 className="text-gray-400 font-bold">无匹配结果</h3>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">试试其他搜索词或筛选条件</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{rooms.map(room => (
|
{filteredRooms.map(room => (
|
||||||
<div key={room.id} className="card hover:border-mc-accent/60 transition-colors">
|
<div key={room.id} className="card hover:border-mc-accent/60 transition-colors">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -102,13 +169,13 @@ export default function Rooms() {
|
|||||||
className="ml-2 text-mc-green hover:text-green-300 transition-colors"
|
className="ml-2 text-mc-green hover:text-green-300 transition-colors"
|
||||||
title="复制房间号"
|
title="复制房间号"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3 inline" />
|
{copied === room.id ? <span className="text-xs">已复制</span> : <Copy className="w-3 h-3 inline" />}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(room.id)}
|
onClick={() => setDeleteConfirm(room.id)}
|
||||||
className="text-gray-500 hover:text-red-400 transition-colors p-1"
|
className="text-gray-500 hover:text-red-400 transition-colors p-1"
|
||||||
title="删除房间"
|
title="删除房间"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user