2026-07-20 15:31:52 +08:00
|
|
|
import { KEFU_BASE_URL } from '@/utils/config.js'
|
|
|
|
|
|
|
|
|
|
const RECONNECT_DELAYS = [2000, 4000, 8000, 15000, 30000]
|
2026-08-20 17:31:00 +08:00
|
|
|
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('')
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 15:31:52 +08:00
|
|
|
|
|
|
|
|
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:')
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 17:31:00 +08:00
|
|
|
function shouldUseNativeSocket() {
|
|
|
|
|
let enabled = false
|
|
|
|
|
// #ifdef H5
|
|
|
|
|
enabled = typeof window !== 'undefined' && typeof window.WebSocket === 'function'
|
|
|
|
|
// #endif
|
|
|
|
|
return enabled
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 15:31:52 +08:00
|
|
|
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
|
2026-08-20 17:31:00 +08:00
|
|
|
this.connectTimer = null
|
|
|
|
|
this.lastServerActivityAt = 0
|
2026-07-20 15:31:52 +08:00
|
|
|
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('&')
|
|
|
|
|
|
2026-08-20 17:31:00 +08:00
|
|
|
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: () => {} })
|
2026-07-20 15:31:52 +08:00
|
|
|
this.socketTask = task
|
|
|
|
|
|
2026-08-20 17:31:00 +08:00
|
|
|
const onOpen = () => {
|
2026-07-20 15:31:52 +08:00
|
|
|
if (this.socketTask !== task) return
|
2026-08-20 17:31:00 +08:00
|
|
|
this.stopConnectTimeout()
|
2026-07-20 15:31:52 +08:00
|
|
|
this.reconnectCount = 0
|
2026-08-20 17:31:00 +08:00
|
|
|
this.lastServerActivityAt = Date.now()
|
2026-07-20 15:31:52 +08:00
|
|
|
this.options.onStatus && this.options.onStatus('online')
|
|
|
|
|
this.startHeartbeat(task)
|
2026-08-20 17:31:00 +08:00
|
|
|
}
|
|
|
|
|
const onMessage = event => {
|
2026-07-20 15:31:52 +08:00
|
|
|
if (this.socketTask === task) this.handleMessage(event.data)
|
2026-08-20 17:31:00 +08:00
|
|
|
}
|
|
|
|
|
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)
|
2026-07-20 15:31:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleMessage(raw) {
|
2026-08-20 17:31:00 +08:00
|
|
|
this.lastServerActivityAt = Date.now()
|
|
|
|
|
const payload = decodeSocketPayload(raw)
|
|
|
|
|
let event = payload
|
|
|
|
|
if (typeof payload === 'string') {
|
2026-07-20 15:31:52 +08:00
|
|
|
try {
|
2026-08-20 17:31:00 +08:00
|
|
|
event = JSON.parse(payload)
|
2026-07-20 15:31:52 +08:00
|
|
|
} 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
|
2026-08-20 17:31:00 +08:00
|
|
|
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
|
2026-07-20 15:31:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleDisconnect(task) {
|
|
|
|
|
if (task && this.socketTask !== task) return
|
2026-08-20 17:31:00 +08:00
|
|
|
this.stopConnectTimeout()
|
2026-07-20 15:31:52 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 17:31:00 +08:00
|
|
|
reconnectNow(force = false) {
|
|
|
|
|
if (force) {
|
|
|
|
|
this.forceReconnect('app resumed')
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-20 15:31:52 +08:00
|
|
|
if (this.manualClose || this.socketTask || !this.connectionParams || !this.networkOnline) return
|
|
|
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
this.connect(this.connectionParams)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 17:31:00 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 15:31:52 +08:00
|
|
|
handleNetworkChange(status = {}) {
|
|
|
|
|
this.networkOnline = status.isConnected !== false
|
|
|
|
|
if (!this.networkOnline) {
|
|
|
|
|
this.options.onStatus && this.options.onStatus('offline')
|
2026-08-20 17:31:00 +08:00
|
|
|
this.stopConnectTimeout()
|
2026-07-20 15:31:52 +08:00
|
|
|
this.stopHeartbeat()
|
|
|
|
|
const task = this.socketTask
|
|
|
|
|
this.socketTask = null
|
2026-08-20 17:31:00 +08:00
|
|
|
this.closeSocket(task, 'network offline')
|
2026-07-20 15:31:52 +08:00
|
|
|
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
|
2026-08-20 17:31:00 +08:00
|
|
|
this.stopConnectTimeout()
|
2026-07-20 15:31:52 +08:00
|
|
|
this.stopHeartbeat()
|
|
|
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
this.connectionParams = null
|
|
|
|
|
this.unbindNetworkListeners()
|
|
|
|
|
const task = this.socketTask
|
|
|
|
|
this.socketTask = null
|
2026-08-20 17:31:00 +08:00
|
|
|
this.closeSocket(task, 'page hidden')
|
2026-07-20 15:31:52 +08:00
|
|
|
this.options.onStatus && this.options.onStatus('closed')
|
|
|
|
|
}
|
|
|
|
|
}
|