2025-11-27 02:56:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* WebSocket 消息类型(int 值)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export enum WebSocketMessageType {
|
|
|
|
|
|
SUB = 1, // 订阅
|
|
|
|
|
|
UNSUB = 2, // 取消订阅
|
|
|
|
|
|
DATA = 3, // 数据推送
|
|
|
|
|
|
SUB_ACK = 4, // 订阅确认
|
|
|
|
|
|
PING = 5, // 心跳
|
|
|
|
|
|
PONG = 6 // 心跳响应
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* WebSocket 消息
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface WebSocketMessage {
|
|
|
|
|
|
type: number // WebSocketMessageType 的 int 值(1:SUB, 2:UNSUB, 3:DATA, 4:SUB_ACK, 5:PING, 6:PONG)
|
|
|
|
|
|
channel?: string
|
|
|
|
|
|
payload?: any
|
|
|
|
|
|
timestamp?: number
|
|
|
|
|
|
status?: number // 0: success, 非0: error
|
|
|
|
|
|
message?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 订阅回调函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type SubscriptionCallback = (data: any) => void
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 全局 WebSocket 管理器
|
|
|
|
|
|
*/
|
|
|
|
|
|
class WebSocketManager {
|
|
|
|
|
|
private ws: WebSocket | null = null
|
|
|
|
|
|
private reconnectTimer: NodeJS.Timeout | null = null
|
|
|
|
|
|
private pingInterval: NodeJS.Timeout | null = null
|
|
|
|
|
|
private isConnecting = false
|
|
|
|
|
|
private isUnmounting = false
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅管理:channel -> Set<callback>
|
|
|
|
|
|
private subscriptions = new Map<string, Set<SubscriptionCallback>>()
|
|
|
|
|
|
|
|
|
|
|
|
// 订阅状态:channel -> boolean(是否已向后端订阅)
|
|
|
|
|
|
private subscribedChannels = new Set<string>()
|
|
|
|
|
|
|
|
|
|
|
|
// 连接状态回调
|
|
|
|
|
|
private connectionCallbacks: Set<(connected: boolean) => void> = new Set()
|
|
|
|
|
|
|
|
|
|
|
|
private reconnectDelay = 3000
|
|
|
|
|
|
private pingIntervalTime = 30000
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 连接 WebSocket(全局共享连接)
|
|
|
|
|
|
*/
|
|
|
|
|
|
connect(): void {
|
2025-12-03 00:24:46 +08:00
|
|
|
|
// 检查是否有token,未登录不允许连接
|
|
|
|
|
|
const token = this.getToken()
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
|
console.log('[WebSocket] 未登录,不建立连接')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 02:56:24 +08:00
|
|
|
|
// 如果已经连接或正在连接,直接返回
|
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果正在卸载,不允许连接
|
|
|
|
|
|
if (this.isUnmounting) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.isConnecting = true
|
|
|
|
|
|
const wsUrl = this.getWebSocketUrl()
|
|
|
|
|
|
console.log('[WebSocket] 正在连接:', wsUrl)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 如果已经有连接(但状态不是 OPEN),先关闭
|
|
|
|
|
|
if (this.ws) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ws.close()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 忽略关闭错误
|
|
|
|
|
|
}
|
|
|
|
|
|
this.ws = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ws = new WebSocket(wsUrl)
|
|
|
|
|
|
this.ws = ws
|
|
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
|
console.log('[WebSocket] 连接成功')
|
|
|
|
|
|
this.isConnecting = false
|
|
|
|
|
|
this.notifyConnectionStatus(true)
|
|
|
|
|
|
this.startPing()
|
|
|
|
|
|
this.resubscribeAll() // 重新订阅所有频道
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
|
this.handleMessage(event.data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ws.onerror = (error) => {
|
|
|
|
|
|
console.error('[WebSocket] 连接错误:', error)
|
|
|
|
|
|
this.isConnecting = false
|
|
|
|
|
|
this.notifyConnectionStatus(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
|
|
|
|
|
console.log('[WebSocket] 连接关闭')
|
|
|
|
|
|
this.isConnecting = false
|
|
|
|
|
|
this.notifyConnectionStatus(false)
|
|
|
|
|
|
this.stopPing()
|
2025-12-03 00:24:46 +08:00
|
|
|
|
// 自动重连(除非正在卸载或未登录)
|
|
|
|
|
|
if (!this.isUnmounting && this.getToken()) {
|
2025-11-27 02:56:24 +08:00
|
|
|
|
this.scheduleReconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[WebSocket] 创建连接失败:', error)
|
|
|
|
|
|
this.isConnecting = false
|
|
|
|
|
|
this.notifyConnectionStatus(false)
|
2025-12-03 00:24:46 +08:00
|
|
|
|
// 自动重连(除非正在卸载或未登录)
|
|
|
|
|
|
if (!this.isUnmounting && this.getToken()) {
|
2025-11-27 02:56:24 +08:00
|
|
|
|
this.scheduleReconnect()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 断开连接(仅在应用完全卸载时调用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
disconnect(): void {
|
|
|
|
|
|
console.log('[WebSocket] 断开连接')
|
|
|
|
|
|
this.isUnmounting = true
|
|
|
|
|
|
this.stopPing()
|
|
|
|
|
|
if (this.reconnectTimer) {
|
|
|
|
|
|
clearTimeout(this.reconnectTimer)
|
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.ws) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ws.close()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 忽略关闭错误
|
|
|
|
|
|
}
|
|
|
|
|
|
this.ws = null
|
|
|
|
|
|
}
|
|
|
|
|
|
this.notifyConnectionStatus(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 订阅频道
|
|
|
|
|
|
*/
|
|
|
|
|
|
subscribe(channel: string, callback: SubscriptionCallback, payload?: any): () => void {
|
|
|
|
|
|
// 添加订阅者
|
|
|
|
|
|
if (!this.subscriptions.has(channel)) {
|
|
|
|
|
|
this.subscriptions.set(channel, new Set())
|
|
|
|
|
|
}
|
|
|
|
|
|
this.subscriptions.get(channel)!.add(callback)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果还未向后端订阅,发送订阅消息
|
|
|
|
|
|
if (!this.subscribedChannels.has(channel)) {
|
|
|
|
|
|
this.sendSubscribe(channel, payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 返回取消订阅函数
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
this.unsubscribe(channel, callback)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 取消订阅
|
|
|
|
|
|
*/
|
|
|
|
|
|
unsubscribe(channel: string, callback: SubscriptionCallback): void {
|
|
|
|
|
|
const callbacks = this.subscriptions.get(channel)
|
|
|
|
|
|
if (callbacks) {
|
|
|
|
|
|
callbacks.delete(callback)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有订阅者了,向后端取消订阅
|
|
|
|
|
|
if (callbacks.size === 0) {
|
|
|
|
|
|
this.subscriptions.delete(channel)
|
|
|
|
|
|
this.sendUnsubscribe(channel)
|
|
|
|
|
|
this.subscribedChannels.delete(channel)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送订阅消息
|
|
|
|
|
|
*/
|
|
|
|
|
|
private sendSubscribe(channel: string, payload?: any): void {
|
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
const message: WebSocketMessage = {
|
|
|
|
|
|
type: WebSocketMessageType.SUB,
|
|
|
|
|
|
channel,
|
|
|
|
|
|
payload
|
|
|
|
|
|
}
|
|
|
|
|
|
this.ws.send(JSON.stringify(message))
|
|
|
|
|
|
this.subscribedChannels.add(channel)
|
|
|
|
|
|
console.log('已订阅频道:', channel)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 如果连接未建立,先连接
|
|
|
|
|
|
this.connect()
|
|
|
|
|
|
// 连接建立后会通过 resubscribeAll 自动订阅
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送取消订阅消息
|
|
|
|
|
|
*/
|
|
|
|
|
|
private sendUnsubscribe(channel: string): void {
|
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
const message: WebSocketMessage = {
|
|
|
|
|
|
type: WebSocketMessageType.UNSUB,
|
|
|
|
|
|
channel
|
|
|
|
|
|
}
|
|
|
|
|
|
this.ws.send(JSON.stringify(message))
|
|
|
|
|
|
console.log('已取消订阅频道:', channel)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理收到的消息
|
|
|
|
|
|
*/
|
|
|
|
|
|
private handleMessage(data: string): void {
|
|
|
|
|
|
// 处理心跳
|
|
|
|
|
|
if (data === 'PONG') {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const message: WebSocketMessage = JSON.parse(data)
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === WebSocketMessageType.DATA && message.channel) {
|
|
|
|
|
|
// 数据推送:分发到订阅者
|
|
|
|
|
|
const callbacks = this.subscriptions.get(message.channel)
|
|
|
|
|
|
if (callbacks) {
|
|
|
|
|
|
callbacks.forEach(callback => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
callback(message.payload)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`频道 ${message.channel} 回调执行失败:`, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (message.type === WebSocketMessageType.SUB_ACK) {
|
|
|
|
|
|
// 订阅确认
|
|
|
|
|
|
if (message.status !== undefined && message.status !== 0) {
|
|
|
|
|
|
console.error(`订阅频道 ${message.channel} 失败:`, message.message)
|
|
|
|
|
|
this.subscribedChannels.delete(message.channel || '')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`订阅频道 ${message.channel} 成功`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('解析 WebSocket 消息失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 重新订阅所有频道
|
|
|
|
|
|
*/
|
|
|
|
|
|
private resubscribeAll(): void {
|
|
|
|
|
|
this.subscribedChannels.clear()
|
|
|
|
|
|
this.subscriptions.forEach((callbacks, channel) => {
|
|
|
|
|
|
if (callbacks.size > 0) {
|
|
|
|
|
|
this.sendSubscribe(channel)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 安排重连
|
|
|
|
|
|
*/
|
|
|
|
|
|
private scheduleReconnect(): void {
|
|
|
|
|
|
if (this.isUnmounting) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 00:24:46 +08:00
|
|
|
|
// 检查是否有token,未登录不重连
|
|
|
|
|
|
if (!this.getToken()) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 02:56:24 +08:00
|
|
|
|
if (this.reconnectTimer) {
|
|
|
|
|
|
clearTimeout(this.reconnectTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
|
|
|
|
this.connect()
|
|
|
|
|
|
}, this.reconnectDelay)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 开始心跳
|
|
|
|
|
|
*/
|
|
|
|
|
|
private startPing(): void {
|
|
|
|
|
|
this.stopPing()
|
|
|
|
|
|
|
|
|
|
|
|
// 立即发送一次心跳
|
|
|
|
|
|
const sendPing = () => {
|
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
this.ws.send('PING')
|
|
|
|
|
|
console.log('发送心跳: PING')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sendPing()
|
|
|
|
|
|
|
|
|
|
|
|
// 每30秒发送一次心跳
|
|
|
|
|
|
this.pingInterval = setInterval(sendPing, this.pingIntervalTime)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 停止心跳
|
|
|
|
|
|
*/
|
|
|
|
|
|
private stopPing(): void {
|
|
|
|
|
|
if (this.pingInterval) {
|
|
|
|
|
|
clearInterval(this.pingInterval)
|
|
|
|
|
|
this.pingInterval = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-03 00:24:46 +08:00
|
|
|
|
* 获取 WebSocket URL(带token认证)
|
2025-11-27 02:56:24 +08:00
|
|
|
|
*/
|
|
|
|
|
|
private getWebSocketUrl(): string {
|
|
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
|
|
|
|
const host = window.location.host
|
2025-12-03 00:24:46 +08:00
|
|
|
|
const token = this.getToken()
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
// 通过查询参数传递token
|
|
|
|
|
|
return `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`
|
|
|
|
|
|
}
|
2025-11-27 02:56:24 +08:00
|
|
|
|
return `${protocol}//${host}/ws`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 00:24:46 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取token(从localStorage)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getToken(): string | null {
|
|
|
|
|
|
return localStorage.getItem('jwt_token')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 02:56:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 注册连接状态回调
|
|
|
|
|
|
*/
|
|
|
|
|
|
onConnectionChange(callback: (connected: boolean) => void): () => void {
|
|
|
|
|
|
this.connectionCallbacks.add(callback)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
this.connectionCallbacks.delete(callback)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通知连接状态变化
|
|
|
|
|
|
*/
|
|
|
|
|
|
private notifyConnectionStatus(connected: boolean): void {
|
|
|
|
|
|
this.connectionCallbacks.forEach(callback => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
callback(connected)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('连接状态回调执行失败:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取连接状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
isConnected(): boolean {
|
|
|
|
|
|
return this.ws?.readyState === WebSocket.OPEN
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 导出单例
|
|
|
|
|
|
export const wsManager = new WebSocketManager()
|
|
|
|
|
|
|