diff --git a/src/api/modules/game/room.ts b/src/api/modules/game/room.ts index 08b1257..0b88d54 100644 --- a/src/api/modules/game/room.ts +++ b/src/api/modules/game/room.ts @@ -4,7 +4,9 @@ import type { CreateRoomResponse, GetRoomListRequest, GetRoomListResponse, - RoomDetailResponse + JoinRoomRequest, + RoomDetailResponse, + RoomItem } from './types' /** @@ -37,6 +39,19 @@ export function createRoom(data: CreateRoomRequest) { return requestClient.post('/game/room/create', data) } +/** + * 加入房间 + * + * 玩家通过此接口加入指定房间,成为房间内的玩家。 + * 公开房间无需传密码。加入成功后需通过 WebSocket 进行后续交互。 + * + * @param data - 加入房间参数(roomId 和可选的 password) + * @returns 更新后的房间信息 + */ +export function joinRoom(data: JoinRoomRequest) { + return requestClient.post('/game/room/join', data) +} + /** * 获取房间详情 * diff --git a/src/api/modules/game/types.ts b/src/api/modules/game/types.ts index c12721b..0170348 100644 --- a/src/api/modules/game/types.ts +++ b/src/api/modules/game/types.ts @@ -151,6 +151,14 @@ export interface MoveItem { col: number } +/** 加入房间请求参数 */ +export interface JoinRoomRequest { + /** 房间 ID */ + roomId: string + /** 房间密码(可选,公开房间留空) */ + password?: string +} + /** WebSocket 消息通用封装 */ export interface WsMessage { type: string diff --git a/src/common/websocket/roomSocket.ts b/src/common/websocket/roomSocket.ts index d1bba5b..28d1952 100644 --- a/src/common/websocket/roomSocket.ts +++ b/src/common/websocket/roomSocket.ts @@ -1,25 +1,40 @@ import type { WsMessage } from '@/api/modules/game' +/** 重连配置 */ +const RECONNECT_MAX_RETRIES = 5 +/** 重连基础延迟(ms),指数退避:1s, 2s, 4s, 8s, 16s */ +const RECONNECT_BASE_DELAY = 1000 +/** 重连最大延迟(ms) */ +const RECONNECT_MAX_DELAY = 16000 + /** WebSocket 连接回调 */ export interface RoomSocketCallbacks { onOpen?: () => void onClose?: (event: CloseEvent) => void onError?: (event: Event) => void onMessage?: (message: WsMessage) => void + /** 自动重连成功回调(重连后需调用方重新拉取房间状态) */ + onReconnect?: () => void + /** 重连次数耗尽回调 */ + onReconnectFailed?: () => void } /** WebSocket 客户端封装 */ export interface RoomSocket { /** 发送消息 */ send: (type: string, data?: unknown) => void - /** 关闭连接 */ + /** 关闭连接(主动关闭不触发重连) */ close: () => void /** 连接状态 */ isOpen: () => boolean } /** - * 创建房间 WebSocket 连接 + * 创建房间 WebSocket 连接(含自动重连) + * + * 连接断开时自动指数退避重连,最多重试 5 次。 + * 主动调用 close() 或重连耗尽时触发 onClose 回调。 + * 重连成功后触发 onReconnect 回调,调用方应在此重新拉取房间状态。 * * @param roomId 房间 ID * @param token 用户认证 token @@ -33,45 +48,113 @@ export function createRoomSocket( ): RoomSocket { const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/room?roomId=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}` - const ws = new WebSocket(wsUrl) + /** 当前 WebSocket 实例 */ + let ws: WebSocket | null = null + /** 当前重试次数 */ + let retryCount = 0 + /** 是否主动关闭(主动关闭不重连) */ + let intentionalClose = false + /** 重连定时器 ID,用于组件卸载时取消 */ + let reconnectTimer: ReturnType | null = null - ws.onopen = () => { - // eslint-disable-next-line no-console - console.log('[WS] 房间连接已建立, roomId=', roomId) - callbacks.onOpen?.() - } + /** 建立 WebSocket 连接 */ + function connect(): void { + // 如果已有旧的重连定时器,取消它 + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } - ws.onclose = (event) => { - // eslint-disable-next-line no-console - console.log('[WS] 房间连接已关闭, roomId=', roomId, 'code=', event.code) - callbacks.onClose?.(event) - } + ws = new WebSocket(wsUrl) - ws.onerror = (event) => { - console.error('[WS] 房间连接错误, roomId=', roomId, event) - callbacks.onError?.(event) - } + ws.onopen = () => { + // eslint-disable-next-line no-console + console.log('[WS] 房间连接已建立, roomId=', roomId) + if (retryCount > 0) { + // 这是重连成功 + // eslint-disable-next-line no-console + console.log('[WS] 重连成功, roomId=', roomId, '重试次数=', retryCount) + retryCount = 0 + callbacks.onReconnect?.() + } else { + callbacks.onOpen?.() + } + } - ws.onmessage = (event) => { - try { - const message: WsMessage = JSON.parse(event.data as string) - callbacks.onMessage?.(message) - } catch (e) { - console.error('[WS] 消息解析失败:', e) + ws.onclose = (event) => { + // 主动关闭,不重连 + if (intentionalClose) { + // eslint-disable-next-line no-console + console.log('[WS] 主动关闭连接, roomId=', roomId, 'code=', event.code) + callbacks.onClose?.(event) + return + } + + // 已达最大重试次数 + if (retryCount >= RECONNECT_MAX_RETRIES) { + // eslint-disable-next-line no-console + console.log('[WS] 重连次数耗尽, roomId=', roomId, 'retries=', retryCount) + callbacks.onReconnectFailed?.() + callbacks.onClose?.(event) + return + } + + // 指数退避重连 + retryCount++ + const delay = Math.min( + RECONNECT_BASE_DELAY * Math.pow(2, retryCount - 1), + RECONNECT_MAX_DELAY + ) + // eslint-disable-next-line no-console + console.log( + `[WS] 连接断开,${delay / 1000}s 后重连 (${retryCount}/${RECONNECT_MAX_RETRIES}), roomId=`, + roomId + ) + + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, delay) + } + + ws.onerror = (event) => { + console.error('[WS] 连接错误, roomId=', roomId, event) + callbacks.onError?.(event) + } + + ws.onmessage = (event) => { + try { + const message: WsMessage = JSON.parse(event.data as string) + callbacks.onMessage?.(message) + } catch (e) { + console.error('[WS] 消息解析失败:', e) + } } } + // 初始连接 + connect() + return { send(type: string, data?: unknown) { - if (ws.readyState === WebSocket.OPEN) { + if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type, data })) } }, close() { - ws.close(1000, '用户离开') + intentionalClose = true + // 取消进行中的重连定时器 + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (ws) { + ws.close(1000, '用户离开') + ws = null + } }, isOpen() { - return ws.readyState === WebSocket.OPEN + return ws?.readyState === WebSocket.OPEN } } } diff --git a/src/components/baseLayouts/Lobby.vue b/src/components/baseLayouts/Lobby.vue index bfc5748..6b723a3 100644 --- a/src/components/baseLayouts/Lobby.vue +++ b/src/components/baseLayouts/Lobby.vue @@ -51,8 +51,9 @@ import { useRouter } from 'vue-router' import type { RoomItem } from '@/api/modules/game' import { useBaseLayoutStore } from '@/stores' import { storeToRefs } from 'pinia' -import { getRoomList } from '@/api/modules/game' +import { getRoomList, joinRoom } from '@/api/modules/game' import { sseBus } from '@/stores/sseBus' +import { ElMessage } from 'element-plus' /** 状态筛选选项 */ const statusFilters = [ @@ -129,17 +130,26 @@ const filteredRooms = computed(() => { return list }) -/** 加入房间 */ -function handleJoin(roomId: string) { - router.push(`/room/${roomId}`) +/** 加入房间(需先调用后端 join API 成为玩家) */ +async function handleJoin(roomId: string) { + try { + await joinRoom({ roomId }) + router.push(`/room/${roomId}`) + } catch (e: unknown) { + // 后端业务错误通过 ApiResponse.message 传递,需兼容多种 reject 类型 + const msg = + (e as { message?: string })?.message || + (e instanceof Error ? e.message : '加入房间失败,请稍后重试') + ElMessage.error(msg) + } } -/** 加入指定座位 */ -function handleJoinSeat(roomId: string, seatIndex: number) { - router.push({ path: `/room/${roomId}`, query: { seat: String(seatIndex) } }) +/** 加入指定座位(后端自动分配座位,调用 join API 即可) */ +async function handleJoinSeat(roomId: string, _seatIndex: number) { + await handleJoin(roomId) } -/** 观战 */ +/** 观战(不调用 join API,仅建立 WebSocket 连接接收对局数据) */ function handleSpectate(roomId: string) { router.push(`/room/${roomId}`) } diff --git a/src/views/game/RoomPage.vue b/src/views/game/RoomPage.vue index ce6aa31..b66cdba 100644 --- a/src/views/game/RoomPage.vue +++ b/src/views/game/RoomPage.vue @@ -76,12 +76,26 @@ onMounted(async () => { const res = await getRoomDetail(roomId) roomStore.initFromDetail(res.data, userId) - // 2. 建立 WebSocket 连接 + // 2. 建立 WebSocket 连接(含自动重连) const token = accountStore.accessToken socket = createRoomSocket(roomId, token, { onMessage: handleWsMessage, - onClose: () => { - // 连接关闭,提示并返回大厅 + /** + * 重连成功 → 重新拉取房间详情恢复完整状态(棋盘、轮次、玩家列表等) + */ + onReconnect: async () => { + try { + const res = await getRoomDetail(roomId) + roomStore.initFromDetail(res.data, userId) + } catch { + // 重连后拉取房间状态失败(房间可能已结束),返回大厅 + router.push('/') + } + }, + /** + * 重连次数耗尽 → 返回大厅 + */ + onReconnectFailed: () => { router.push('/') } })