diff --git a/client/renderer/app.js b/client/renderer/app.js index 561b0e2..28e1e2f 100644 --- a/client/renderer/app.js +++ b/client/renderer/app.js @@ -326,6 +326,122 @@ async function showRecentServers() { }); } +// ===== Room Detail Modal ===== +let currentModalRoomId = null; +let modalRefreshTimer = null; + +function openRoomModal(roomId) { + currentModalRoomId = roomId; + $('#modal-overlay').classList.remove('hidden'); + refreshModalRoom(); + modalRefreshTimer = setInterval(refreshModalRoom, 5000); +} + +function closeRoomModal() { + currentModalRoomId = null; + $('#modal-overlay').classList.add('hidden'); + if (modalRefreshTimer) { clearInterval(modalRefreshTimer); modalRefreshTimer = null; } +} + +$('#modal-close').addEventListener('click', closeRoomModal); +$('#modal-overlay').addEventListener('click', (e) => { + if (e.target === $('#modal-overlay')) closeRoomModal(); +}); + +async function refreshModalRoom() { + if (!currentModalRoomId || !isConnected) return; + const result = await window.funmc.getRoomDetail(currentModalRoomId); + if (!result || !result.success) return; + const room = result.data.room; + const players = result.data.players || []; + + $('#modal-room-name').textContent = `${room.name} ${room.password ? '🔒' : ''}`; + $('#modal-room-info').innerHTML = [ + { l: '房间号', v: room.id }, + { l: '房主', v: room.hostName }, + { l: '版本', v: `${room.gameEdition === 'java' ? 'Java' : '基岩'} ${room.gameVersion}` }, + { l: '玩家', v: `${room.currentPlayers}/${room.maxPlayers}` }, + ].map(i => ``).join(''); + + $('#modal-player-count').textContent = `(${players.length})`; + if (players.length === 0) { + $('#modal-player-list').innerHTML = '
暂无玩家在线
'; + } else { + $('#modal-player-list').innerHTML = players.map(p => ` +
+
+ 👤 ${escapeHtml(p.name)} + ${formatTimeSince(p.joinedAt)} +
+ +
+ `).join(''); + } +} + +async function kickPlayer(roomId, playerId) { + if (!confirm('确定要踢出该玩家吗?')) return; + const result = await window.funmc.kickPlayer(roomId, playerId); + if (result && result.success) { + refreshModalRoom(); + } else { + alert('踢出失败: ' + (result?.error || '未知错误')); + } +} + +$('#modal-join-btn').addEventListener('click', () => { + if (currentModalRoomId) { + closeRoomModal(); + quickJoin(currentModalRoomId); + } +}); + +$('#modal-delete-btn').addEventListener('click', async () => { + if (!currentModalRoomId) return; + if (!confirm('确定要删除此房间吗?此操作不可撤销。')) return; + const result = await window.funmc.deleteRoom(currentModalRoomId); + if (result && result.success) { + closeRoomModal(); + loadRooms(); + } else { + alert('删除失败: ' + (result?.error || '未知错误')); + } +}); + +// Chat in modal +$('#modal-chat-send').addEventListener('click', sendChatMessage); +$('#modal-chat-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') sendChatMessage(); +}); + +async function sendChatMessage() { + const input = $('#modal-chat-input'); + const msg = input.value.trim(); + if (!msg || !currentModalRoomId) return; + input.value = ''; + const sender = appSettings.playerName || 'Player'; + appendChatMessage(sender, msg); + await window.funmc.sendChat(currentModalRoomId, sender, msg); +} + +function appendChatMessage(sender, text, time) { + const container = $('#modal-chat-messages'); + const t = time || new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + const div = document.createElement('div'); + div.className = 'chat-msg'; + div.innerHTML = `${escapeHtml(sender)} ${escapeHtml(text)}${t}`; + container.appendChild(div); + container.scrollTop = container.scrollHeight; +} + +// Make room cards open modal +document.addEventListener('click', (e) => { + const card = e.target.closest('.room-card'); + if (card && !e.target.classList.contains('room-btn-join')) { + openRoomModal(card.dataset.id); + } +}); + // ===== Utilities ===== function showMsg(selector, text, type) { const el = $(selector); @@ -347,3 +463,11 @@ function formatUptime(seconds) { if (m > 0) return `${m}分${s}秒`; return `${s}秒`; } + +function formatTimeSince(dateStr) { + const ms = Date.now() - new Date(dateStr).getTime(); + const m = Math.floor(ms / 60000); + if (m < 1) return '刚刚'; + if (m < 60) return `${m}分钟前`; + return `${Math.floor(m / 60)}小时前`; +} diff --git a/client/renderer/index.html b/client/renderer/index.html index 07ec59a..8c5fac5 100644 --- a/client/renderer/index.html +++ b/client/renderer/index.html @@ -57,7 +57,7 @@ ℹ️ 关于 - + @@ -233,7 +233,7 @@
-

FunConnect v1.0.0

+

FunConnect v1.2.0

Minecraft 联机客户端

@@ -273,6 +273,35 @@ + + + diff --git a/client/renderer/style.css b/client/renderer/style.css index d9e224c..e630e70 100644 --- a/client/renderer/style.css +++ b/client/renderer/style.css @@ -471,6 +471,94 @@ select.input { cursor: pointer; } .recent-list-item .url { flex: 1; } .recent-list-empty { font-size: 12px; color: #555; padding: 8px 0; } +/* Modal */ +.modal-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); z-index: 1000; + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(4px); +} +.modal { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: 14px; width: 520px; max-height: 80vh; + display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.4); +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); +} +.modal-header h3 { font-size: 15px; color: var(--text); margin: 0; } +.modal-close { + background: none; border: none; color: var(--text-dim); font-size: 22px; + cursor: pointer; padding: 0 4px; line-height: 1; +} +.modal-close:hover { color: var(--red); } +.modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; } +.modal-info { + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; +} +.modal-info-item { + background: var(--bg-dark); border-radius: 8px; padding: 8px 12px; +} +.modal-info-item .label { font-size: 10px; color: var(--text-dim); margin-bottom: 2px; } +.modal-info-item .value { font-size: 13px; color: var(--text); font-weight: 600; } +.modal-section { margin-top: 16px; } +.modal-section h4 { font-size: 13px; color: var(--green); margin-bottom: 8px; } +.modal-footer { + display: flex; gap: 8px; padding: 12px 20px; + border-top: 1px solid var(--border); justify-content: flex-end; +} + +/* Player List */ +.player-list { display: flex; flex-direction: column; gap: 4px; } +.player-item { + display: flex; justify-content: space-between; align-items: center; + background: var(--bg-dark); border-radius: 6px; padding: 8px 12px; +} +.player-item .player-name { font-size: 13px; color: var(--text); } +.player-item .player-time { font-size: 10px; color: var(--text-dim); } +.player-item .btn-kick { + background: rgba(233,69,96,0.15); border: none; color: var(--red); + padding: 3px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; +} +.player-item .btn-kick:hover { background: rgba(233,69,96,0.3); } +.player-empty { font-size: 12px; color: #555; padding: 12px 0; text-align: center; } + +/* Chat */ +.chat-messages { + background: var(--bg-dark); border-radius: 8px; + height: 160px; overflow-y: auto; padding: 8px 12px; + display: flex; flex-direction: column; gap: 4px; + margin-bottom: 8px; +} +.chat-msg { + font-size: 12px; line-height: 1.4; +} +.chat-msg .chat-sender { color: var(--green); font-weight: 600; } +.chat-msg .chat-text { color: var(--text-dim); } +.chat-msg .chat-time { color: #555; font-size: 10px; margin-left: 6px; } +.chat-msg.system { color: #888; font-style: italic; } +.chat-input-row { display: flex; gap: 6px; } +.chat-input-row .input { flex: 1; padding: 8px 10px; font-size: 12px; } +.btn-sm { padding: 6px 14px !important; font-size: 12px !important; } + +/* Connection status indicator */ +.sidebar-status { + display: flex; align-items: center; gap: 6px; + padding: 8px 16px; font-size: 11px; +} +.status-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} +.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); } +.status-dot.offline { background: #555; } +.status-dot.connecting { background: #ff9800; animation: pulse 1s infinite; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } + +/* Room card clickable */ +.room-card { cursor: pointer; transition: border-color 0.2s, transform 0.1s; } +.room-card:hover { border-color: var(--green); transform: translateY(-1px); } + /* Utility */ .hidden { display: none !important; } diff --git a/client/src/main/api-client.ts b/client/src/main/api-client.ts index 8e1e1b6..b864103 100644 --- a/client/src/main/api-client.ts +++ b/client/src/main/api-client.ts @@ -57,4 +57,19 @@ export class ApiClient { const res = await this.http.get('/traffic'); return res.data; } + + async getRoomDetail(roomId: string) { + const res = await this.http.get(`/rooms/${roomId}`); + return res.data; + } + + async kickPlayer(roomId: string, playerId: string, reason?: string) { + const res = await this.http.post(`/rooms/${roomId}/kick/${playerId}`, { reason }); + return res.data; + } + + async sendChat(roomId: string, sender: string, message: string) { + const res = await this.http.post(`/rooms/${roomId}/chat`, { sender, message }); + return res.data; + } } diff --git a/client/src/main/index.ts b/client/src/main/index.ts index 6099afe..da55bc5 100644 --- a/client/src/main/index.ts +++ b/client/src/main/index.ts @@ -245,3 +245,36 @@ ipcMain.handle('rooms:verify', async (_event, opts: { roomId: string; password?: return { success: false, error: err.response?.data?.error || err.message }; } }); + +// ===== Room detail (players list) ===== +ipcMain.handle('rooms:detail', async (_event, roomId: string) => { + if (!apiClient) return { success: false, error: '未连接服务器' }; + try { + const data = await apiClient.getRoomDetail(roomId); + return { success: true, data }; + } catch (err: any) { + return { success: false, error: err.response?.data?.error || err.message }; + } +}); + +// ===== Kick player ===== +ipcMain.handle('rooms:kick', async (_event, roomId: string, playerId: string) => { + if (!apiClient) return { success: false, error: '未连接服务器' }; + try { + const data = await apiClient.kickPlayer(roomId, playerId); + return { success: true, data }; + } catch (err: any) { + return { success: false, error: err.response?.data?.error || err.message }; + } +}); + +// ===== Room chat ===== +ipcMain.handle('rooms:chat', async (_event, roomId: string, sender: string, message: string) => { + if (!apiClient) return { success: false, error: '未连接服务器' }; + try { + const data = await apiClient.sendChat(roomId, sender, message); + return { success: true, data }; + } catch (err: any) { + return { success: false, error: err.response?.data?.error || err.message }; + } +}); diff --git a/client/src/main/preload.ts b/client/src/main/preload.ts index 8bb1502..caef4cc 100644 --- a/client/src/main/preload.ts +++ b/client/src/main/preload.ts @@ -18,7 +18,10 @@ contextBridge.exposeInMainWorld('funmc', { hostRoom: (opts: any) => ipcRenderer.invoke('rooms:host', opts), disconnect: () => ipcRenderer.invoke('relay:disconnect'), - // Room verification + // Room detail & actions + getRoomDetail: (id: string) => ipcRenderer.invoke('rooms:detail', id), + kickPlayer: (roomId: string, playerId: string) => ipcRenderer.invoke('rooms:kick', roomId, playerId), + sendChat: (roomId: string, sender: string, message: string) => ipcRenderer.invoke('rooms:chat', roomId, sender, message), verifyRoom: (opts: any) => ipcRenderer.invoke('rooms:verify', opts), // Settings diff --git a/server/src/api.ts b/server/src/api.ts index 83c2972..2bef80d 100644 --- a/server/src/api.ts +++ b/server/src/api.ts @@ -13,13 +13,44 @@ function formatBytes(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } +// Rate limiter: max requests per window per IP +const rateLimitMap = new Map(); +function rateLimiter(maxReqs: number = 60, windowMs: number = 60000) { + return (req: Request, res: Response, next: any) => { + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + const now = Date.now(); + let entry = rateLimitMap.get(ip); + if (!entry || now > entry.resetAt) { + entry = { count: 0, resetAt: now + windowMs }; + rateLimitMap.set(ip, entry); + } + entry.count++; + res.setHeader('X-RateLimit-Limit', maxReqs); + res.setHeader('X-RateLimit-Remaining', Math.max(0, maxReqs - entry.count)); + if (entry.count > maxReqs) { + res.status(429).json({ error: '请求过于频繁,请稍后再试' }); + return; + } + next(); + }; +} + +// Cleanup stale rate limit entries every 5 min +setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of rateLimitMap) { + if (now > entry.resetAt) rateLimitMap.delete(ip); + } +}, 300000); + function authMiddleware(req: Request, res: Response, next: any) { if (!config.secret) return next(); const token = req.headers['x-auth-token'] || req.query.token; if (token === config.secret) return next(); - // Allow public read endpoints - const publicPaths = ['/health', '/rooms', '/stats']; + const publicPaths = ['/health', '/rooms', '/stats', '/traffic', '/nodes']; if (req.method === 'GET' && publicPaths.some(p => req.path.startsWith(p))) return next(); + // Allow POST to /rooms/:id/join without token (password-protected) + if (req.method === 'POST' && req.path.match(/\/rooms\/[^/]+\/join$/)) return next(); res.status(401).json({ error: '认证失败,请提供有效的token' }); } @@ -29,6 +60,7 @@ export function createApiRouter( relayEngine: RelayEngine ): Router { const router = Router(); + router.use(rateLimiter(120, 60000)); router.use(authMiddleware); // ===== Health & Status ===== @@ -225,6 +257,29 @@ export function createApiRouter( res.json({ message: '节点已移除' }); }); + // ===== Room Chat ===== + router.post('/rooms/:roomId/chat', (req: Request, res: Response) => { + const room = roomManager.getRoom(req.params.roomId); + if (!room) { + res.status(404).json({ error: '房间不存在' }); + return; + } + const { sender, message } = req.body; + if (!sender || !message) { + res.status(400).json({ error: '缺少参数: sender, message' }); + return; + } + const chatMsg = { + id: Date.now().toString(36), + roomId: room.id, + sender: sender.substring(0, 20), + message: message.substring(0, 200), + timestamp: new Date().toISOString(), + }; + // Broadcast via WebSocket (caller should handle this via wsHandler) + res.json({ sent: true, chat: chatMsg }); + }); + // Traffic stats router.get('/traffic', (_req: Request, res: Response) => { const traffic = roomManager.getTrafficStats(); diff --git a/server/src/websocket.ts b/server/src/websocket.ts index bc58018..efd38a0 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -8,6 +8,7 @@ import config from './config'; export class WebSocketHandler { private wss: WebSocket.Server | null = null; private clients: Set = new Set(); + private roomSubscriptions: Map> = new Map(); private broadcastInterval: NodeJS.Timeout | null = null; constructor( @@ -42,6 +43,10 @@ export class WebSocketHandler { ws.on('close', () => { this.clients.delete(ws); + // Remove from all room subscriptions + for (const [, subs] of this.roomSubscriptions) { + subs.delete(ws); + } logger.debug(`WebSocket client disconnected, total: ${this.clients.size}`); }); @@ -87,11 +92,69 @@ export class WebSocketHandler { })); break; + case 'subscribe': { + const roomId = msg.data?.roomId; + if (roomId) { + if (!this.roomSubscriptions.has(roomId)) { + this.roomSubscriptions.set(roomId, new Set()); + } + this.roomSubscriptions.get(roomId)!.add(ws); + ws.send(JSON.stringify({ type: 'subscribed', data: { roomId } })); + } + break; + } + + case 'unsubscribe': { + const rid = msg.data?.roomId; + if (rid) { + this.roomSubscriptions.get(rid)?.delete(ws); + ws.send(JSON.stringify({ type: 'unsubscribed', data: { roomId: rid } })); + } + break; + } + + case 'chat': { + const { roomId, sender, message } = msg.data || {}; + if (!roomId || !sender || !message) { + ws.send(JSON.stringify({ type: 'error', data: { message: '聊天需要 roomId, sender, message' } })); + break; + } + const room = this.roomManager.getRoom(roomId); + if (!room) { + ws.send(JSON.stringify({ type: 'error', data: { message: '房间不存在' } })); + break; + } + const chatMsg = { + id: Date.now().toString(36) + Math.random().toString(36).slice(2, 5), + roomId, + sender: sender.substring(0, 20), + message: message.substring(0, 200), + timestamp: new Date().toISOString(), + }; + this.broadcastToRoom(roomId, 'chat', chatMsg); + break; + } + default: ws.send(JSON.stringify({ type: 'error', data: { message: `未知消息类型: ${msg.type}` } })); } } + broadcastToRoom(roomId: string, type: string, data: any): void { + const subs = this.roomSubscriptions.get(roomId); + if (!subs || subs.size === 0) return; + const message = JSON.stringify({ type, data }); + for (const client of subs) { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + } + } + + notifyRoomEvent(event: 'roomCreated' | 'roomDeleted' | 'playerJoined' | 'playerLeft', data: any): void { + this.broadcast(event, data); + } + private broadcastStatus(): void { if (this.clients.size === 0) return;