diff --git a/src/common/hooks/useSse.ts b/src/common/hooks/useSse.ts new file mode 100644 index 0000000..ee9381c --- /dev/null +++ b/src/common/hooks/useSse.ts @@ -0,0 +1,122 @@ +import { ref } from 'vue' +import { sseBus } from '@/stores/sseBus' +import { useAccountStore } from '@/stores/modules/account' + +/** + * SSE 连接管理 composable + * + * 登录后调用 connect() 建立全局唯一的 SSE 长连接。 + * 收到服务端事件后解析 JSON 并通过 sseBus 分发给订阅者。 + * EventSource 原生自动重连,重试 3 次失败后通知用户刷新。 + * + * 使用示例: + * ```ts + * const { isConnected, connect, disconnect } = useSse() + * watch(() => userStore.accessToken, (v) => v ? connect() : disconnect()) + * ``` + */ +export function useSse() { + /** SSE 连接是否已建立 */ + const isConnected = ref(false) + /** 重试次数(EventSource 每次 onerror 自增,open 后归零) */ + const retryCount = ref(0) + /** EventSource 实例 */ + let es: EventSource | null = null + + /** + * 建立 SSE 连接 + * + * 从 Pinia store 获取 accessToken 拼接到 URL 查询参数。 + * EventSource 不支持自定义请求头,Sa-Token 支持从 ?token= 参数读取。 + */ + function connect(): void { + const accountStore = useAccountStore() + const token = accountStore.accessToken + if (!token) { + console.warn('[SSE] 无 accessToken,跳过连接') + return + } + + // 已有连接则先关闭 + if (es) { + es.close() + } + + // Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe + const url = `/api/sse/subscribe?token=${encodeURIComponent(token)}` + es = new EventSource(url) + + es.onopen = () => { + isConnected.value = true + retryCount.value = 0 + console.log('[SSE] 连接已建立') + } + + // 服务端定义的业务事件类型 + const eventTypes = [ + 'connected', + 'room_created', + 'room_updated', + 'room_removed', + 'kicked', + 'game_started', + 'balance_chg' + ] + + for (const type of eventTypes) { + es.addEventListener(type, (e: Event) => { + const msg = e as MessageEvent + // heartbeat 和 connected 确认消息可能为空数据 + if (!msg.data) return + + try { + const data = JSON.parse(msg.data) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sseBus.emit(type as any, data) + } catch (err) { + console.warn('[SSE] 事件数据解析失败:', type, msg.data, err) + } + }) + } + + // heartbeat 事件(服务端保活,前端忽略即可) + es.addEventListener('heartbeat', () => { + // NOOP: 仅用于保活 + }) + + // 连接错误处理(EventSource 会自动重连) + es.onerror = () => { + isConnected.value = false + retryCount.value++ + + if (retryCount.value >= 3) { + sseBus.emit('error', { + message: '实时连接异常,请刷新页面', + retryCount: retryCount.value + }) + } + } + } + + /** + * 断开 SSE 连接 + * + * 在用户登出时调用。 + */ + function disconnect(): void { + if (es) { + es.close() + es = null + } + isConnected.value = false + retryCount.value = 0 + console.log('[SSE] 连接已断开') + } + + return { + isConnected, + retryCount, + connect, + disconnect + } +}