import { KEFU_BASE_URL } from '@/utils/config.js' const RECONNECT_DELAYS = [2000, 4000, 8000, 15000, 30000] function getSocketBaseUrl() { const override = uni.getStorageSync('kefu-socket-base-url') let baseUrl = String(override || KEFU_BASE_URL).replace(/\/+$/, '') // H5 本地环境的客服地址是相对路径 /api,WebSocket 必须使用完整的 ws:// URL。 if (baseUrl.startsWith('/') && typeof window !== 'undefined' && window.location) { baseUrl = `${window.location.origin}${baseUrl}` } return baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') } export class KefuSocket { constructor(options = {}) { this.options = options this.socketTask = null this.connectionParams = null this.manualClose = false this.networkOnline = true this.reconnectCount = 0 this.reconnectTimer = null this.heartbeatTimer = null this.networkListener = status => this.handleNetworkChange(status) this.browserOnlineListener = () => this.handleNetworkChange({ isConnected: true }) this.browserOfflineListener = () => this.handleNetworkChange({ isConnected: false }) this.bindNetworkListeners() } connect(params = {}) { this.connectionParams = { ...params } const { userId, mchId = 1002, conversationId } = this.connectionParams if (!userId || this.manualClose || this.socketTask || !this.networkOnline) return this.options.onStatus && this.options.onStatus('connecting') const query = [ 'userType=member', `userId=${encodeURIComponent(userId)}`, `mchId=${encodeURIComponent(mchId || 1002)}`, conversationId ? `conversationId=${encodeURIComponent(conversationId)}` : '' ].filter(Boolean).join('&') const task = uni.connectSocket({ url: `${getSocketBaseUrl()}/ws/kefu?${query}`, complete: () => {} }) this.socketTask = task task.onOpen(() => { if (this.socketTask !== task) return this.reconnectCount = 0 this.options.onStatus && this.options.onStatus('online') this.startHeartbeat(task) }) task.onMessage(event => { if (this.socketTask === task) this.handleMessage(event.data) }) task.onError(() => this.handleDisconnect(task)) task.onClose(() => this.handleDisconnect(task)) } handleMessage(raw) { let event = raw if (typeof raw === 'string') { try { event = JSON.parse(raw) } catch (error) { return } } if (event && event.type === 'pong') return this.options.onEvent && this.options.onEvent(event) } startHeartbeat(task) { this.stopHeartbeat() this.heartbeatTimer = setInterval(() => { if (this.socketTask !== task) return task.send({ data: 'ping', fail: () => this.handleDisconnect(task) }) }, 25000) } handleDisconnect(task) { if (task && this.socketTask !== task) return this.stopHeartbeat() this.socketTask = null if (this.manualClose) return this.options.onStatus && this.options.onStatus('offline') this.scheduleReconnect() } scheduleReconnect() { if (this.manualClose || this.reconnectTimer || !this.connectionParams || !this.networkOnline) return const delay = RECONNECT_DELAYS[Math.min(this.reconnectCount, RECONNECT_DELAYS.length - 1)] this.reconnectCount += 1 this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.options.onReconnect && this.options.onReconnect(this.reconnectCount) this.connect(this.connectionParams) }, delay) } reconnectNow() { if (this.manualClose || this.socketTask || !this.connectionParams || !this.networkOnline) return if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.reconnectTimer = null this.connect(this.connectionParams) } handleNetworkChange(status = {}) { this.networkOnline = status.isConnected !== false if (!this.networkOnline) { this.options.onStatus && this.options.onStatus('offline') this.stopHeartbeat() const task = this.socketTask this.socketTask = null if (task) { try { task.close({ code: 1000, reason: 'network offline' }) } catch (error) { // The platform may have disposed the socket before the offline event. } } return } this.reconnectNow() } bindNetworkListeners() { if (typeof uni.onNetworkStatusChange === 'function') { uni.onNetworkStatusChange(this.networkListener) } if (typeof window !== 'undefined' && window.addEventListener) { this.networkOnline = typeof navigator === 'undefined' || navigator.onLine !== false window.addEventListener('online', this.browserOnlineListener) window.addEventListener('offline', this.browserOfflineListener) } } unbindNetworkListeners() { if (typeof uni.offNetworkStatusChange === 'function') { uni.offNetworkStatusChange(this.networkListener) } if (typeof window !== 'undefined' && window.removeEventListener) { window.removeEventListener('online', this.browserOnlineListener) window.removeEventListener('offline', this.browserOfflineListener) } } stopHeartbeat() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } close() { this.manualClose = true this.stopHeartbeat() if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.reconnectTimer = null this.connectionParams = null this.unbindNetworkListeners() const task = this.socketTask this.socketTask = null if (task) task.close({ code: 1000, reason: 'page hidden' }) this.options.onStatus && this.options.onStatus('closed') } }