feat: 创建 useSse SSE 连接管理 composable
This commit is contained in:
122
src/common/hooks/useSse.ts
Normal file
122
src/common/hooks/useSse.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user