fix: 暂存

This commit is contained in:
2026-06-23 14:32:00 +08:00
parent 0fae4feadb
commit 7c12697579
6 changed files with 215 additions and 38 deletions

View File

@@ -6,16 +6,16 @@ import { useAccountStore } from '@/stores/modules/account'
/** /**
* SSE 连接管理 composable基于 @microsoft/fetch-event-source * SSE 连接管理 composable基于 @microsoft/fetch-event-source
* *
* 登录后调用 connect() 建立全局唯一的 SSE 长连接。 * 设计原则:
* 收到服务端事件后解析 JSON 并通过 sseBus 分发给订阅者。 * - 全局唯一:同一时间只有一条活跃的 SSE 连接
* * - token 驱动:仅 accessToken 变更时才断开重连
* 鉴权:通过 fetch-event-source 的 headers 传 saToken和 axios 走同一套机制。 * - 防重复:已连接(相同 token时 connect() 直接跳过
* 401 时精确读取 err.response.status 触发 token 刷新,刷新成功自动重连。 * - 所有业务变更通过 sseBus 事件总线推送,各组件自行订阅
* *
* 使用示例: * 使用示例:
* ```ts * ```ts
* const { isConnected, connect, disconnect } = useSse() * const { isConnected, connect, disconnect } = useSse()
* watch(() => userStore.accessToken, (v) => v ? connect() : disconnect()) * watch(() => accountStore.accessToken, (v) => v ? connect() : disconnect())
* ``` * ```
*/ */
export function useSse() { export function useSse() {
@@ -23,6 +23,10 @@ export function useSse() {
const isConnected = ref(false) const isConnected = ref(false)
/** 用于取消 inflight 连接的控制器 */ /** 用于取消 inflight 连接的控制器 */
let abortController: AbortController | null = null let abortController: AbortController | null = null
/** 当前连接使用的 token用于判断是否需要重连 */
let currentToken: string | null = null
/** 是否正在建立连接(防并发) */
let connecting = false
/** 当前连接的重试计数 */ /** 当前连接的重试计数 */
let retryCount = 0 let retryCount = 0
/** 是否正在刷新 token防并发 */ /** 是否正在刷新 token防并发 */
@@ -33,6 +37,8 @@ export function useSse() {
* *
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。 * 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。 * 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。
*
* 防重复:如果已用相同 token 连接或正在连接中,直接跳过。
*/ */
async function connect(): Promise<void> { async function connect(): Promise<void> {
const accountStore = useAccountStore() const accountStore = useAccountStore()
@@ -42,9 +48,16 @@ export function useSse() {
return return
} }
// 取消上一次连接 // 相同 token 已连接或正在连接中,跳过
if (currentToken === token && (isConnected.value || connecting)) {
return
}
// 取消旧连接
abort() abort()
connecting = true
currentToken = token
abortController = new AbortController() abortController = new AbortController()
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe // Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe
@@ -54,6 +67,7 @@ export function useSse() {
/** 连接建立 */ /** 连接建立 */
async onopen() { async onopen() {
connecting = false
isConnected.value = true isConnected.value = true
retryCount = 0 retryCount = 0
console.log('[SSE] 连接已建立') console.log('[SSE] 连接已建立')
@@ -75,6 +89,7 @@ export function useSse() {
/** 连接关闭 */ /** 连接关闭 */
onclose() { onclose() {
connecting = false
isConnected.value = false isConnected.value = false
console.log('[SSE] 连接已关闭') console.log('[SSE] 连接已关闭')
}, },
@@ -87,6 +102,7 @@ export function useSse() {
* 其他错误 → 返回重试延迟ms库自动排队重连 * 其他错误 → 返回重试延迟ms库自动排队重连
*/ */
onerror(err) { onerror(err) {
connecting = false
isConnected.value = false isConnected.value = false
// 鉴权失败 → 刷新 token异步 fire-and-forgetonerror 本身保持同步) // 鉴权失败 → 刷新 token异步 fire-and-forgetonerror 本身保持同步)
@@ -135,7 +151,7 @@ export function useSse() {
throw err // 超过阈值,停止重连 throw err // 超过阈值,停止重连
} }
// 返回重试间隔ms指数退避3s, 6s, 12s, 24s // 返回重试间隔ms指数退避3s, 6s, 12s, 24s,最大 30s
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000) const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`) console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
return delay return delay
@@ -151,7 +167,9 @@ export function useSse() {
abortController.abort() abortController.abort()
abortController = null abortController = null
} }
connecting = false
isConnected.value = false isConnected.value = false
currentToken = null
retryCount = 0 retryCount = 0
} }

View File

@@ -56,6 +56,10 @@ export function createRoomSocket(
let intentionalClose = false let intentionalClose = false
/** 重连定时器 ID用于组件卸载时取消 */ /** 重连定时器 ID用于组件卸载时取消 */
let reconnectTimer: ReturnType<typeof setTimeout> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 消息发送队列(连接未就绪时暂存) */
let messageQueue: Array<{ type: string; data?: unknown }> = []
/** 最大排队消息数 */
const MAX_QUEUE_SIZE = 50
/** 建立 WebSocket 连接 */ /** 建立 WebSocket 连接 */
function connect(): void { function connect(): void {
@@ -68,6 +72,12 @@ export function createRoomSocket(
ws = new WebSocket(wsUrl) ws = new WebSocket(wsUrl)
ws.onopen = () => { ws.onopen = () => {
// 用户已离开,直接关闭连接
if (intentionalClose) {
ws?.close(1000, '用户已离开')
return
}
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[WS] 房间连接已建立, roomId=', roomId) console.log('[WS] 房间连接已建立, roomId=', roomId)
if (retryCount > 0) { if (retryCount > 0) {
@@ -79,6 +89,9 @@ export function createRoomSocket(
} else { } else {
callbacks.onOpen?.() callbacks.onOpen?.()
} }
// 刷新排队中的消息
flushQueue()
} }
ws.onclose = (event) => { ws.onclose = (event) => {
@@ -135,10 +148,31 @@ export function createRoomSocket(
// 初始连接 // 初始连接
connect() 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 { return {
send(type: string, data?: unknown) { send(type: string, data?: unknown) {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, data })) 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() { close() {
@@ -149,8 +183,10 @@ export function createRoomSocket(
reconnectTimer = null reconnectTimer = null
} }
if (ws) { if (ws) {
ws.close(1000, '用户离开') // 仅 OPEN 状态可安全关闭CONNECTING 时由 onopen 处理
ws = null if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, '用户离开')
}
} }
}, },
isOpen() { isOpen() {

View File

@@ -101,7 +101,20 @@ export const useRoomStore = defineStore('room', () => {
} }
/** 添加聊天消息 */ /** 添加聊天消息 */
/** 添加聊天消息(自动去重:同用户同内容 2 秒内只保留一条) */
function addMessage(msg: ChatMessage) { 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) messages.value.push(msg)
} }

View File

@@ -29,7 +29,7 @@ const canvasSize = PADDING * 2 + CELL_SIZE * (BOARD_SIZE - 1)
const store = useRoomStore() const store = useRoomStore()
const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store) const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store)
const socket = inject<RoomSocket>('roomSocket', null)! const socket = inject<RoomSocket>('roomSocket')!
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
const hoverPos = ref<{ row: number; col: number } | null>(null) const hoverPos = ref<{ row: number; col: number } | null>(null)
@@ -101,8 +101,9 @@ function drawBoard() {
if (board) { if (board) {
for (let r = 0; r < BOARD_SIZE; r++) { for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) { for (let c = 0; c < BOARD_SIZE; c++) {
if (board[r] && board[r][c]) { const stone = board[r]?.[c]
drawStone(ctx, r, c, board[r][c] === 1 ? '#1a1a1a' : '#f5f5f5') if (stone) {
drawStone(ctx, r, c, stone === 1 ? '#1a1a1a' : '#f5f5f5')
} }
} }
} }
@@ -123,7 +124,7 @@ function drawBoard() {
// ---- 最后一手标记(红色圆圈) ---- // ---- 最后一手标记(红色圆圈) ----
const moves = store.moves const moves = store.moves
if (moves.length > 0) { if (moves.length > 0) {
const lastMove = moves[moves.length - 1] const lastMove = moves[moves.length - 1]!
const x = PADDING + lastMove.col * CELL_SIZE const x = PADDING + lastMove.col * CELL_SIZE
const y = PADDING + lastMove.row * CELL_SIZE const y = PADDING + lastMove.row * CELL_SIZE
ctx.strokeStyle = '#E74C3C' ctx.strokeStyle = '#E74C3C'

View File

@@ -63,7 +63,7 @@ const { players, currentUserId } = storeToRefs(store)
const router = useRouter() const router = useRouter()
/** 通过 inject 获取 WS 连接(父组件 provide */ /** 通过 inject 获取 WS 连接(父组件 provide */
const socket = inject<RoomSocket>('roomSocket', null)! const socket = inject<RoomSocket>('roomSocket')!
/** 当前用户是否已准备 */ /** 当前用户是否已准备 */
const isReady = computed(() => { const isReady = computed(() => {

View File

@@ -2,13 +2,21 @@
<div class="room-chat"> <div class="room-chat">
<!-- 消息列表 --> <!-- 消息列表 -->
<div ref="messagesContainer" class="room-chat__messages"> <div ref="messagesContainer" class="room-chat__messages">
<div v-for="(msg, idx) in store.messages" :key="idx" class="room-chat__msg"> <div
<span class="room-chat__msg-user">{{ msg.userId === store.currentUserId ? '我' : getUserName(msg.userId) }}</span> v-for="(msg, idx) in store.messages"
<span class="room-chat__msg-text">{{ msg.content }}</span> :key="idx"
</div> class="room-chat__msg"
<div v-if="store.messages.length === 0" class="room-chat__empty"> :class="{ 'room-chat__msg--self': msg.userId === store.currentUserId }"
暂无消息 >
<div class="room-chat__msg-meta">
<span class="room-chat__msg-user">{{
msg.userId === store.currentUserId ? '我' : getUserName(msg.userId)
}}</span>
<span class="room-chat__msg-time">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="room-chat__msg-bubble">{{ msg.content }}</div>
</div> </div>
<div v-if="store.messages.length === 0" class="room-chat__empty">暂无消息</div>
</div> </div>
<!-- 输入区 --> <!-- 输入区 -->
@@ -17,9 +25,15 @@
v-model="input" v-model="input"
class="room-chat__input" class="room-chat__input"
placeholder="输入消息..." placeholder="输入消息..."
maxlength="200" :maxlength="CHAT_MAX_LENGTH"
@keyup.enter="sendMessage" @keyup.enter="sendMessage"
/> />
<span
class="room-chat__char-count"
:class="{ 'room-chat__char-count--warn': input.length > CHAT_MAX_LENGTH * 0.8 }"
>
{{ input.length }}/{{ CHAT_MAX_LENGTH }}
</span>
<button class="room-chat__send-btn" @click="sendMessage">发送</button> <button class="room-chat__send-btn" @click="sendMessage">发送</button>
</div> </div>
</div> </div>
@@ -31,9 +45,12 @@ import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { RoomSocket } from '@/common/websocket/roomSocket' import type { RoomSocket } from '@/common/websocket/roomSocket'
/** 聊天最大字符数(与后端 CHAT_MAX_LENGTH 保持一致) */
const CHAT_MAX_LENGTH = 500
const store = useRoomStore() const store = useRoomStore()
const { messages, players } = storeToRefs(store) const { messages, players } = storeToRefs(store)
const socket = inject<RoomSocket>('roomSocket', null) const socket = inject<RoomSocket>('roomSocket')
/** 发送消息空安全socket 尚未建立时静默丢弃) */ /** 发送消息空安全socket 尚未建立时静默丢弃) */
function safeSend(type: string, data?: unknown) { function safeSend(type: string, data?: unknown) {
@@ -48,19 +65,39 @@ const messagesContainer = ref<HTMLElement>()
/** /**
* 监听消息列表变化,自动滚动到底部 * 监听消息列表变化,自动滚动到底部
*/ */
watch(() => messages.value.length, async () => { watch(
await nextTick() () => messages.value.length,
async () => {
await nextTick()
scrollToBottom()
}
)
/**
* 滚动到消息列表底部
*/
function scrollToBottom() {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
} }
}) }
/** 发送聊天消息 */ /** 发送聊天消息(乐观更新:先显示在本地,再通过 WS 发送) */
function sendMessage() { function sendMessage() {
const content = input.value.trim() const content = input.value.trim()
if (!content) return if (!content || content.length > CHAT_MAX_LENGTH) return
safeSend('chat', { content })
// 乐观更新:立即添加到本地消息列表
const localMsg = {
userId: store.currentUserId,
content,
timestamp: Date.now()
}
store.addMessage(localMsg)
input.value = '' input.value = ''
// 通过 WS 发送,广播回来自动去重
safeSend('chat', { content })
} }
/** /**
@@ -70,7 +107,20 @@ function sendMessage() {
*/ */
function getUserName(userId: string): string { function getUserName(userId: string): string {
const player = players.value.find((p) => p.userId === userId) 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}`
} }
</script> </script>
@@ -87,22 +137,63 @@ function getUserName(userId: string): string {
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
} }
.room-chat__msg { .room-chat__msg {
font-size: 13px; display: flex;
line-height: 1.5; 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 { .room-chat__msg-user {
font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--color-primary); color: var(--text-secondary);
margin-right: 6px;
} }
.room-chat__msg-text { .room-chat__msg-time {
color: var(--text-primary); 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 { .room-chat__empty {
@@ -114,7 +205,8 @@ function getUserName(userId: string): string {
.room-chat__input-area { .room-chat__input-area {
display: flex; display: flex;
gap: 8px; align-items: center;
gap: 6px;
padding: 10px; padding: 10px;
border-top: 1px solid var(--border-light); 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 { .room-chat__send-btn {
padding: 6px 14px; padding: 6px 14px;
border: none; border: none;
@@ -142,9 +246,14 @@ function getUserName(userId: string): string {
color: var(--text-inverse); color: var(--text-inverse);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
&:hover { &:hover {
background-color: var(--color-primary-hover); background-color: var(--color-primary-hover);
} }
&:active {
transform: scale(0.97);
}
} }
</style> </style>