fix: 暂存
This commit is contained in:
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ export function createRoomSocket(
|
||||
let intentionalClose = false
|
||||
/** 重连定时器 ID,用于组件卸载时取消 */
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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) {
|
||||
// 仅 OPEN 状态可安全关闭,CONNECTING 时由 onopen 处理
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1000, '用户离开')
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
},
|
||||
isOpen() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>('roomSocket', null)!
|
||||
const socket = inject<RoomSocket>('roomSocket')!
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(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'
|
||||
|
||||
@@ -63,7 +63,7 @@ const { players, currentUserId } = storeToRefs(store)
|
||||
const router = useRouter()
|
||||
|
||||
/** 通过 inject 获取 WS 连接(父组件 provide) */
|
||||
const socket = inject<RoomSocket>('roomSocket', null)!
|
||||
const socket = inject<RoomSocket>('roomSocket')!
|
||||
|
||||
/** 当前用户是否已准备 */
|
||||
const isReady = computed(() => {
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
<div class="room-chat">
|
||||
<!-- 消息列表 -->
|
||||
<div ref="messagesContainer" class="room-chat__messages">
|
||||
<div v-for="(msg, idx) in store.messages" :key="idx" class="room-chat__msg">
|
||||
<span class="room-chat__msg-user">{{ msg.userId === store.currentUserId ? '我' : getUserName(msg.userId) }}</span>
|
||||
<span class="room-chat__msg-text">{{ msg.content }}</span>
|
||||
<div
|
||||
v-for="(msg, idx) in store.messages"
|
||||
:key="idx"
|
||||
class="room-chat__msg"
|
||||
: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 v-if="store.messages.length === 0" class="room-chat__empty">
|
||||
暂无消息
|
||||
<div class="room-chat__msg-bubble">{{ msg.content }}</div>
|
||||
</div>
|
||||
<div v-if="store.messages.length === 0" class="room-chat__empty">暂无消息</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
@@ -17,9 +25,15 @@
|
||||
v-model="input"
|
||||
class="room-chat__input"
|
||||
placeholder="输入消息..."
|
||||
maxlength="200"
|
||||
:maxlength="CHAT_MAX_LENGTH"
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>('roomSocket', null)
|
||||
const socket = inject<RoomSocket>('roomSocket')
|
||||
|
||||
/** 发送消息(空安全,socket 尚未建立时静默丢弃) */
|
||||
function safeSend(type: string, data?: unknown) {
|
||||
@@ -48,19 +65,39 @@ const messagesContainer = ref<HTMLElement>()
|
||||
/**
|
||||
* 监听消息列表变化,自动滚动到底部
|
||||
*/
|
||||
watch(() => messages.value.length, async () => {
|
||||
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}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user