feat: 客服
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import request from '@/utils/request.js'
|
||||
import { BASE_URL, KEFU_BASE_URL, Authorization } from '@/utils/config.js'
|
||||
import { getStorageFun, TOKEN_NAME } from '@/utils/auth.js'
|
||||
|
||||
export const CONTENT_TYPE = Object.freeze({
|
||||
TEXT: 1,
|
||||
IMAGE: 2,
|
||||
FILE: 3,
|
||||
AUDIO: 4,
|
||||
PRODUCT: 10,
|
||||
ORDER: 11,
|
||||
QUOTE: 12,
|
||||
RECALLED: 99
|
||||
})
|
||||
|
||||
function kefuRequest({ url, method = 'GET', data = {} }) {
|
||||
const token = getStorageFun(TOKEN_NAME)
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${KEFU_BASE_URL}${url}`,
|
||||
method,
|
||||
data,
|
||||
header: {
|
||||
Authorization,
|
||||
'HSM-AUTH': token || '',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
success: response => {
|
||||
const body = response.data || {}
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && body.bizcode === 100) {
|
||||
resolve(body)
|
||||
return
|
||||
}
|
||||
const error = new Error(body.msg || `客服接口请求失败(${response.statusCode})`)
|
||||
error.statusCode = response.statusCode
|
||||
error.response = body
|
||||
reject(error)
|
||||
},
|
||||
fail: error => reject(new Error(error.errMsg || '客服接口连接失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function getCurrentMember() {
|
||||
return request({ url: '/user/getUserInfo', method: 'get', isShowLoading: false }).then(result => result.data)
|
||||
}
|
||||
|
||||
export function getMessages({ userId, conversationId, page = 1, pageSize = 80 }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/messages',
|
||||
method: 'get',
|
||||
data: { userId, conversationId, page, pageSize }
|
||||
}).then(result => result.data || {})
|
||||
}
|
||||
|
||||
export function sendMessage(data) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/send',
|
||||
method: 'post',
|
||||
data
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function transferToHuman({ userId, conversationId, reason = '用户点击转人工' }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/transfer-human',
|
||||
method: 'post',
|
||||
data: { userId, conversationId, reason }
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function recallMessage({ userId, messageId }) {
|
||||
const data = { userId, messageId }
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/message/recall',
|
||||
method: 'post',
|
||||
data
|
||||
}).catch(error => {
|
||||
if (!error || error.statusCode !== 404) throw error
|
||||
return kefuRequest({ url: '/kefu/user/recall', method: 'post', data })
|
||||
})
|
||||
.then(result => result.data)
|
||||
}
|
||||
|
||||
export function deleteMessageForMember({ userId, messageId }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/message/delete',
|
||||
method: 'post',
|
||||
data: { userId, messageId }
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function transcribeMessage({ userId, messageId }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/message/transcribe',
|
||||
method: 'post',
|
||||
data: { userId, messageId }
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function getUnreadCount(userId) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/unread-count',
|
||||
method: 'get',
|
||||
data: { userId }
|
||||
}).then(result => Number(result.data || 0))
|
||||
}
|
||||
|
||||
export function markMemberMessagesRead({ userId, conversationId }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/read',
|
||||
method: 'post',
|
||||
data: { userId, conversationId }
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function getBrowseHistory({ userId, page = 1, pageSize = 10 }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/browse-history',
|
||||
method: 'get',
|
||||
data: { userId, page, pageSize }
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function getOrders({ userId, page = 1, pageSize = 10 }) {
|
||||
return kefuRequest({
|
||||
url: '/kefu/user/orders',
|
||||
method: 'get',
|
||||
data: { userId, page, pageSize }
|
||||
}).then(result => result.data)
|
||||
}
|
||||
|
||||
export function uploadMessageImage(filePath) {
|
||||
const token = getStorageFun(TOKEN_NAME)
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/common/upload/image`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: { type: 'COMMON' },
|
||||
header: {
|
||||
Authorization,
|
||||
'HSM-AUTH': token || ''
|
||||
},
|
||||
success: response => {
|
||||
try {
|
||||
const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data
|
||||
if (!body || body.bizcode !== 100) throw new Error((body && body.msg) || '图片上传失败')
|
||||
const data = body.data || {}
|
||||
const url = data.accessUrl || data.dbUrl || data.url || data.fileUrl || data.path
|
||||
if (!url) throw new Error('图片上传后未返回访问地址')
|
||||
resolve(url)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: error => reject(new Error(error.errMsg || '图片上传失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveUploadResult(response, fallbackMessage) {
|
||||
const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data
|
||||
if (!body || body.bizcode !== 100) throw new Error((body && body.msg) || fallbackMessage)
|
||||
const data = body.data || {}
|
||||
const url = data.accessUrl || data.dbUrl || data.url || data.fileUrl || data.path
|
||||
if (!url) throw new Error('上传成功但未返回访问地址')
|
||||
return url
|
||||
}
|
||||
|
||||
export function uploadMessageAsset(source, kind = 'file') {
|
||||
const token = getStorageFun(TOKEN_NAME)
|
||||
const endpoints = [...new Set([
|
||||
`${String(KEFU_BASE_URL).replace(/\/$/, '')}/kefu/user/upload/${kind}`,
|
||||
`${String(BASE_URL).replace(/\/$/, '')}/common/upload/${kind}`
|
||||
])]
|
||||
// #ifdef H5
|
||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', source, source.name || `${kind}-${Date.now()}`)
|
||||
return (async () => {
|
||||
let lastError = null
|
||||
for (const endpoint of endpoints) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization, 'HSM-AUTH': token || '' },
|
||||
body: formData
|
||||
})
|
||||
if (response.status === 404) {
|
||||
lastError = new Error(`${kind === 'audio' ? '语音' : '文件'}上传接口不存在`)
|
||||
continue
|
||||
}
|
||||
return resolveUploadResult({ data: await response.text() }, `${kind === 'audio' ? '语音' : '文件'}上传失败`)
|
||||
}
|
||||
throw lastError || new Error(`${kind === 'audio' ? '语音' : '文件'}上传失败`)
|
||||
})()
|
||||
}
|
||||
// #endif
|
||||
const filePath = typeof source === 'string' ? source : (source.tempFilePath || source.path)
|
||||
return new Promise((resolve, reject) => {
|
||||
const uploadAt = index => {
|
||||
uni.uploadFile({
|
||||
url: endpoints[index],
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: { Authorization, 'HSM-AUTH': token || '' },
|
||||
success: response => {
|
||||
if (Number(response.statusCode) === 404 && index + 1 < endpoints.length) {
|
||||
uploadAt(index + 1)
|
||||
return
|
||||
}
|
||||
try { resolve(resolveUploadResult(response, `${kind === 'audio' ? '语音' : '文件'}上传失败`)) } catch (error) { reject(error) }
|
||||
},
|
||||
fail: error => reject(new Error(error.errMsg || `${kind === 'audio' ? '语音' : '文件'}上传失败`))
|
||||
})
|
||||
}
|
||||
uploadAt(0)
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadMessageFile = source => uploadMessageAsset(source, 'file')
|
||||
export const uploadMessageAudio = source => uploadMessageAsset(source, 'audio')
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user