feat: 客服

This commit is contained in:
2026-07-20 15:31:52 +08:00
parent 0099ccaa60
commit 82a8043578
23 changed files with 5071 additions and 360 deletions
@@ -0,0 +1,168 @@
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')
}
}