From eb6e90144088ea32404746f2a414be63b81fed13 Mon Sep 17 00:00:00 2001 From: FunMC Date: Mon, 23 Feb 2026 08:26:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B8=AD=E7=BB=A7=E5=8D=8F=E8=AE=AE=20+=20=E5=85=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=88=BF=E9=97=B4=E8=AF=A6=E6=83=85/?= =?UTF-8?q?=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关键修复: - RelayClient: 二进制头部改为FUNMC_JOIN:roomId|playerName|password协议 - RelayClient: 等待服务端OK:CONNECTED/ERROR:*握手响应 - rooms:join: 先连接中继再启动本地代理, 传入playerName和password - 连接失败自动cleanup Web管理面板: - 房间详情弹窗: 点击房间卡片打开 - 玩家列表 + 踢出功能 (UserX图标) - 复制房间号 / 删除房间按钮 Mobile: - 房间详情底部弹窗 (Modal slide) - 在线玩家列表 - 分享房间号 (Share API) - 复制房间号 - apiClient.getRoomDetail 方法 --- client/src/main/index.ts | 20 +++-- client/src/main/relay-client.ts | 40 +++++++--- mobile/src/lib/api.ts | 4 + mobile/src/screens/RoomsScreen.tsx | 118 ++++++++++++++++++++++++++++- server/web/src/pages/Rooms.tsx | 75 +++++++++++++++++- 5 files changed, 233 insertions(+), 24 deletions(-) diff --git a/client/src/main/index.ts b/client/src/main/index.ts index da55bc5..86d852a 100644 --- a/client/src/main/index.ts +++ b/client/src/main/index.ts @@ -147,18 +147,13 @@ ipcMain.handle('rooms:delete', async (_event, roomId: string) => { }); // Join room - start local proxy and relay connection -ipcMain.handle('rooms:join', async (_event, opts: { serverHost: string; serverPort: number; roomId: string; localPort: number }) => { +ipcMain.handle('rooms:join', async (_event, opts: { serverHost: string; serverPort: number; roomId: string; localPort: number; password?: string }) => { try { cleanup(); - relayClient = new RelayClient(opts.serverHost, opts.serverPort, opts.roomId); - localProxy = new LocalProxy(opts.localPort, relayClient); + const playerName = (store.get('playerName') as string) || 'Player'; + relayClient = new RelayClient(opts.serverHost, opts.serverPort, opts.roomId, playerName, opts.password); - await localProxy.start(); - - relayClient.on('connected', () => { - mainWindow?.webContents.send('relay:status', { status: 'connected' }); - }); relayClient.on('disconnected', () => { mainWindow?.webContents.send('relay:status', { status: 'disconnected' }); }); @@ -166,8 +161,17 @@ ipcMain.handle('rooms:join', async (_event, opts: { serverHost: string; serverPo mainWindow?.webContents.send('relay:status', { status: 'error', error: err }); }); + // Connect to relay server first (handshake) + await relayClient.connect(); + + // Then start local proxy for Minecraft to connect to + localProxy = new LocalProxy(opts.localPort, relayClient); + await localProxy.start(); + + mainWindow?.webContents.send('relay:status', { status: 'connected' }); return { success: true, localPort: opts.localPort }; } catch (err: any) { + cleanup(); return { success: false, error: err.message }; } }); diff --git a/client/src/main/relay-client.ts b/client/src/main/relay-client.ts index 7255405..22926c9 100644 --- a/client/src/main/relay-client.ts +++ b/client/src/main/relay-client.ts @@ -9,7 +9,9 @@ export class RelayClient extends EventEmitter { constructor( private host: string, private port: number, - private roomId: string + private roomId: string, + private playerName: string = 'Player', + private password?: string ) { super(); } @@ -17,21 +19,35 @@ export class RelayClient extends EventEmitter { connect(): Promise { return new Promise((resolve, reject) => { this.socket = new net.Socket(); + let handshakeDone = false; 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(); + // Send FUNMC_JOIN handshake matching server relay protocol + const parts = [this.roomId, this.playerName]; + if (this.password) parts.push(this.password); + this.socket!.write(`FUNMC_JOIN:${parts.join('|')}\n`); + }); + + // Wait for server response before marking as connected + this.socket.once('data', (data) => { + const response = data.toString('utf8').trim(); + handshakeDone = true; + if (response.startsWith('OK:')) { + this.connected = true; + this.socket!.setTimeout(0); // clear timeout after handshake + this.emit('connected'); + resolve(); + } else { + // e.g. ERROR:ROOM_NOT_FOUND, ERROR:WRONG_PASSWORD, ERROR:ROOM_FULL, ERROR:HOST_OFFLINE + const errorMsg = response.replace('ERROR:', ''); + this.socket!.destroy(); + reject(new Error(errorMsg)); + } }); this.socket.on('error', (err) => { this.emit('error', err.message); - if (!this.connected) reject(err); + if (!handshakeDone) reject(err); }); this.socket.on('close', () => { @@ -40,7 +56,9 @@ export class RelayClient extends EventEmitter { }); this.socket.setTimeout(10000, () => { - this.socket?.destroy(new Error('Connection timeout')); + if (!handshakeDone) { + this.socket?.destroy(new Error('Connection timeout')); + } }); }); } diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 375bcf4..31f7de5 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -92,6 +92,10 @@ class ApiClient { async getTraffic() { return request(this.api('/traffic')); } + + async getRoomDetail(roomId: string): Promise<{ room: RoomInfo; players: any[] }> { + return request(this.api(`/rooms/${roomId}`)); + } } export const apiClient = new ApiClient(); diff --git a/mobile/src/screens/RoomsScreen.tsx b/mobile/src/screens/RoomsScreen.tsx index ff27b1b..68c4a71 100644 --- a/mobile/src/screens/RoomsScreen.tsx +++ b/mobile/src/screens/RoomsScreen.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, FlatList, - RefreshControl, Alert, TextInput, + RefreshControl, Alert, TextInput, Modal, Share, ScrollView, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { colors, spacing } from '../lib/theme'; @@ -41,14 +41,36 @@ export default function RoomsScreen() { return matchSearch && matchFilter; }); + const [detailVisible, setDetailVisible] = useState(false); + const [detailRoom, setDetailRoom] = useState(null); + const [detailPlayers, setDetailPlayers] = useState([]); + async function copyId(id: string) { await Clipboard.setStringAsync(id); Alert.alert('已复制', `房间号 ${id} 已复制到剪贴板`); } + async function shareRoom(room: RoomInfo) { + try { + await Share.share({ + message: `来FunConnect联机吧!\n房间: ${room.name}\n房间号: ${room.id}\n版本: ${room.gameEdition === 'java' ? 'Java' : '基岩'} ${room.gameVersion}\n玩家: ${room.currentPlayers}/${room.maxPlayers}`, + }); + } catch { /* cancelled */ } + } + + async function openDetail(room: RoomInfo) { + setDetailRoom(room); + setDetailVisible(true); + try { + const data = await apiClient.getRoomDetail(room.id); + if (data.room) setDetailRoom(data.room); + setDetailPlayers(data.players || []); + } catch { setDetailPlayers([]); } + } + function renderRoom({ item }: { item: RoomInfo }) { return ( - + openDetail(item)} activeOpacity={0.7}> 🎮 @@ -75,7 +97,7 @@ export default function RoomsScreen() { 复制号 - + ); } @@ -120,6 +142,54 @@ export default function RoomsScreen() { + {/* Room Detail Modal */} + + + + + {detailRoom?.name} {detailRoom?.password ? '🔒' : ''} + setDetailVisible(false)}> + + + + + + {[ + { l: '房间号', v: detailRoom?.id }, + { l: '房主', v: detailRoom?.hostName }, + { l: '版本', v: `${detailRoom?.gameEdition === 'java' ? 'Java' : '基岩'} ${detailRoom?.gameVersion}` }, + { l: '玩家', v: `${detailRoom?.currentPlayers}/${detailRoom?.maxPlayers}` }, + ].map((item, i) => ( + + {item.l} + {item.v} + + ))} + + 在线玩家 ({detailPlayers.length}) + {detailPlayers.length === 0 ? ( + 暂无玩家在线 + ) : ( + detailPlayers.map((p: any) => ( + + 👤 {p.name} + {new Date(p.joinedAt).toLocaleTimeString('zh-CN')} + + )) + )} + + + detailRoom && shareRoom(detailRoom)}> + 📤 分享 + + detailRoom && copyId(detailRoom.id)}> + 📋 复制房间号 + + + + + + ('all'); const [deleteConfirm, setDeleteConfirm] = useState(null); const [copied, setCopied] = useState(null); + const [detailRoom, setDetailRoom] = useState(null); + const [detailPlayers, setDetailPlayers] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); const loadRooms = useCallback(async () => { try { @@ -56,8 +59,76 @@ export default function Rooms() { setTimeout(() => setCopied(null), 2000); } + async function openDetail(roomId: string) { + setDetailLoading(true); + try { + const data = await apiService.getRoom(roomId); + setDetailRoom(data.room); + setDetailPlayers(data.players || []); + } catch { setDetailRoom(null); } + setDetailLoading(false); + } + + async function handleKick(roomId: string, playerId: string) { + if (!confirm('确定要踢出该玩家吗?')) return; + try { + await apiService.kickPlayer(roomId, playerId); + openDetail(roomId); + } catch { alert('踢出失败'); } + } + return (
+ {/* Room Detail Modal */} + {detailRoom && ( +
setDetailRoom(null)}> +
e.stopPropagation()}> +
+

+ {detailRoom.name} + {detailRoom.password && } +

+ +
+
+ {[ + { l: '房间号', v: detailRoom.id }, + { l: '房主', v: detailRoom.hostName }, + { l: '版本', v: `${detailRoom.gameEdition === 'java' ? 'Java' : '基岩'} ${detailRoom.gameVersion}` }, + { l: '玩家', v: `${detailRoom.currentPlayers}/${detailRoom.maxPlayers}` }, + ].map((item, i) => ( +
+

{item.l}

+

{item.v}

+
+ ))} +
+

在线玩家 ({detailPlayers.length})

+ {detailPlayers.length === 0 ? ( +

暂无玩家在线

+ ) : ( +
+ {detailPlayers.map((p: any) => ( +
+
+ 👤 {p.name} + {new Date(p.joinedAt).toLocaleTimeString('zh-CN')} +
+ +
+ ))} +
+ )} +
+ + +
+
+
+ )} + {/* Delete Confirmation Dialog */} {deleteConfirm && (
@@ -151,7 +222,7 @@ export default function Rooms() {
{filteredRooms.map(room => ( -
+
openDetail(room.id)}>