import { KEFU_BASE_URL } from '@/utils/config.js' const RECONNECT_DELAYS = [2000, 4000, 8000, 15000, 30000] const HEARTBEAT_INTERVAL = 20000 const HEARTBEAT_TIMEOUT = 45000 const CONNECT_TIMEOUT = 10000 function decodeSocketPayload(raw) { if (typeof raw === 'string') return raw let bytes = null if (typeof ArrayBuffer !== 'undefined' && raw instanceof ArrayBuffer) { bytes = new Uint8Array(raw) } else if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(raw)) { bytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength) } if (!bytes) return raw if (typeof TextDecoder !== 'undefined') { try { return new TextDecoder('utf-8').decode(bytes) } catch (error) { // Fall through to the decoder supported by older App WebViews. } } let encoded = '' for (let index = 0; index < bytes.length; index += 1) { encoded += `%${bytes[index].toString(16).padStart(2, '0')}` } try { return decodeURIComponent(encoded) } catch (error) { return Array.from(bytes).map(code => String.fromCharCode(code)).join('') } } 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:') } function shouldUseNativeSocket() { let enabled = false // #ifdef H5 enabled = typeof window !== 'undefined' && typeof window.WebSocket === 'function' // #endif return enabled } 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.connectTimer = null this.lastServerActivityAt = 0 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 url = `${getSocketBaseUrl()}/ws/kefu?${query}` // H5 使用浏览器原生 WebSocket。部分 uni-app H5 版本的 SocketTask // 不会稳定触发 onOpen,页面会误判为断线并永久停留在轮询状态。 const useNativeSocket = shouldUseNativeSocket() const task = useNativeSocket ? new window.WebSocket(url) : uni.connectSocket({ url, complete: () => {} }) this.socketTask = task const onOpen = () => { if (this.socketTask !== task) return this.stopConnectTimeout() this.reconnectCount = 0 this.lastServerActivityAt = Date.now() this.options.onStatus && this.options.onStatus('online') this.startHeartbeat(task) } const onMessage = event => { if (this.socketTask === task) this.handleMessage(event.data) } const onDisconnect = () => this.handleDisconnect(task) if (useNativeSocket) { task.addEventListener('open', onOpen) task.addEventListener('message', onMessage) task.addEventListener('error', onDisconnect) task.addEventListener('close', onDisconnect) } else { task.onOpen(onOpen) task.onMessage(onMessage) task.onError(onDisconnect) task.onClose(onDisconnect) } this.startConnectTimeout(task) } handleMessage(raw) { this.lastServerActivityAt = Date.now() const payload = decodeSocketPayload(raw) let event = payload if (typeof payload === 'string') { try { event = JSON.parse(payload) } 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 if (this.lastServerActivityAt && Date.now() - this.lastServerActivityAt > HEARTBEAT_TIMEOUT) { this.forceReconnect('heartbeat timeout') return } this.sendSocket(task, 'ping') }, HEARTBEAT_INTERVAL) } sendSocket(task, data) { try { if (typeof window !== 'undefined' && task instanceof window.WebSocket) { if (task.readyState === window.WebSocket.OPEN) task.send(data) else this.handleDisconnect(task) return } task.send({ data, fail: () => this.handleDisconnect(task) }) } catch (error) { this.handleDisconnect(task) } } closeSocket(task, reason) { if (!task) return try { if (typeof window !== 'undefined' && task instanceof window.WebSocket) { task.close(1000, reason) } else { task.close({ code: 1000, reason }) } } catch (error) { // App/native runtime may already have released the socket. } } startConnectTimeout(task) { this.stopConnectTimeout() this.connectTimer = setTimeout(() => { if (this.socketTask !== task) return this.socketTask = null this.closeSocket(task, 'connect timeout') this.options.onStatus && this.options.onStatus('offline') this.scheduleReconnect() }, CONNECT_TIMEOUT) } stopConnectTimeout() { if (this.connectTimer) clearTimeout(this.connectTimer) this.connectTimer = null } handleDisconnect(task) { if (task && this.socketTask !== task) return this.stopConnectTimeout() 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(force = false) { if (force) { this.forceReconnect('app resumed') return } if (this.manualClose || this.socketTask || !this.connectionParams || !this.networkOnline) return if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.reconnectTimer = null this.connect(this.connectionParams) } forceReconnect(reason = 'connection refresh') { if (this.manualClose || !this.connectionParams || !this.networkOnline) return if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.reconnectTimer = null this.stopConnectTimeout() this.stopHeartbeat() const task = this.socketTask this.socketTask = null this.closeSocket(task, reason) this.options.onStatus && this.options.onStatus('connecting') setTimeout(() => this.connect(this.connectionParams), 0) } handleNetworkChange(status = {}) { this.networkOnline = status.isConnected !== false if (!this.networkOnline) { this.options.onStatus && this.options.onStatus('offline') this.stopConnectTimeout() this.stopHeartbeat() const task = this.socketTask this.socketTask = null this.closeSocket(task, 'network offline') 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.stopConnectTimeout() this.stopHeartbeat() if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.reconnectTimer = null this.connectionParams = null this.unbindNetworkListeners() const task = this.socketTask this.socketTask = null this.closeSocket(task, 'page hidden') this.options.onStatus && this.options.onStatus('closed') } }