diff --git a/src/common/hooks/useSse.ts b/src/common/hooks/useSse.ts index 866e2e6..3a203e3 100644 --- a/src/common/hooks/useSse.ts +++ b/src/common/hooks/useSse.ts @@ -6,16 +6,16 @@ import { useAccountStore } from '@/stores/modules/account' /** * SSE 连接管理 composable(基于 @microsoft/fetch-event-source) * - * 登录后调用 connect() 建立全局唯一的 SSE 长连接。 - * 收到服务端事件后解析 JSON 并通过 sseBus 分发给订阅者。 - * - * 鉴权:通过 fetch-event-source 的 headers 传 saToken,和 axios 走同一套机制。 - * 401 时精确读取 err.response.status 触发 token 刷新,刷新成功自动重连。 + * 设计原则: + * - 全局唯一:同一时间只有一条活跃的 SSE 连接 + * - token 驱动:仅 accessToken 变更时才断开重连 + * - 防重复:已连接(相同 token)时 connect() 直接跳过 + * - 所有业务变更通过 sseBus 事件总线推送,各组件自行订阅 * * 使用示例: * ```ts * const { isConnected, connect, disconnect } = useSse() - * watch(() => userStore.accessToken, (v) => v ? connect() : disconnect()) + * watch(() => accountStore.accessToken, (v) => v ? connect() : disconnect()) * ``` */ export function useSse() { @@ -23,6 +23,10 @@ export function useSse() { const isConnected = ref(false) /** 用于取消 inflight 连接的控制器 */ let abortController: AbortController | null = null + /** 当前连接使用的 token(用于判断是否需要重连) */ + let currentToken: string | null = null + /** 是否正在建立连接(防并发) */ + let connecting = false /** 当前连接的重试计数 */ let retryCount = 0 /** 是否正在刷新 token(防并发) */ @@ -33,6 +37,8 @@ export function useSse() { * * 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。 * 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。 + * + * 防重复:如果已用相同 token 连接或正在连接中,直接跳过。 */ async function connect(): Promise { const accountStore = useAccountStore() @@ -42,9 +48,16 @@ export function useSse() { return } - // 取消上一次连接 + // 相同 token 已连接或正在连接中,跳过 + if (currentToken === token && (isConnected.value || connecting)) { + return + } + + // 取消旧连接 abort() + connecting = true + currentToken = token abortController = new AbortController() // Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe @@ -54,6 +67,7 @@ export function useSse() { /** 连接建立 */ async onopen() { + connecting = false isConnected.value = true retryCount = 0 console.log('[SSE] 连接已建立') @@ -75,6 +89,7 @@ export function useSse() { /** 连接关闭 */ onclose() { + connecting = false isConnected.value = false console.log('[SSE] 连接已关闭') }, @@ -87,6 +102,7 @@ export function useSse() { * 其他错误 → 返回重试延迟(ms),库自动排队重连 */ onerror(err) { + connecting = false isConnected.value = false // 鉴权失败 → 刷新 token(异步 fire-and-forget,onerror 本身保持同步) @@ -135,7 +151,7 @@ export function useSse() { throw err // 超过阈值,停止重连 } - // 返回重试间隔(ms),指数退避:3s, 6s, 12s, 24s + // 返回重试间隔(ms),指数退避:3s, 6s, 12s, 24s,最大 30s const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000) console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`) return delay @@ -151,7 +167,9 @@ export function useSse() { abortController.abort() abortController = null } + connecting = false isConnected.value = false + currentToken = null retryCount = 0 } diff --git a/src/common/websocket/roomSocket.ts b/src/common/websocket/roomSocket.ts index 28d1952..bafcf2f 100644 --- a/src/common/websocket/roomSocket.ts +++ b/src/common/websocket/roomSocket.ts @@ -56,6 +56,10 @@ export function createRoomSocket( let intentionalClose = false /** 重连定时器 ID,用于组件卸载时取消 */ let reconnectTimer: ReturnType | null = null + /** 消息发送队列(连接未就绪时暂存) */ + let messageQueue: Array<{ type: string; data?: unknown }> = [] + /** 最大排队消息数 */ + const MAX_QUEUE_SIZE = 50 /** 建立 WebSocket 连接 */ function connect(): void { @@ -68,6 +72,12 @@ export function createRoomSocket( ws = new WebSocket(wsUrl) ws.onopen = () => { + // 用户已离开,直接关闭连接 + if (intentionalClose) { + ws?.close(1000, '用户已离开') + return + } + // eslint-disable-next-line no-console console.log('[WS] 房间连接已建立, roomId=', roomId) if (retryCount > 0) { @@ -79,6 +89,9 @@ export function createRoomSocket( } else { callbacks.onOpen?.() } + + // 刷新排队中的消息 + flushQueue() } ws.onclose = (event) => { @@ -135,10 +148,31 @@ export function createRoomSocket( // 初始连接 connect() + /** 刷新消息发送队列 */ + function flushQueue(): void { + if (messageQueue.length === 0) return + // eslint-disable-next-line no-console + console.log(`[WS] 刷新排队消息, count=${messageQueue.length}`) + const pending = messageQueue + messageQueue = [] + for (const msg of pending) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)) + } + } + } + return { send(type: string, data?: unknown) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type, data })) + } else if (ws && ws.readyState === WebSocket.CONNECTING) { + // 连接尚未就绪,排队等待 + if (messageQueue.length < MAX_QUEUE_SIZE) { + messageQueue.push({ type, data }) + } + } else { + console.warn(`[WS] 消息未发送(连接未就绪), type=${type}, readyState=${ws?.readyState ?? 'null'}`) } }, close() { @@ -149,8 +183,10 @@ export function createRoomSocket( reconnectTimer = null } if (ws) { - ws.close(1000, '用户离开') - ws = null + // 仅 OPEN 状态可安全关闭,CONNECTING 时由 onopen 处理 + if (ws.readyState === WebSocket.OPEN) { + ws.close(1000, '用户离开') + } } }, isOpen() { diff --git a/src/stores/modules/room.ts b/src/stores/modules/room.ts index 70ae6a5..face7ba 100644 --- a/src/stores/modules/room.ts +++ b/src/stores/modules/room.ts @@ -101,7 +101,20 @@ export const useRoomStore = defineStore('room', () => { } /** 添加聊天消息 */ + /** 添加聊天消息(自动去重:同用户同内容 2 秒内只保留一条) */ function addMessage(msg: ChatMessage) { + // 去重:检查最后一条消息是否与即将添加的相同 + const last = messages.value[messages.value.length - 1] + if ( + last && + last.userId === msg.userId && + last.content === msg.content && + Math.abs(last.timestamp - msg.timestamp) < 2000 + ) { + // 用后端的时间戳更新(更准确),其它不变 + last.timestamp = msg.timestamp + return + } messages.value.push(msg) } diff --git a/src/views/game/components/GomokuBoard.vue b/src/views/game/components/GomokuBoard.vue index 5209e9a..667a728 100644 --- a/src/views/game/components/GomokuBoard.vue +++ b/src/views/game/components/GomokuBoard.vue @@ -29,7 +29,7 @@ const canvasSize = PADDING * 2 + CELL_SIZE * (BOARD_SIZE - 1) const store = useRoomStore() const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store) -const socket = inject('roomSocket', null)! +const socket = inject('roomSocket')! const canvasRef = ref(null) const hoverPos = ref<{ row: number; col: number } | null>(null) @@ -101,8 +101,9 @@ function drawBoard() { if (board) { for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { - if (board[r] && board[r][c]) { - drawStone(ctx, r, c, board[r][c] === 1 ? '#1a1a1a' : '#f5f5f5') + const stone = board[r]?.[c] + if (stone) { + drawStone(ctx, r, c, stone === 1 ? '#1a1a1a' : '#f5f5f5') } } } @@ -123,7 +124,7 @@ function drawBoard() { // ---- 最后一手标记(红色圆圈) ---- const moves = store.moves if (moves.length > 0) { - const lastMove = moves[moves.length - 1] + const lastMove = moves[moves.length - 1]! const x = PADDING + lastMove.col * CELL_SIZE const y = PADDING + lastMove.row * CELL_SIZE ctx.strokeStyle = '#E74C3C' diff --git a/src/views/game/components/RoomActions.vue b/src/views/game/components/RoomActions.vue index c047e51..7749728 100644 --- a/src/views/game/components/RoomActions.vue +++ b/src/views/game/components/RoomActions.vue @@ -63,7 +63,7 @@ const { players, currentUserId } = storeToRefs(store) const router = useRouter() /** 通过 inject 获取 WS 连接(父组件 provide) */ -const socket = inject('roomSocket', null)! +const socket = inject('roomSocket')! /** 当前用户是否已准备 */ const isReady = computed(() => { diff --git a/src/views/game/components/RoomChat.vue b/src/views/game/components/RoomChat.vue index a8abf21..d9589db 100644 --- a/src/views/game/components/RoomChat.vue +++ b/src/views/game/components/RoomChat.vue @@ -2,13 +2,21 @@
-
- {{ msg.userId === store.currentUserId ? '我' : getUserName(msg.userId) }} - {{ msg.content }} -
-
- 暂无消息 +
+
+ {{ + msg.userId === store.currentUserId ? '我' : getUserName(msg.userId) + }} + {{ formatTime(msg.timestamp) }} +
+
{{ msg.content }}
+
暂无消息
@@ -17,9 +25,15 @@ v-model="input" class="room-chat__input" placeholder="输入消息..." - maxlength="200" + :maxlength="CHAT_MAX_LENGTH" @keyup.enter="sendMessage" /> + + {{ input.length }}/{{ CHAT_MAX_LENGTH }} +
@@ -31,9 +45,12 @@ import { useRoomStore } from '@/stores' import { storeToRefs } from 'pinia' import type { RoomSocket } from '@/common/websocket/roomSocket' +/** 聊天最大字符数(与后端 CHAT_MAX_LENGTH 保持一致) */ +const CHAT_MAX_LENGTH = 500 + const store = useRoomStore() const { messages, players } = storeToRefs(store) -const socket = inject('roomSocket', null) +const socket = inject('roomSocket') /** 发送消息(空安全,socket 尚未建立时静默丢弃) */ function safeSend(type: string, data?: unknown) { @@ -48,19 +65,39 @@ const messagesContainer = ref() /** * 监听消息列表变化,自动滚动到底部 */ -watch(() => messages.value.length, async () => { - await nextTick() +watch( + () => messages.value.length, + async () => { + await nextTick() + scrollToBottom() + } +) + +/** + * 滚动到消息列表底部 + */ +function scrollToBottom() { if (messagesContainer.value) { messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight } -}) +} -/** 发送聊天消息 */ +/** 发送聊天消息(乐观更新:先显示在本地,再通过 WS 发送) */ function sendMessage() { const content = input.value.trim() - if (!content) return - safeSend('chat', { content }) + if (!content || content.length > CHAT_MAX_LENGTH) return + + // 乐观更新:立即添加到本地消息列表 + const localMsg = { + userId: store.currentUserId, + content, + timestamp: Date.now() + } + store.addMessage(localMsg) input.value = '' + + // 通过 WS 发送,广播回来自动去重 + safeSend('chat', { content }) } /** @@ -70,7 +107,20 @@ function sendMessage() { */ function getUserName(userId: string): string { const player = players.value.find((p) => p.userId === userId) - return player?.nickname ?? '未知用户' + return player?.nickname ?? '观战者' +} + +/** + * 格式化时间戳为 HH:mm + * @param ts 毫秒时间戳 + * @returns 格式化后的时间字符串 + */ +function formatTime(ts: number): string { + if (!ts) return '' + const d = new Date(ts) + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + return `${hh}:${mm}` } @@ -87,22 +137,63 @@ function getUserName(userId: string): string { overflow-y: auto; display: flex; flex-direction: column; - gap: 6px; + gap: 8px; } .room-chat__msg { - font-size: 13px; - line-height: 1.5; + display: flex; + flex-direction: column; + gap: 2px; + max-width: 85%; + + &--self { + align-self: flex-end; + + .room-chat__msg-bubble { + background-color: var(--color-primary); + color: var(--text-inverse); + border-radius: 10px 10px 2px 10px; + } + + .room-chat__msg-meta { + flex-direction: row-reverse; + } + } + + &:not(&--self) { + align-self: flex-start; + + .room-chat__msg-bubble { + background-color: var(--bg-surface-hover); + color: var(--text-primary); + border-radius: 10px 10px 10px 2px; + } + } +} + +.room-chat__msg-meta { + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px; } .room-chat__msg-user { + font-size: 11px; font-weight: 600; - color: var(--color-primary); - margin-right: 6px; + color: var(--text-secondary); } -.room-chat__msg-text { - color: var(--text-primary); +.room-chat__msg-time { + font-size: 10px; + color: var(--text-tertiary); +} + +.room-chat__msg-bubble { + padding: 6px 10px; + font-size: 13px; + line-height: 1.5; + word-break: break-word; } .room-chat__empty { @@ -114,7 +205,8 @@ function getUserName(userId: string): string { .room-chat__input-area { display: flex; - gap: 8px; + align-items: center; + gap: 6px; padding: 10px; border-top: 1px solid var(--border-light); } @@ -134,6 +226,18 @@ function getUserName(userId: string): string { } } +.room-chat__char-count { + font-size: 11px; + color: var(--text-tertiary); + min-width: 42px; + text-align: right; + transition: color 0.15s ease; + + &--warn { + color: var(--color-warning); + } +} + .room-chat__send-btn { padding: 6px 14px; border: none; @@ -142,9 +246,14 @@ function getUserName(userId: string): string { color: var(--text-inverse); font-size: 13px; cursor: pointer; + white-space: nowrap; &:hover { background-color: var(--color-primary-hover); } + + &:active { + transform: scale(0.97); + } }