feat: 客服
This commit is contained in:
+2124
File diff suppressed because it is too large
Load Diff
+129
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<view class="context-card">
|
||||
<image class="context-image" :src="image" mode="aspectFill" />
|
||||
<view class="context-copy">
|
||||
<text class="context-kind">{{ kindLabel }}</text>
|
||||
<text class="context-title">{{ title }}</text>
|
||||
<text class="context-meta">{{ meta }}</text>
|
||||
</view>
|
||||
<view class="context-actions">
|
||||
<button class="context-send" :disabled="sending" @click="$emit('send')">{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button class="context-remove" @click="$emit('remove')">移除</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CustomerServiceContextCard',
|
||||
props: {
|
||||
context: { type: Object, required: true },
|
||||
sending: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['send', 'remove'],
|
||||
computed: {
|
||||
isProduct() {
|
||||
return this.context.type === 'product'
|
||||
},
|
||||
kindLabel() {
|
||||
return this.isProduct ? '待咨询商品' : '待咨询订单'
|
||||
},
|
||||
image() {
|
||||
const data = this.context.data || {}
|
||||
return data.goodsMainGraph || data.mainGraph || data.image || '/uni_modules/hashmall-customer-service/static/image-placeholder.png'
|
||||
},
|
||||
title() {
|
||||
const data = this.context.data || {}
|
||||
return data.goodsName || data.name || (this.isProduct ? '商品信息' : '订单信息')
|
||||
},
|
||||
meta() {
|
||||
const data = this.context.data || {}
|
||||
if (!this.isProduct) return data.orderNo || data.no || '点击发送订单信息'
|
||||
const price = data.currentPrice || data.price
|
||||
return price ? `¥${price}` : '点击发送商品信息'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.context-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
border-top: 1rpx solid #e7e7ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.context-image {
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
flex: 0 0 92rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.context-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.context-kind {
|
||||
color: #7934f6;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-title,
|
||||
.context-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-title {
|
||||
margin-top: 4rpx;
|
||||
color: #1a1a1d;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-meta {
|
||||
margin-top: 4rpx;
|
||||
color: #5f6068;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.context-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.context-send,
|
||||
.context-remove {
|
||||
min-width: 96rpx;
|
||||
min-height: 52rpx;
|
||||
padding: 0 14rpx;
|
||||
border-radius: 26rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 52rpx;
|
||||
}
|
||||
|
||||
.context-send {
|
||||
background: #7934f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.context-send[disabled] {
|
||||
background: #c9b2f4;
|
||||
}
|
||||
|
||||
.context-remove {
|
||||
background: transparent;
|
||||
color: #5f6068;
|
||||
}
|
||||
</style>
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<view v-if="isRecalled" class="message-row message-row--recalled">
|
||||
<text class="recall-notice">{{ isSelf ? '你撤回了一条消息' : '客服撤回了一条消息' }}</text>
|
||||
</view>
|
||||
<view v-else class="message-row" :class="{ 'message-row--self': isSelf }">
|
||||
<view v-if="showTime || isSelf" class="message-meta" :class="{ 'message-meta--self': isSelf }">
|
||||
<text>{{ displayName }}</text>
|
||||
<text v-if="showTime">{{ formattedTime }}</text>
|
||||
</view>
|
||||
<view class="message-line" :class="{ 'message-line--self': isSelf }">
|
||||
<image v-if="!isSelf" class="avatar" :src="avatar" mode="aspectFill" />
|
||||
<view class="message-column">
|
||||
<view
|
||||
class="message-content"
|
||||
:class="[isSelf ? 'message-content--self' : 'message-content--service', contentClass]"
|
||||
@longpress.stop="openActions"
|
||||
@contextmenu.prevent.stop="openActions"
|
||||
>
|
||||
<text v-if="normalizedType === contentType.TEXT" class="message-text" selectable>{{ message.content }}</text>
|
||||
<image
|
||||
v-else-if="normalizedType === contentType.IMAGE"
|
||||
class="message-image"
|
||||
:src="message.content"
|
||||
mode="aspectFill"
|
||||
@click="previewImage"
|
||||
@load="$emit('layout-change', message)"
|
||||
@error="$emit('layout-change', message)"
|
||||
/>
|
||||
<view v-else-if="normalizedType === contentType.AUDIO" class="audio-block">
|
||||
<button class="audio-message" @click.stop="toggleAudio">
|
||||
<u-icon :name="audioPlaying ? 'pause-circle' : 'play-circle'" size="24" color="#7934f6" />
|
||||
<text>{{ audioPlaying ? '正在播放' : '语音消息' }}</text>
|
||||
<text class="audio-duration">{{ attachment.duration ? `${attachment.duration}″` : '' }}</text>
|
||||
</button>
|
||||
<text v-if="attachment.transcript" class="audio-transcript" selectable>{{ attachment.transcript }}</text>
|
||||
<button v-else class="transcribe-button" :disabled="transcribing" @click.stop="$emit('transcribe', message)">
|
||||
{{ transcribing ? '识别中…' : '转文字' }}
|
||||
</button>
|
||||
</view>
|
||||
<button v-else-if="normalizedType === contentType.FILE" class="file-message" @click.stop="openFile">
|
||||
<u-icon name="file-text" size="28" color="#7934f6" />
|
||||
<view class="file-message__body">
|
||||
<text class="file-message__name">{{ attachment.name || '聊天文件' }}</text>
|
||||
<text class="file-message__meta">{{ formatFileSize(attachment.size) }}</text>
|
||||
</view>
|
||||
</button>
|
||||
<view v-else-if="card" class="message-card" @click="openCard">
|
||||
<image class="card-image" :src="card.image || fallbackImage" mode="aspectFill" />
|
||||
<view class="card-body">
|
||||
<text class="card-kind">{{ normalizedType === contentType.PRODUCT ? '咨询商品' : '咨询订单' }}</text>
|
||||
<text class="card-title">{{ card.title }}</text>
|
||||
<text class="card-meta">{{ card.meta }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="quote" class="quote-message">
|
||||
<view class="quote-source">
|
||||
<text class="quote-source__name">{{ quote.senderName }}</text>
|
||||
<text class="quote-source__content">{{ quote.preview }}</text>
|
||||
</view>
|
||||
<text class="message-text quote-message__text" selectable>{{ quote.text }}</text>
|
||||
</view>
|
||||
<text v-else class="message-text message-text--muted">暂不支持的消息类型</text>
|
||||
</view>
|
||||
<view v-if="isSelf" class="send-state">
|
||||
<text v-if="message.sendStatus === 'sending'" class="send-state__loading">发送中</text>
|
||||
<button v-else-if="message.sendStatus === 'failed'" class="retry-button" @click.stop="$emit('retry', message)">重新发送</button>
|
||||
<text v-else>已发送</text>
|
||||
</view>
|
||||
</view>
|
||||
<image v-if="isSelf" class="avatar" :src="avatar" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CONTENT_TYPE } from '../../js_sdk/api.js'
|
||||
|
||||
export default {
|
||||
name: 'CustomerServiceMessageItem',
|
||||
props: {
|
||||
message: { type: Object, required: true },
|
||||
showTime: { type: Boolean, default: false },
|
||||
serviceAvatar: { type: String, default: '' },
|
||||
memberAvatar: { type: String, default: '' },
|
||||
memberName: { type: String, default: '' },
|
||||
transcribing: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['retry', 'open-card', 'actions', 'transcribe', 'layout-change'],
|
||||
data() {
|
||||
return {
|
||||
contentType: CONTENT_TYPE,
|
||||
fallbackImage: '/uni_modules/hashmall-customer-service/static/image-placeholder.png',
|
||||
audioPlaying: false,
|
||||
audioContext: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelf() {
|
||||
return Number(this.message.senderType) === 2
|
||||
},
|
||||
normalizedType() {
|
||||
return Number(this.message.contentType)
|
||||
},
|
||||
isRecalled() {
|
||||
return this.normalizedType === CONTENT_TYPE.RECALLED
|
||||
},
|
||||
avatar() {
|
||||
if (this.isSelf) {
|
||||
return this.memberAvatar || this.message.senderAvatar || 'https://static.tbmall.xin/static/avater_not.png'
|
||||
}
|
||||
return this.serviceAvatar || '/uni_modules/hashmall-customer-service/static/tb-service-avatar.png'
|
||||
},
|
||||
displayName() {
|
||||
if (this.isSelf) return this.memberName || this.message.senderName || '我'
|
||||
return this.message.senderName || '系统'
|
||||
},
|
||||
formattedTime() {
|
||||
const raw = this.message.ctdate || this.message.createdAt || Date.now()
|
||||
const value = Number(raw) < 100000000000 ? Number(raw) * 1000 : Number(raw)
|
||||
const date = new Date(value)
|
||||
const pad = number => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
},
|
||||
card() {
|
||||
if (![CONTENT_TYPE.PRODUCT, CONTENT_TYPE.ORDER].includes(Number(this.message.contentType))) return null
|
||||
try {
|
||||
const data = typeof this.message.content === 'string' ? JSON.parse(this.message.content) : this.message.content
|
||||
if (Number(this.message.contentType) === CONTENT_TYPE.PRODUCT) {
|
||||
return {
|
||||
data,
|
||||
image: data.mainGraph || data.goodsMainGraph || data.image,
|
||||
title: data.name || data.goodsName || '商品信息',
|
||||
meta: data.price || data.currentPrice ? `¥${data.price || data.currentPrice}` : '查看商品详情'
|
||||
}
|
||||
}
|
||||
return {
|
||||
data,
|
||||
image: data.mainGraph || data.goodsMainGraph || data.image,
|
||||
title: data.goodsName || data.name || '订单信息',
|
||||
meta: data.orderNo || data.no || '查看订单详情'
|
||||
}
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
attachment() {
|
||||
if (![CONTENT_TYPE.FILE, CONTENT_TYPE.AUDIO].includes(this.normalizedType)) return {}
|
||||
try {
|
||||
const value = typeof this.message.content === 'string' ? JSON.parse(this.message.content) : this.message.content
|
||||
return value && typeof value === 'object' ? value : { url: String(value || '') }
|
||||
} catch (error) {
|
||||
return { url: String(this.message.content || '') }
|
||||
}
|
||||
},
|
||||
quote() {
|
||||
if (this.normalizedType !== CONTENT_TYPE.QUOTE) return null
|
||||
try {
|
||||
const data = typeof this.message.content === 'string' ? JSON.parse(this.message.content) : this.message.content
|
||||
const source = data.quote || {}
|
||||
return {
|
||||
text: String(data.text || ''),
|
||||
senderName: source.senderName || '聊天消息',
|
||||
preview: this.messagePreview(source)
|
||||
}
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
contentClass() {
|
||||
if (this.normalizedType === CONTENT_TYPE.IMAGE) return 'message-content--image'
|
||||
if ([CONTENT_TYPE.PRODUCT, CONTENT_TYPE.ORDER].includes(this.normalizedType)) return 'message-content--card'
|
||||
if (this.normalizedType === CONTENT_TYPE.QUOTE) return 'message-content--quote'
|
||||
return ''
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destroyAudio()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyAudio()
|
||||
},
|
||||
methods: {
|
||||
destroyAudio() {
|
||||
if (this.audioContext) this.audioContext.destroy()
|
||||
this.audioContext = null
|
||||
this.audioPlaying = false
|
||||
},
|
||||
toggleAudio() {
|
||||
if (!this.attachment.url) return
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = uni.createInnerAudioContext()
|
||||
this.audioContext.src = this.attachment.url
|
||||
this.audioContext.onPlay(() => { this.audioPlaying = true })
|
||||
this.audioContext.onPause(() => { this.audioPlaying = false })
|
||||
this.audioContext.onStop(() => { this.audioPlaying = false })
|
||||
this.audioContext.onEnded(() => { this.audioPlaying = false })
|
||||
this.audioContext.onError(() => {
|
||||
this.audioPlaying = false
|
||||
uni.showToast({ title: '语音播放失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
if (this.audioPlaying) this.audioContext.pause()
|
||||
else this.audioContext.play()
|
||||
},
|
||||
openFile() {
|
||||
if (!this.attachment.url) return
|
||||
// #ifdef H5
|
||||
window.open(this.attachment.url, '_blank', 'noopener')
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: '正在打开文件' })
|
||||
uni.downloadFile({
|
||||
url: this.attachment.url,
|
||||
success: result => uni.openDocument({ filePath: result.tempFilePath, showMenu: true, fail: () => uni.showToast({ title: '暂不支持打开该文件', icon: 'none' }) }),
|
||||
fail: () => uni.showToast({ title: '文件下载失败', icon: 'none' }),
|
||||
complete: () => uni.hideLoading()
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
formatFileSize(size) {
|
||||
const value = Number(size || 0)
|
||||
if (!value) return '点击查看文件'
|
||||
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
},
|
||||
previewImage() {
|
||||
uni.previewImage({ urls: [this.message.content], current: this.message.content })
|
||||
},
|
||||
openCard() {
|
||||
if (this.card) this.$emit('open-card', { type: this.message.contentType, data: this.card.data })
|
||||
},
|
||||
openActions() {
|
||||
this.$emit('actions', this.message)
|
||||
},
|
||||
messagePreview(source) {
|
||||
const type = Number(source.contentType)
|
||||
if (type === CONTENT_TYPE.IMAGE) return '[图片]'
|
||||
if (type === CONTENT_TYPE.FILE) return '[文件]'
|
||||
if (type === CONTENT_TYPE.AUDIO) return '[语音]'
|
||||
if (type === CONTENT_TYPE.PRODUCT) return '[商品]'
|
||||
if (type === CONTENT_TYPE.ORDER) return '[订单]'
|
||||
if (type === CONTENT_TYPE.QUOTE) return '[引用消息]'
|
||||
return String(source.content || '').slice(0, 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
padding: 16rpx 34rpx;
|
||||
}
|
||||
|
||||
.message-row--self {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-row--recalled {
|
||||
align-items: center;
|
||||
padding-top: 8rpx;
|
||||
padding-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.recall-notice {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #85868e;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
flex: 0 0 84rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #f2ebff;
|
||||
}
|
||||
|
||||
.message-column {
|
||||
display: flex;
|
||||
max-width: 72%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-row--self .message-column {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
color: #9d9ea5;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.message-meta--self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-line {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.message-line--self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
overflow: hidden;
|
||||
border-radius: 25rpx;
|
||||
}
|
||||
|
||||
.message-content--service {
|
||||
border: 0;
|
||||
border-top-left-radius: 4rpx;
|
||||
background: #fff;
|
||||
color: #1a1a1d;
|
||||
}
|
||||
|
||||
.message-content--self {
|
||||
border-top-right-radius: 4rpx;
|
||||
background: #f2ebff;
|
||||
color: #17181d;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
display: block;
|
||||
padding: 22rpx 30rpx;
|
||||
font-size: 29rpx;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-text--muted {
|
||||
color: #5f6068;
|
||||
}
|
||||
|
||||
.message-content--image,
|
||||
.message-content--card {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-content--quote {
|
||||
min-width: 260rpx;
|
||||
}
|
||||
|
||||
.quote-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quote-source {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
margin: 14rpx 18rpx 0;
|
||||
padding: 12rpx 14rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(76, 55, 115, 0.08);
|
||||
color: #686970;
|
||||
}
|
||||
|
||||
.quote-source__name {
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quote-source__content {
|
||||
overflow: hidden;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quote-message__text {
|
||||
padding-top: 14rpx;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
display: block;
|
||||
width: 224rpx;
|
||||
min-height: 224rpx;
|
||||
max-height: 420rpx;
|
||||
border: 18rpx solid #f2ebff;
|
||||
border-radius: 24rpx;
|
||||
background: #f2ebff;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
display: flex;
|
||||
width: 500rpx;
|
||||
gap: 18rpx;
|
||||
padding: 18rpx;
|
||||
border: 1rpx solid #e7e7ec;
|
||||
border-radius: 20rpx;
|
||||
background: #fff;
|
||||
color: #1a1a1d;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
flex: 0 0 120rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-kind {
|
||||
color: #7934f6;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 6rpx;
|
||||
color: #1a1a1d;
|
||||
font-size: 27rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
overflow: hidden;
|
||||
margin-top: 6rpx;
|
||||
color: #5f6068;
|
||||
font-size: 23rpx;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-state {
|
||||
min-height: 40rpx;
|
||||
padding-top: 8rpx;
|
||||
color: #9d9ea5;
|
||||
font-size: 23rpx;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-state__loading {
|
||||
color: #777983;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
min-height: 44rpx;
|
||||
padding: 0 8rpx;
|
||||
background: transparent;
|
||||
color: #c93636;
|
||||
font-size: 21rpx;
|
||||
line-height: 44rpx;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 50rpx;
|
||||
height: 64rpx;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
line-height: 64rpx;
|
||||
}
|
||||
|
||||
.copy-button::after,
|
||||
.retry-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.audio-message,
|
||||
.file-message {
|
||||
display: flex;
|
||||
min-width: 220rpx;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin: 0;
|
||||
padding: 4rpx 0;
|
||||
background: transparent;
|
||||
color: #27272c;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.audio-message::after,
|
||||
.file-message::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.audio-duration {
|
||||
margin-left: auto;
|
||||
color: #777983;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.audio-block {
|
||||
display: flex;
|
||||
min-width: 240rpx;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.audio-transcript {
|
||||
padding-top: 10rpx;
|
||||
border-top: 1rpx solid rgba(121, 52, 246, 0.14);
|
||||
color: #35343b;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.55;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.transcribe-button {
|
||||
align-self: flex-start;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #6730d7;
|
||||
font-size: 23rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.transcribe-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.transcribe-button[disabled] {
|
||||
color: #8e8f96;
|
||||
}
|
||||
|
||||
.file-message {
|
||||
min-width: 360rpx;
|
||||
}
|
||||
|
||||
.file-message__body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-message__name {
|
||||
overflow: hidden;
|
||||
color: #27272c;
|
||||
font-size: 26rpx;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-message__meta {
|
||||
margin-top: 4rpx;
|
||||
color: #777983;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
</style>
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
# 信任桥商城 AI 智能客服产品需求文档
|
||||
|
||||
> 文档版本:v1.0(评审稿)
|
||||
> 编制日期:2026-07-17
|
||||
> 适用范围:商城 H5、微信小程序、App、客服管理后台、客服后端
|
||||
> 前置能力:现有客服会话、消息、文件、语音、商品/订单卡片、WebSocket
|
||||
|
||||
## 1. 结论
|
||||
|
||||
在现有客服系统中接入 AI 智能体可行,且不需要新建一套独立聊天系统。
|
||||
|
||||
推荐采用“同一会话、后端路由、AI 优先、按需转人工”的模式:用户仍进入现有客服页面;用户消息先由后端客服路由服务接收。在 AI 服务状态下,消息交给 AI 智能体回答,不通知真实客服;当用户表达“人工客服、人工、真人、转人工”等明确意图,或 AI 服务发生异常时,会话切换为待人工状态,再通过现有 WebSocket 通知客服后台。
|
||||
|
||||
AI 模型必须由商城后端调用,前端和小程序不得保存模型 API Key。模型供应商通过配置接入,首期可使用现有阿里云百炼/DashScope OpenAI 兼容接口,后续可替换模型而不改用户端。
|
||||
|
||||
## 2. 项目背景
|
||||
|
||||
目前客服系统已经具备:
|
||||
|
||||
- 用户端和后台端实时聊天。
|
||||
- 文本、图片、文件、语音、商品、订单和引用消息。
|
||||
- 消息撤回、删除、已读和语音转文字。
|
||||
- WebSocket 心跳、断线重连和未读提醒。
|
||||
- 客服主动联系会员。
|
||||
- 后台查看会员、浏览商品和交易订单。
|
||||
|
||||
当前所有用户消息都会进入人工客服工作台。业务量增长后,商品规则、物流时效、退换货流程等重复问题会占用大量人工坐席。本项目通过 AI 完成首轮接待,仅将确实需要人工处理的会话推送给真实客服。
|
||||
|
||||
## 3. 产品目标
|
||||
|
||||
### 3.1 核心目标
|
||||
|
||||
1. 用户进入客服页后首先与 AI 智能客服交流。
|
||||
2. AI 能回答商城规则、商品、订单、物流和售后等常见问题。
|
||||
3. 用户明确要求人工时,立即停止 AI 自动回复并通知真实客服。
|
||||
4. 未触发人工的 AI 会话不进入人工待处理提醒,避免坐席被无效消息打扰。
|
||||
5. 转人工时自动向客服提供问题摘要、相关商品/订单和最近对话,减少重复询问。
|
||||
6. AI 故障时不阻断现有客服能力,可自动降级到人工客服。
|
||||
|
||||
### 3.2 建议成功指标
|
||||
|
||||
- AI 首次响应时间 P95 小于 5 秒。
|
||||
- 用户消息处理成功率不低于 99.9%。
|
||||
- 明确转人工意图识别召回率不低于 99%。
|
||||
- “不需要人工”等否定表达误转率低于 1%。
|
||||
- 人工接入后 AI 继续自动回复的并发事故为 0。
|
||||
- 首期 AI 独立解决率达到 30% 后再逐步扩大流量。
|
||||
|
||||
### 3.3 本期不做
|
||||
|
||||
- AI 自动退款、改价、取消订单等资金或订单写操作。
|
||||
- AI 代替人工作出赔付承诺。
|
||||
- AI 主动营销和批量外呼。
|
||||
- 多智能体协作编排。
|
||||
- 将用户隐私数据用于外部模型训练。
|
||||
|
||||
## 4. 产品原则
|
||||
|
||||
1. **AI 身份透明**:明确显示“AI 智能客服”,不能伪装成真人。
|
||||
2. **转人工随时可用**:用户可以输入关键词,也可以点击“转人工”按钮。
|
||||
3. **人工优先**:进入待人工或人工服务状态后,AI 立即停止业务回答。
|
||||
4. **事实来自系统**:订单、物流、价格等动态数据必须通过后端工具查询,禁止模型凭空编造。
|
||||
5. **最小数据传输**:只向模型发送回答当前问题所必需的信息。
|
||||
6. **可降级**:模型超时、限流或不可用时,客服系统仍能正常保存消息并转人工。
|
||||
|
||||
## 5. 角色定义
|
||||
|
||||
| 角色 | 说明 |
|
||||
| --- | --- |
|
||||
| 会员 | 商城已登录用户,使用本人头像和名称 |
|
||||
| AI 智能客服 | 默认首轮接待,使用 TB 客服头像并显示 AI 标识 |
|
||||
| 人工客服 | 后台坐席,可接管、回复、结束人工服务或交回 AI |
|
||||
| 客服管理员 | 配置 AI 开关、转人工规则、知识库、服务时间和数据报表 |
|
||||
|
||||
## 6. 会话状态
|
||||
|
||||
每个客服会话只能处于以下一种服务状态:
|
||||
|
||||
| 状态 | 含义 | AI 是否回复 | 是否通知人工 |
|
||||
| --- | --- | --- | --- |
|
||||
| `AI_ACTIVE` | AI 正在接待 | 是 | 否 |
|
||||
| `HUMAN_PENDING` | 已申请人工,等待接入 | 否,仅发送一次系统提示 | 是 |
|
||||
| `HUMAN_ACTIVE` | 人工客服已接管 | 否 | 是 |
|
||||
| `CLOSED` | 本轮服务结束 | 否 | 否 |
|
||||
|
||||
状态流转:
|
||||
|
||||
```text
|
||||
进入客服 / 重新咨询
|
||||
↓
|
||||
AI_ACTIVE
|
||||
├─ 普通咨询 → AI 回复 → AI_ACTIVE
|
||||
├─ 转人工意图 → HUMAN_PENDING → 客服接入 → HUMAN_ACTIVE
|
||||
└─ AI 故障/高风险 → HUMAN_PENDING
|
||||
|
||||
HUMAN_ACTIVE
|
||||
├─ 客服结束会话 → CLOSED
|
||||
└─ 客服交回 AI → AI_ACTIVE
|
||||
```
|
||||
|
||||
状态切换必须在后端事务中完成,不能仅靠前端控制。人工接入与 AI 回复并发时,以人工接入为最高优先级;AI 返回结果前必须再次检查会话状态,状态已改变则丢弃 AI 回复。
|
||||
|
||||
## 7. 用户端需求
|
||||
|
||||
### 7.1 首次进入
|
||||
|
||||
页面标题保持“全媒体智能客服”,聊天区显示欢迎消息:
|
||||
|
||||
> 您好,我是 AI 智能客服,可以帮您查询商品、订单、物流和售后问题。如需人工服务,请输入“人工客服”或点击“转人工”。
|
||||
|
||||
AI 消息使用现有 TB 客服头像,并在名称旁显示“AI”标识。人工接入后名称和状态切换为实际客服名称或“人工客服”。
|
||||
|
||||
### 7.2 AI 对话
|
||||
|
||||
- 用户文本消息先保存,再异步交给 AI,避免模型失败导致消息丢失。
|
||||
- 等待回复时显示“AI 正在输入…”。
|
||||
- AI 回复按正常客服消息存储并通过 WebSocket 推送。
|
||||
- 单次回复建议控制在 300 字以内,复杂步骤使用短段落或编号。
|
||||
- 商品、订单类回答优先附带现有商品卡片或订单卡片。
|
||||
- AI 无法确认时明确说明,并引导用户补充信息或转人工。
|
||||
|
||||
### 7.3 转人工
|
||||
|
||||
支持两种入口:
|
||||
|
||||
1. 用户输入明确转人工表达。
|
||||
2. 输入区“+”面板提供固定的“转人工”按钮。
|
||||
|
||||
触发后立即显示:
|
||||
|
||||
> 已为您通知人工客服,请稍候。等待期间您可以继续发送消息。
|
||||
|
||||
进入 `HUMAN_PENDING` 后:
|
||||
|
||||
- AI 不再回答业务问题。
|
||||
- 用户后续消息继续保存,并实时推送后台。
|
||||
- 重复输入“人工”不重复创建请求、不重复播放提醒音。
|
||||
- 客服接入后显示“人工客服已接入”。
|
||||
|
||||
### 7.4 结束人工服务
|
||||
|
||||
客服结束服务后显示系统消息:
|
||||
|
||||
> 本次人工服务已结束,如有其他问题可以继续咨询 AI 智能客服。
|
||||
|
||||
用户再次发送消息时,可以创建新一轮 `AI_ACTIVE` 服务。
|
||||
|
||||
## 8. 转人工识别规则
|
||||
|
||||
### 8.1 P0 规则
|
||||
|
||||
首期采用“规则优先 + 意图识别补充”的方式,不能只使用简单的字符串包含判断。
|
||||
|
||||
默认正向词库:
|
||||
|
||||
- 人工
|
||||
- 人工客服
|
||||
- 转人工
|
||||
- 找人工
|
||||
- 真人
|
||||
- 真人客服
|
||||
- 客服人员
|
||||
- 找个人
|
||||
- 让客服联系我
|
||||
- 我要投诉
|
||||
|
||||
支持同音、空格、标点和常见变体,例如“人 工”“转一下人工”“找真人聊”“人工!”。词库必须支持后台配置,修改后无需重新发布前端。
|
||||
|
||||
### 8.2 否定表达
|
||||
|
||||
以下表达不能触发转人工:
|
||||
|
||||
- 不需要人工
|
||||
- 不用转人工
|
||||
- 先不用真人
|
||||
- AI 就可以
|
||||
- 机器人回答就行
|
||||
|
||||
判断顺序:文本标准化 → 否定规则 → 明确转人工规则 → 可选语义意图识别。转人工检测由后端完成,前端检测只能用于交互加速,不能作为最终依据。
|
||||
|
||||
### 8.3 系统自动转人工
|
||||
|
||||
除用户主动要求外,以下系统异常必须自动转人工,避免用户卡死:
|
||||
|
||||
- AI 请求连续两次超时或失败。
|
||||
- AI 返回内容被安全审核拦截。
|
||||
- 当前问题涉及退款争议、投诉、账户安全且 AI 无法给出确定流程。
|
||||
- AI 连续两轮判断“无法回答”。
|
||||
|
||||
系统自动转人工时,`transferReason` 必须记录具体原因。此类规则可在后台单独开关。
|
||||
|
||||
## 9. 人工客服后台需求
|
||||
|
||||
### 9.1 会话分组
|
||||
|
||||
后台会话列表增加:
|
||||
|
||||
- **待人工**:`HUMAN_PENDING`,默认置顶并显示红点、提醒音。
|
||||
- **服务中**:`HUMAN_ACTIVE`。
|
||||
- **AI 会话**:`AI_ACTIVE`,默认不提醒,仅供查看和质检。
|
||||
- **已结束**:`CLOSED`。
|
||||
|
||||
只有进入 `HUMAN_PENDING` 的会话产生新人工任务提醒。AI 正常问答不增加人工未读数量,不播放提醒音。
|
||||
|
||||
### 9.2 待人工卡片
|
||||
|
||||
会话列表至少显示:
|
||||
|
||||
- 用户头像、名称、用户 ID。
|
||||
- 等待时长。
|
||||
- 转人工原因。
|
||||
- 最近一条用户消息。
|
||||
- 是否携带商品或订单。
|
||||
|
||||
### 9.3 人工接管
|
||||
|
||||
客服点击“接入”后:
|
||||
|
||||
1. 后端原子地将状态改为 `HUMAN_ACTIVE` 并绑定客服 ID。
|
||||
2. 其他客服看到“已由某某接入”,不能重复接入。
|
||||
3. 用户端收到接入事件。
|
||||
4. AI 停止生成和发送消息。
|
||||
|
||||
客服也可以从 AI 会话中主动点击“接管”,此操作直接进入 `HUMAN_ACTIVE`。
|
||||
|
||||
### 9.4 AI 移交摘要
|
||||
|
||||
转人工时后台顶部显示 AI 自动摘要:
|
||||
|
||||
- 用户当前诉求。
|
||||
- 已确认的关键信息。
|
||||
- 相关商品、订单、物流或售后单。
|
||||
- AI 已提供过的解决方案。
|
||||
- 转人工原因。
|
||||
|
||||
摘要只是辅助信息,原始聊天记录必须完整保留。
|
||||
|
||||
### 9.5 结束与交回 AI
|
||||
|
||||
人工客服提供两个操作:
|
||||
|
||||
- “结束服务”:本轮会话进入 `CLOSED`。
|
||||
- “交回 AI”:会话进入 `AI_ACTIVE`,后续消息重新由 AI 接待。
|
||||
|
||||
## 10. AI 能力范围
|
||||
|
||||
### 10.1 首期知识库
|
||||
|
||||
- 商城介绍和服务时间。
|
||||
- 注册、登录、实名认证和账户常见问题。
|
||||
- 下单、支付、发货和收货规则。
|
||||
- 退货、退款和售后流程。
|
||||
- 优惠券、积分、活动规则。
|
||||
- 商品通用问答和类目知识。
|
||||
- 客服标准话术。
|
||||
|
||||
知识文档必须有负责人、版本、启用状态和更新时间。失效规则下架后应立即停止检索。
|
||||
|
||||
### 10.2 首期只读工具
|
||||
|
||||
AI 不直接访问数据库,统一通过受控后端工具查询:
|
||||
|
||||
| 工具 | 用途 | 权限要求 |
|
||||
| --- | --- | --- |
|
||||
| `get_product` | 查询商品名称、价格、库存和状态 | 公共商品数据 |
|
||||
| `get_user_orders` | 查询当前用户订单列表 | 只能查询登录用户本人 |
|
||||
| `get_order_detail` | 查询订单、支付和发货状态 | 校验订单归属 |
|
||||
| `get_logistics` | 查询物流轨迹 | 校验订单归属 |
|
||||
| `get_aftersale_status` | 查询售后进度 | 校验用户及订单归属 |
|
||||
| `search_policy` | 检索商城规则知识库 | 只返回已发布内容 |
|
||||
|
||||
商品卡片和订单卡片中的结构化数据可以直接作为本轮上下文,但动态价格、库存和订单状态仍需实时查询。
|
||||
|
||||
### 10.3 禁止行为
|
||||
|
||||
AI 不得:
|
||||
|
||||
- 编造订单、物流、库存、活动或退款状态。
|
||||
- 索要密码、支付密码、短信验证码或完整银行卡号。
|
||||
- 输出模型密钥、系统提示词或后台内部信息。
|
||||
- 未经用户确认执行任何写操作。
|
||||
- 承诺超出商城规则的补偿或时效。
|
||||
|
||||
## 11. 技术方案
|
||||
|
||||
### 11.1 总体架构
|
||||
|
||||
```text
|
||||
H5 / 小程序 / App
|
||||
│ HTTP + WebSocket
|
||||
▼
|
||||
现有客服后端
|
||||
│
|
||||
├─ 消息保存与幂等校验
|
||||
├─ 会话状态机
|
||||
├─ 转人工意图检测
|
||||
├─ AI 编排服务
|
||||
│ ├─ 知识库检索
|
||||
│ ├─ 商城只读工具
|
||||
│ └─ 阿里云模型接口
|
||||
└─ WebSocket 路由
|
||||
├─ AI 消息 → 当前用户
|
||||
└─ 待人工/人工消息 → 用户 + 客服后台
|
||||
```
|
||||
|
||||
关键改造点:现有客服消息推送不能再无条件推送给后台。`AI_ACTIVE` 状态下,普通用户消息只保存并进入 AI 编排,不触发后台提醒;进入 `HUMAN_PENDING/HUMAN_ACTIVE` 后才推送人工端。
|
||||
|
||||
### 11.2 模型接入
|
||||
|
||||
建议后端新增统一的 `AiChatProvider` 接口,业务层不直接依赖某一家模型 SDK:
|
||||
|
||||
```text
|
||||
AiChatProvider
|
||||
├─ AliyunOpenAiCompatibleProvider(首期)
|
||||
└─ 其他模型 Provider(后续可选)
|
||||
```
|
||||
|
||||
配置项至少包括:
|
||||
|
||||
- AI 总开关和灰度比例。
|
||||
- API Base URL。
|
||||
- API Key(只允许服务器环境变量或密钥管理服务保存)。
|
||||
- 模型名称。
|
||||
- 请求超时、最大重试次数、最大上下文长度。
|
||||
- 系统提示词版本和知识库版本。
|
||||
|
||||
严禁将 API Key 写入 Git、前端 `.env`、小程序代码或消息内容。日志中也必须脱敏。
|
||||
|
||||
### 11.3 上下文策略
|
||||
|
||||
每次模型请求建议包含:
|
||||
|
||||
- 固定系统规则。
|
||||
- 最近 10 至 20 条有效消息。
|
||||
- 当前会话摘要。
|
||||
- 用户主动发送的商品/订单上下文。
|
||||
- 经权限校验后的工具查询结果。
|
||||
- 知识库命中片段及版本。
|
||||
|
||||
长会话通过摘要压缩,不能无限携带全部历史消息。撤回或仅本地删除的消息需要按现有业务规则决定是否进入 AI 上下文。
|
||||
|
||||
### 11.4 并发与幂等
|
||||
|
||||
- 每条用户消息使用唯一请求 ID。
|
||||
- 同一会话的 AI 任务串行处理,避免回复顺序错乱。
|
||||
- AI 返回前再次检查会话状态和任务版本。
|
||||
- WebSocket 重复推送不得造成重复消息。
|
||||
- 转人工状态切换使用数据库条件更新或分布式锁,避免多客服同时接入。
|
||||
|
||||
## 12. 数据设计建议
|
||||
|
||||
### 12.1 会话扩展字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `service_mode` | varchar | `AI_ACTIVE/HUMAN_PENDING/HUMAN_ACTIVE/CLOSED` |
|
||||
| `assigned_admin_id` | bigint | 当前接入客服 ID |
|
||||
| `transfer_reason` | varchar | 用户主动、AI 超时、无法回答等 |
|
||||
| `transfer_time` | datetime | 进入待人工时间 |
|
||||
| `ai_session_id` | varchar | 模型侧会话标识,可为空 |
|
||||
| `ai_summary` | text | 移交摘要 |
|
||||
| `ai_prompt_version` | varchar | 提示词版本 |
|
||||
| `state_version` | int | 状态并发控制版本 |
|
||||
|
||||
### 12.2 消息扩展建议
|
||||
|
||||
现有 `senderType` 建议增加 AI 类型,例如:
|
||||
|
||||
- `1`:人工客服。
|
||||
- `2`:会员。
|
||||
- `3`:AI 智能客服。
|
||||
- `4`:系统事件。
|
||||
|
||||
AI 调用明细建议单独建表,不把大量调试信息塞入消息正文:请求 ID、模型、耗时、Token 用量、知识命中、工具调用、结果状态、错误码和提示词版本。
|
||||
|
||||
## 13. 接口建议
|
||||
|
||||
可复用现有发送和消息查询接口,并新增以下能力:
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/kefu/user/transfer-human` | POST | 用户主动申请人工,幂等 |
|
||||
| `/kefu/conversation/takeover` | POST | 客服接管会话 |
|
||||
| `/kefu/conversation/return-ai` | POST | 客服交回 AI |
|
||||
| `/kefu/conversation/close-service` | POST | 结束本轮服务 |
|
||||
| `/kefu/ai/config` | GET/PUT | AI 和转人工规则配置 |
|
||||
| `/kefu/ai/knowledge/*` | CRUD | 知识文档管理 |
|
||||
| `/kefu/ai/records` | GET | AI 调用和质检记录 |
|
||||
|
||||
建议新增 WebSocket 事件:
|
||||
|
||||
| 事件 | 接收端 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `kefu_ai_typing` | 用户端 | AI 正在生成回复 |
|
||||
| `kefu_transfer_requested` | 后台 | 新的待人工任务 |
|
||||
| `kefu_human_joined` | 用户端、后台 | 人工已接入 |
|
||||
| `kefu_returned_to_ai` | 用户端、后台 | 已交回 AI |
|
||||
| `kefu_service_closed` | 用户端、后台 | 本轮服务结束 |
|
||||
|
||||
## 14. 后台配置
|
||||
|
||||
客服管理后台增加“AI 客服设置”:
|
||||
|
||||
- AI 总开关。
|
||||
- 灰度用户比例和商户范围。
|
||||
- 模型地址、模型名称和连通性测试。
|
||||
- 转人工关键词、否定词和系统自动转人工规则。
|
||||
- 欢迎语、等待语、离线语。
|
||||
- 人工服务时间和超时提醒。
|
||||
- 知识库文档管理。
|
||||
- AI 会话抽检、错误记录和效果报表。
|
||||
|
||||
API Key 只支持重新设置,页面不得回显完整值。
|
||||
|
||||
## 15. 异常与降级
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
| --- | --- |
|
||||
| 模型单次超时 | 重试一次,期间保留“正在输入”状态 |
|
||||
| 连续失败两次 | 转 `HUMAN_PENDING` 并通知人工 |
|
||||
| 模型限流 | 短暂排队;超过阈值转人工 |
|
||||
| 知识库不可用 | 仅回答可确认问题,否则转人工 |
|
||||
| WebSocket 断开 | 现有自动重连;消息通过历史接口补齐 |
|
||||
| 人工均不在线 | 告知已留言和服务时间,保留待处理任务 |
|
||||
| AI 总开关关闭 | 新消息直接按现有人工客服流程处理 |
|
||||
|
||||
## 16. 安全与合规
|
||||
|
||||
- 模型调用由后端完成,所有工具调用必须复用登录身份和数据权限。
|
||||
- 订单、地址、手机号等敏感信息按回答需要最小化传输并脱敏。
|
||||
- 用户输入、模型输出和工具结果进行安全审查。
|
||||
- 保存模型调用审计记录,支持按会话追溯。
|
||||
- 用户删除本人聊天记录不等于删除平台审计记录,具体保留周期需由公司隐私政策确定。
|
||||
- 测试环境和生产环境使用不同密钥、知识库和日志配置。
|
||||
|
||||
## 17. 埋点与报表
|
||||
|
||||
建议统计:
|
||||
|
||||
- AI 会话数、AI 消息数、平均响应时间。
|
||||
- AI 独立解决率。
|
||||
- 转人工率和各转人工原因占比。
|
||||
- 关键词触发率、否定词拦截率、误转人工率。
|
||||
- 人工首次响应时间和平均等待时间。
|
||||
- 模型错误率、超时率和 Token 成本。
|
||||
- 知识库命中率、未命中问题排行。
|
||||
- 用户“有帮助/没帮助”评价。
|
||||
|
||||
## 18. 验收标准
|
||||
|
||||
### 18.1 AI 接待
|
||||
|
||||
- 新会话默认进入 `AI_ACTIVE`。
|
||||
- 普通问题由 AI 回复,后台不产生人工提醒和人工未读数。
|
||||
- AI 消息正确显示 AI 身份、头像、时间和消息状态。
|
||||
- 商品和订单问题能使用真实结构化数据回答。
|
||||
|
||||
### 18.2 转人工
|
||||
|
||||
- 输入“人工客服、人工、真人、转人工”等表达后 1 秒内进入待人工状态。
|
||||
- 输入“不需要人工、先不用真人”等否定表达不转人工。
|
||||
- 转人工后 AI 不再发送业务回复。
|
||||
- 后台只产生一次待人工提醒,并显示原因和等待时长。
|
||||
- 一名客服接入后,其他客服不能重复接入。
|
||||
- 用户端及时显示人工已接入。
|
||||
|
||||
### 18.3 异常
|
||||
|
||||
- 模型超时或不可用时消息不丢失。
|
||||
- 达到失败阈值后自动转人工。
|
||||
- WebSocket 重连后不重复显示 AI 消息。
|
||||
- 关闭 AI 开关后现有人工客服功能完整可用。
|
||||
|
||||
### 18.4 跨端
|
||||
|
||||
- H5、微信小程序和 App 核心状态、消息和转人工行为一致。
|
||||
- 小程序前端包内不存在模型 API Key。
|
||||
- 切后台、弱网和网络恢复后会话状态正确。
|
||||
|
||||
## 19. 分阶段实施
|
||||
|
||||
### 阶段一:基础路由与转人工
|
||||
|
||||
- 增加会话状态机。
|
||||
- 接入一个后端 AI Provider。
|
||||
- 实现关键词、否定词和固定转人工按钮。
|
||||
- 后台增加待人工分组、接入和结束服务。
|
||||
- AI 暂只回答通用知识库问题。
|
||||
|
||||
### 阶段二:商城数据工具
|
||||
|
||||
- 接入商品、订单、物流和售后只读工具。
|
||||
- 增加移交摘要。
|
||||
- 增加知识库管理、调用日志和基础报表。
|
||||
|
||||
### 阶段三:灰度和优化
|
||||
|
||||
- 先内部账号和测试环境验证。
|
||||
- 生产环境按 10% → 30% → 50% → 100% 灰度。
|
||||
- 根据未命中问题和误转案例优化词库、知识库和提示词。
|
||||
- 达到稳定指标后再评估有限的写操作能力。
|
||||
|
||||
## 20. 开发工作量判断
|
||||
|
||||
本项目难度为中等。聊天 UI、消息持久化和 WebSocket 已经具备,主要工作不在重新开发聊天页面,而在后端增加会话状态机、AI 编排、推送路由和后台待人工工作流。
|
||||
|
||||
推荐开发顺序:
|
||||
|
||||
1. 会话状态和消息发送方扩展。
|
||||
2. 转人工规则与后台提醒路由。
|
||||
3. AI Provider 和基础问答。
|
||||
4. 后台接入/结束/交回 AI。
|
||||
5. 商品、订单等只读工具。
|
||||
6. 知识库、摘要、日志、报表与灰度。
|
||||
|
||||
首期应优先保证转人工可靠和 AI 可随时关闭,再逐步提高 AI 回答能力。
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Design System
|
||||
|
||||
## Overview
|
||||
|
||||
HashMall 客服前台采用轻量、克制的移动产品界面。用户在商城浏览或订单处理过程中进入,因此页面应像商城内部的原生工具,而不是独立活动页。默认使用浅色主题,消息阅读区保持低干扰。
|
||||
|
||||
## Color
|
||||
|
||||
- Brand primary: `#7934F6`
|
||||
- Brand pressed: `#6426D8`
|
||||
- Brand soft: `#F2EBFF`
|
||||
- Page background: `#F5F5F7`
|
||||
- Surface: `#FFFFFF`
|
||||
- Primary text: `#1A1A1D`
|
||||
- Secondary text: `#5F6068`
|
||||
- Muted text: `#777983`
|
||||
- Divider: `#E7E7EC`
|
||||
- Success: `#168A5B`
|
||||
- Warning: `#A45A00`
|
||||
- Error: `#C93636`
|
||||
|
||||
品牌色只用于主按钮、本人消息气泡、焦点和少量状态标识。客服消息使用白色表面和清晰正文色。
|
||||
|
||||
## Typography
|
||||
|
||||
使用平台系统无衬线字体。H5 优先 `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`,小程序沿用系统字体。
|
||||
|
||||
- Page title: 17px / 600
|
||||
- Section or state title: 16px / 600
|
||||
- Message body: 15px / 400, line-height 1.55
|
||||
- Supporting text: 13px / 400
|
||||
- Caption and timestamp: 12px / 400
|
||||
|
||||
消息正文允许换行和长字符串断行,不使用装饰性字体。
|
||||
|
||||
## Layout
|
||||
|
||||
- 页面采用纵向三段结构:状态/导航、可滚动消息区、固定输入区。
|
||||
- 内容宽度在桌面 H5 最大 720px,移动端占满视口。
|
||||
- 页面横向安全间距 16px,气泡最大宽度约 76%。
|
||||
- 使用 4、8、12、16、24px 的间距节奏。
|
||||
- 输入区适配底部安全区和软键盘,不用固定像素高度锁死页面。
|
||||
|
||||
## Components
|
||||
|
||||
### Message bubble
|
||||
|
||||
- 本人消息:紫色实底、白字,圆角 14px,右上角保留较小圆角以区分方向。
|
||||
- 客服消息:白色实底、深色文字,可使用 1px 中性边界,不叠加宽阴影。
|
||||
- 气泡内文本可选中;URL、图片、卡片按明确组件渲染。
|
||||
|
||||
### Composer
|
||||
|
||||
- 白色表面与顶部细分隔线。
|
||||
- 文本输入框使用中性浅灰背景,圆角 12px。
|
||||
- 图片与更多操作使用至少 44px 的触控区。
|
||||
- 发送按钮使用品牌色;空文本时为明确禁用状态。
|
||||
|
||||
### Context card
|
||||
|
||||
- 商品或订单上下文在输入区上方单层展示。
|
||||
- 使用缩略图、两行标题和一组关键元数据,不嵌套卡片。
|
||||
- 提供“发送”与“移除”两个清晰动作。
|
||||
|
||||
### Status feedback
|
||||
|
||||
- 连接中、离线、轮询中显示在顶部状态条,状态文字与图标同时变化。
|
||||
- 发送失败在对应消息旁展示“重新发送”,不使用全屏弹窗。
|
||||
- 首屏使用消息骨架,空状态提供简短欢迎语和可执行提示。
|
||||
|
||||
## Motion
|
||||
|
||||
- 状态切换和新消息定位使用 150 至 220ms 的 ease-out。
|
||||
- 不做页面入场编排或弹跳动画。
|
||||
- 系统启用减少动态效果时取消非必要过渡。
|
||||
|
||||
## Responsive and platform behavior
|
||||
|
||||
- H5 与微信小程序均使用 uni-app 原生组件。
|
||||
- Socket 使用 `uni.connectSocket`,图片使用 `uni.chooseMedia`/兼容接口和 `uni.previewImage`。
|
||||
- H5 处理 `100dvh` 与 iOS 软键盘;小程序处理 `safe-area-inset-bottom`、导航栏和 `cursor-spacing`。
|
||||
- 不使用浏览器全局 `WebSocket`、DOM 文件选择器或 iframe 作为核心能力。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 接入 HashMall 商城
|
||||
|
||||
独立项目验证完成后,将 `src/pages/customer-service`、`src/components/customer-service`、`src/api`、`src/services`、`src/config`、`src/adapters` 和客服静态资源合入商城工程。
|
||||
|
||||
## 1. 注册页面
|
||||
|
||||
建议在商城 `pages.json` 中将客服页放入分包:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "pages/customer_service_package",
|
||||
"pages": [
|
||||
{
|
||||
"path": "chat/chat",
|
||||
"style": {
|
||||
"navigationBarTitleText": "在线客服",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
合入分包时需要同步调整 `hashmall.js` 中的 `CHAT_ROUTE` 和页面内 `@/` 引用路径。
|
||||
|
||||
## 2. 初始化商城适配
|
||||
|
||||
商城启动后设置登录页。应用级 Authorization 应由商城现有配置注入,不要复制到客服源码或提交 Git。
|
||||
|
||||
```js
|
||||
configureHashMallIntegration({
|
||||
loginPage: '/pages/login_package/login/login',
|
||||
appAuthorization: Authorization
|
||||
})
|
||||
```
|
||||
|
||||
客服请求会继续读取商城已有的:
|
||||
|
||||
- Token:`fcc-token`
|
||||
- 用户缓存:`fcc-user-data`
|
||||
- 用户详情接口:`/user/getUserInfo`
|
||||
|
||||
## 3. “我的”页入口
|
||||
|
||||
把当前客服电话弹窗或第三方客服跳转替换为:
|
||||
|
||||
```js
|
||||
openCustomerService()
|
||||
```
|
||||
|
||||
## 4. 商品详情入口
|
||||
|
||||
```js
|
||||
openProductConsultation({
|
||||
goodsId: goods.id,
|
||||
goodsName: goods.name,
|
||||
goodsMainGraph: goods.mainGraph,
|
||||
currentPrice: goods.currentPrice
|
||||
})
|
||||
```
|
||||
|
||||
页面只把该对象作为待发送预览。正式发送前后端仍需按 `goodsId` 校验并补齐可信商品信息。
|
||||
|
||||
## 5. 订单入口
|
||||
|
||||
```js
|
||||
openOrderConsultation({
|
||||
id: order.id,
|
||||
orderNo: order.orderNo,
|
||||
goodsName: order.goodsName,
|
||||
goodsMainGraph: order.goodsMainGraph,
|
||||
payAmount: order.payAmount
|
||||
})
|
||||
```
|
||||
|
||||
正式发送前后端必须校验订单归属当前登录用户。
|
||||
|
||||
## 6. 小程序配置
|
||||
|
||||
微信公众平台需要配置测试或正式环境的:
|
||||
|
||||
- request 合法域名
|
||||
- socket 合法域名
|
||||
- uploadFile 合法域名
|
||||
- downloadFile 合法域名
|
||||
|
||||
测试环境 API 为 `https://api.o.tbmall.xin`,Socket 使用对应的 `wss://api.o.tbmall.xin`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
HashMall 已登录会员在浏览商品、查看订单或使用个人中心时,通过 H5 或微信小程序联系平台客服。用户通常带着明确问题进入,需要快速恢复历史会话、提供商品或订单上下文,并在弱网或切换应用后继续沟通。
|
||||
|
||||
## Product Purpose
|
||||
|
||||
提供 HashMall 自有的跨端客服聊天体验,替代电话号码弹窗和第三方 iframe 客服。产品需要可靠完成文本、图片、商品卡片和订单卡片的双向消息收发,复用现有后台客服工作台,并保证用户身份、会话归属与消息恢复正确。
|
||||
|
||||
## Brand Personality
|
||||
|
||||
清晰、可靠、克制。界面延续商城现有紫色品牌识别,但客服工具的重点是阅读和处理消息,品牌色只用于关键动作、本人消息和连接状态,不用于大面积装饰。
|
||||
|
||||
## Anti-references
|
||||
|
||||
- 不做第三方网页嵌套式客服,不使用 iframe 作为核心界面。
|
||||
- 不模仿社交应用的娱乐化皮肤、夸张气泡或装饰动画。
|
||||
- 不使用大面积渐变、玻璃拟态、过度圆角和层层卡片。
|
||||
- 不把网络错误、发送失败和登录失效隐藏在无反馈的加载状态中。
|
||||
- 不依赖仅在浏览器存在的 API,避免 H5 可用而小程序失效。
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. 消息优先:视觉层级首先服务于消息阅读、输入和状态判断。
|
||||
2. 失败可恢复:每个网络动作都有明确状态,草稿、失败消息和断线消息能够恢复。
|
||||
3. 上下文由用户确认:商品和订单信息先展示,再由用户主动发送。
|
||||
4. 跨端一致,遵循平台:业务能力一致,键盘、导航、图片选择和返回行为遵循终端习惯。
|
||||
5. 身份来自可信登录态:页面参数只描述咨询场景,不决定用户身份或数据权限。
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
以 WCAG 2.1 AA 为基线。正文和状态文字保持足够对比度;连接、失败和已读状态不只依赖颜色;触控区域不小于 44px;支持系统减少动态效果;消息内容允许系统字体缩放,并兼顾长文本、无图片和慢网用户。
|
||||
@@ -0,0 +1,506 @@
|
||||
# HashMall 前台客服系统产品需求文档
|
||||
|
||||
> 文档状态:已确认插件化方案,进入开发
|
||||
> 文档版本:v0.2
|
||||
> 编制日期:2026-07-16
|
||||
> 插件目录:`frontend-web/uni_modules/hashmall-customer-service/`
|
||||
> 目标终端:H5、微信小程序,结构上兼容后续 App 接入
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文档定义 HashMall 商城前台客服系统的产品范围、用户流程、交互要求、接口依赖、跨端兼容、安全前置条件和验收标准。
|
||||
|
||||
客服前台以 `uni_modules` 插件形式直接内嵌现有 `frontend-web` 商城工程,复用宿主登录态、请求封装、页面生命周期和构建流程;不创建、部署或运行独立客服前端服务。
|
||||
|
||||
## 2. 项目背景
|
||||
|
||||
HashMall 已经完成客服系统的主要后端能力和管理后台客服工作台,商城前台目前仍使用第三方 H5 客服页面或电话号码弹窗,没有接入自有客服会话。
|
||||
|
||||
现有前台客服入口包括:
|
||||
|
||||
- “我的”页服务工具中的“联系客服”。
|
||||
- 商品详情页的客服入口。
|
||||
- 订单列表或订单详情中的售后咨询入口。
|
||||
- 现有 `pages/rwa_package/setting/kefu.vue` 通过 `iframe` 加载第三方客服 H5。
|
||||
|
||||
微信小程序不能沿用当前 `iframe` 方案;即使改用小程序 `web-view`,也会受业务域名、登录态透传、返回导航和页面体验限制。因此新客服前台应采用 uni-app 原生页面与组件实现,不依赖 iframe。
|
||||
|
||||
## 3. 现状梳理
|
||||
|
||||
### 3.1 已完成的后端能力
|
||||
|
||||
当前 `backend/JST-ERP` 分支已经包含:
|
||||
|
||||
- 客服会话、客服消息、商品浏览历史数据表和 DAO。
|
||||
- 用户发送消息、查询历史消息、查询浏览记录、查询订单、记录商品浏览等接口。
|
||||
- 后台会话列表、会话详情、置顶、删除、消息发送和已读接口。
|
||||
- `/ws/kefu` 与 `/api/ws/kefu` WebSocket 端点。
|
||||
- 新消息、会话变化、已读状态变化的实时推送。
|
||||
- 文本、图片、商品卡片和订单卡片四种消息类型。
|
||||
|
||||
### 3.2 已完成的管理后台能力
|
||||
|
||||
当前管理后台已经包含:
|
||||
|
||||
- 会话列表、置顶、删除、未读数量。
|
||||
- 客服与会员聊天区。
|
||||
- 文本、表情和图片消息。
|
||||
- 会员资料、最近浏览和交易订单侧栏。
|
||||
- WebSocket 实时刷新与断线重连。
|
||||
- 图片上传后发送可访问 URL。
|
||||
|
||||
### 3.3 前台现状与缺口
|
||||
|
||||
当前商城前台:
|
||||
|
||||
- 没有自有客服聊天页。
|
||||
- “我的”页客服入口主要弹出电话联系方式。
|
||||
- 部分入口跳转到第三方 H5 客服页面。
|
||||
- 没有复用自有客服消息接口。
|
||||
- 没有接入自有客服 WebSocket。
|
||||
- 没有发送商品卡片、订单卡片的完整流程。
|
||||
- 没有客服消息发送状态、失败重试、断线恢复和历史消息分页体验。
|
||||
- 商品详情页尚未统一接入客服浏览记录上报。
|
||||
|
||||
## 4. 产品目标
|
||||
|
||||
### 4.1 核心目标
|
||||
|
||||
1. 用户能够从商城关键场景进入自有客服聊天页。
|
||||
2. 用户与后台客服能够实时收发消息并查看历史记录。
|
||||
3. H5 与微信小程序保持一致的核心功能和主要视觉体验。
|
||||
4. 用户可以携带商品或订单上下文发起咨询,减少描述成本。
|
||||
5. 网络异常、切后台、WebSocket 断开时不丢消息,并能自动恢复。
|
||||
6. 用户只能访问本人的会话和消息,不能通过修改参数读取他人数据。
|
||||
|
||||
### 4.2 成功指标
|
||||
|
||||
首期上线后建议关注:
|
||||
|
||||
- 客服入口点击至聊天页成功率不低于 99.5%。
|
||||
- 文本消息发送成功率不低于 99.9%。
|
||||
- 在线情况下新消息端到端可见时间 P95 不超过 2 秒。
|
||||
- WebSocket 断线后 10 秒内自动恢复率不低于 99%。
|
||||
- 由商品或订单场景进入时,上下文卡片发送率可被埋点统计。
|
||||
- 因身份错误导致的跨用户数据访问事件为 0。
|
||||
|
||||
## 5. 用户与使用场景
|
||||
|
||||
### 5.1 用户角色
|
||||
|
||||
- 已登录会员:使用全部客服能力。
|
||||
- 未登录访客:可看客服说明;发起聊天时必须先登录。
|
||||
- 后台客服:使用现有管理后台接收和回复消息,不属于本项目前台开发范围。
|
||||
|
||||
首期不支持匿名访客会话,避免设备身份、会话合并和隐私归属问题。
|
||||
|
||||
### 5.2 主要场景
|
||||
|
||||
1. 通用咨询:用户从“我的 → 联系客服”进入聊天页。
|
||||
2. 商品咨询:用户从商品详情进入,页面展示待咨询商品,可一键发送商品卡片。
|
||||
3. 订单咨询:用户从订单详情或售后场景进入,可一键发送订单卡片。
|
||||
4. 历史追问:用户再次进入客服页,继续原有会话并查看历史消息。
|
||||
5. 客服回复:页面打开时实时收到;页面关闭后,后续可扩展消息角标或订阅消息提醒。
|
||||
|
||||
## 6. 产品范围
|
||||
|
||||
### 6.1 P0:首期必须完成
|
||||
|
||||
- 登录校验与当前用户识别。
|
||||
- 单一客服会话的创建或恢复。
|
||||
- 历史消息加载和向上翻页。
|
||||
- 文本消息发送与接收。
|
||||
- 图片选择、压缩、上传、发送与预览。
|
||||
- WebSocket 连接、心跳、断线重连。
|
||||
- WebSocket 不可用时 3 至 5 秒轮询兜底。
|
||||
- 发送中、发送成功、发送失败状态。
|
||||
- 失败消息手动重试,防止重复发送。
|
||||
- 商品上下文卡片发送。
|
||||
- 订单上下文卡片发送。
|
||||
- H5 与微信小程序兼容。
|
||||
- 空状态、加载状态、错误状态和网络状态提示。
|
||||
- 基础埋点和错误日志。
|
||||
|
||||
### 6.2 P1:建议紧随首期
|
||||
|
||||
- 表情面板。
|
||||
- 从聊天页主动选择最近浏览商品。
|
||||
- 从聊天页主动选择历史订单。
|
||||
- 用户侧未读数量与已读回执。
|
||||
- “客服正在输入”状态。
|
||||
- 客服在线状态和服务时间提示。
|
||||
- 小程序订阅消息或站内消息提醒。
|
||||
|
||||
### 6.3 本期不做
|
||||
|
||||
- 语音、视频和实时音视频通话。
|
||||
- 多客服技能组、智能分配和排队系统。
|
||||
- 机器人客服和知识库问答。
|
||||
- 消息撤回、引用回复、群聊。
|
||||
- 客服评价、投诉工单和 SLA 统计。
|
||||
- 独立访客身份体系。
|
||||
|
||||
## 7. 信息架构
|
||||
|
||||
独立前台客服项目建议规划以下产品模块:
|
||||
|
||||
```text
|
||||
客服聊天页
|
||||
├─ 顶部导航与连接状态
|
||||
├─ 服务时间/系统公告
|
||||
├─ 历史消息区
|
||||
│ ├─ 文本消息
|
||||
│ ├─ 图片消息
|
||||
│ ├─ 商品卡片
|
||||
│ ├─ 订单卡片
|
||||
│ └─ 时间分隔与状态提示
|
||||
├─ 场景上下文提示条
|
||||
├─ 消息输入区
|
||||
│ ├─ 文本输入
|
||||
│ ├─ 图片选择
|
||||
│ ├─ 表情入口(P1)
|
||||
│ └─ 更多功能
|
||||
├─ 商品选择面板(P1)
|
||||
├─ 订单选择面板(P1)
|
||||
└─ 图片预览
|
||||
```
|
||||
|
||||
建议正式接入商城后的页面路由为:
|
||||
|
||||
```text
|
||||
/pages/customer-service/chat
|
||||
```
|
||||
|
||||
建议作为分包页面注册,避免客服资源增加商城主包体积。
|
||||
|
||||
## 8. 核心用户流程
|
||||
|
||||
### 8.1 进入客服页
|
||||
|
||||
1. 用户点击客服入口。
|
||||
2. 系统检查商城登录态。
|
||||
3. 未登录时跳转登录页,并记录客服目标页及场景参数。
|
||||
4. 登录成功后返回客服页。
|
||||
5. 页面从可信登录态获取当前用户,不从 URL 接收用户 ID。
|
||||
6. 加载最近一页历史消息和会话信息。
|
||||
7. 建立 WebSocket 并发送心跳。
|
||||
8. 若入口携带商品或订单上下文,在输入区上方展示待发送卡片,不自动发送。
|
||||
|
||||
### 8.2 发送文本消息
|
||||
|
||||
1. 用户输入内容,空白内容不能发送。
|
||||
2. 前端生成本地消息 ID,立即在消息区展示“发送中”。
|
||||
3. 调用发送接口。
|
||||
4. 成功后用服务端消息 ID 替换本地状态。
|
||||
5. 失败后显示失败标识,点击可重试。
|
||||
6. 重试必须复用幂等键,避免网络超时导致重复消息。
|
||||
|
||||
### 8.3 发送图片消息
|
||||
|
||||
1. 用户从相册选择图片或在 H5 选择本地文件。
|
||||
2. 校验格式、大小与数量。
|
||||
3. 客户端在支持的终端压缩长边和质量。
|
||||
4. 先上传到现有文件服务,获得 HTTPS 图片 URL。
|
||||
5. 再发送 `contentType=2` 的客服消息。
|
||||
6. 上传或发送失败时允许重试。
|
||||
7. 首期每次最多选择 1 张,单张原图建议不超过 10 MB,压缩后建议不超过 2 MB。
|
||||
|
||||
禁止把大图片 Base64 长期写入 `kefu_message.content`。
|
||||
|
||||
### 8.4 发送商品卡片
|
||||
|
||||
1. 从商品详情进入时携带 `goodsId`,不得在 URL 携带完整商品价格等可篡改展示数据。
|
||||
2. 客服页通过商品接口获取最新商品信息。
|
||||
3. 用户点击“发送商品”后发送 `contentType=10`。
|
||||
4. 卡片至少展示商品主图、名称、规格或价格、商品状态。
|
||||
5. 点击卡片返回商品详情;商品下架时显示“商品已失效”。
|
||||
|
||||
### 8.5 发送订单卡片
|
||||
|
||||
1. 从订单场景进入时携带 `orderId`。
|
||||
2. 客服页通过有权限校验的订单接口获取当前用户的订单摘要。
|
||||
3. 用户确认后发送 `contentType=11`。
|
||||
4. 卡片至少展示商品主图、商品名称、订单号、金额和订单状态。
|
||||
5. 用户只能发送归属于自己的订单。
|
||||
|
||||
### 8.6 实时消息与恢复
|
||||
|
||||
1. WebSocket 收到 `kefu_message_type` 后按消息 ID 去重并插入列表。
|
||||
2. 收到 `kefu_conversation_change` 后更新会话摘要。
|
||||
3. 收到 `kefu_message_read_status_change` 后更新已读状态。
|
||||
4. 每 25 至 30 秒发送一次 `ping`,收到 `pong` 视为连接存活。
|
||||
5. 断线按 2、4、8、15、30 秒退避重连,最大间隔 30 秒。
|
||||
6. 应用切到前台时立即检查连接,并拉取断线期间的新消息。
|
||||
7. WebSocket 连续失败后启用 3 至 5 秒轮询;恢复连接后停止轮询。
|
||||
|
||||
## 9. 页面与交互要求
|
||||
|
||||
### 9.1 顶部区域
|
||||
|
||||
- 标题默认“在线客服”。
|
||||
- 可展示“在线”“连接中”“网络异常”等连接状态。
|
||||
- 服务时间由配置返回,不在前端硬编码。
|
||||
- 微信小程序使用原生导航栏或自定义导航栏时必须适配安全区。
|
||||
|
||||
### 9.2 消息列表
|
||||
|
||||
- 客服消息居左,会员消息居右。
|
||||
- 连续消息按时间间隔展示时间分隔,建议超过 5 分钟显示一次。
|
||||
- 首次进入滚动到最新消息。
|
||||
- 用户主动向上滚动时,新消息到达不得强制抢夺滚动位置,应显示“有新消息”按钮。
|
||||
- 向上触顶加载更早消息,并保持加载前后的视觉位置。
|
||||
- 文本必须换行并防止超长字符串撑破布局。
|
||||
- 图片支持缩略图、加载占位、失败占位和全屏预览。
|
||||
- 商品、订单卡片需要结构化解析失败兜底,不能因单条脏数据导致整页报错。
|
||||
|
||||
### 9.3 输入区
|
||||
|
||||
- 支持多行输入,最多显示 4 行后内部滚动。
|
||||
- 文本长度上限首期建议 1000 字,与后端最终限制保持一致。
|
||||
- 发送按钮仅在有有效内容时可用。
|
||||
- 微信小程序需适配键盘顶起、底部安全区和 `cursor-spacing`。
|
||||
- H5 需兼容 iOS Safari 软键盘造成的可视区域变化。
|
||||
- 页面销毁时停止计时器、轮询和 Socket,防止重复连接。
|
||||
|
||||
### 9.4 状态反馈
|
||||
|
||||
- 首屏加载:骨架或明确加载状态。
|
||||
- 无历史消息:欢迎语及常见咨询提示。
|
||||
- 发送中:消息旁显示加载状态。
|
||||
- 发送失败:红色失败标识与重试入口。
|
||||
- 网络离线:顶部非阻塞提示,不清空输入内容。
|
||||
- 登录失效:保存未发送草稿,重新登录后恢复。
|
||||
|
||||
## 10. 跨端兼容要求
|
||||
|
||||
| 能力 | H5 | 微信小程序 | 产品要求 |
|
||||
|---|---|---|---|
|
||||
| 页面实现 | uni-app 原生页面 | uni-app 原生页面 | 不使用 iframe 承载核心聊天 |
|
||||
| 网络请求 | `uni.request` | `uni.request` | API 域名必须配置 HTTPS |
|
||||
| WebSocket | `uni.connectSocket` | `uni.connectSocket` | 不直接依赖浏览器 `WebSocket` 全局对象 |
|
||||
| 图片选择 | H5 文件选择 | `uni.chooseMedia`/兼容接口 | 统一封装并限制大小 |
|
||||
| 图片上传 | `uni.uploadFile` | `uni.uploadFile` | 上传域名加入小程序合法域名 |
|
||||
| 图片预览 | `uni.previewImage` | `uni.previewImage` | 行为保持一致 |
|
||||
| 键盘适配 | iOS/Android 浏览器 | 微信键盘与安全区 | 真机验收 |
|
||||
| 返回行为 | 浏览器/uni 路由 | 小程序页面栈 | 从登录返回时恢复上下文 |
|
||||
| 域名要求 | HTTPS/WSS | request/socket/upload 合法域名 | 发布前配置完成 |
|
||||
|
||||
微信小程序发布前必须在平台后台配置:
|
||||
|
||||
- `request` 合法域名。
|
||||
- `socket` 合法域名。
|
||||
- `uploadFile` 合法域名。
|
||||
- `downloadFile` 合法域名(如图片预览需要)。
|
||||
|
||||
测试环境建议使用 `https://api.o.tbmall.xin` 和对应 `wss` 地址;正式环境使用正式 API 域名。域名必须由环境配置注入,不允许散落硬编码。
|
||||
|
||||
## 11. 当前接口依赖
|
||||
|
||||
### 11.1 用户消息接口
|
||||
|
||||
| 功能 | 方法 | 当前路径 |
|
||||
|---|---|---|
|
||||
| 发送消息 | POST | `/kefu/user/send` |
|
||||
| 查询会话和消息 | GET | `/kefu/user/messages` |
|
||||
| 最近浏览 | GET | `/kefu/user/browse-history` |
|
||||
| 交易订单 | GET | `/kefu/user/orders` |
|
||||
| 记录商品浏览 | POST | `/kefu/user/browse-record` |
|
||||
|
||||
后端同时提供部分兼容路径,但新前台只使用一套规范路径,避免后续维护重复接口。
|
||||
|
||||
### 11.2 WebSocket
|
||||
|
||||
- 端点:`/ws/kefu` 或代理路径 `/api/ws/kefu`。
|
||||
- 当前会员连接参数:`userType=member`、`userId`、`mchId`、`conversationId`。
|
||||
- 心跳输入:字符串 `ping`。
|
||||
- 心跳响应:`{"type":"pong"}`。
|
||||
|
||||
当前事件类型:
|
||||
|
||||
| 事件 | 说明 |
|
||||
|---|---|
|
||||
| `kefu_connected` | 连接建立成功 |
|
||||
| `kefu_message_type` | 新客服消息 |
|
||||
| `kefu_conversation_change` | 会话信息变化 |
|
||||
| `kefu_message_read_status_change` | 消息已读状态变化 |
|
||||
|
||||
### 11.3 消息类型
|
||||
|
||||
| `contentType` | 类型 | `content` 建议格式 |
|
||||
|---:|---|---|
|
||||
| 1 | 文本 | UTF-8 字符串 |
|
||||
| 2 | 图片 | HTTPS 图片 URL |
|
||||
| 10 | 商品卡片 | 版本化 JSON |
|
||||
| 11 | 订单卡片 | 版本化 JSON |
|
||||
|
||||
商品和订单卡片建议增加 `schemaVersion`,并定义稳定字段,避免前后台各自猜测 JSON 结构。
|
||||
|
||||
## 12. 上线前后端必须补强项
|
||||
|
||||
以下问题不影响原型演示,但属于正式开发和上线的前置条件。
|
||||
|
||||
### 12.1 身份与数据权限(阻断上线)
|
||||
|
||||
当前用户接口允许客户端直接传 `userId`,且接口描述为免后台登录;WebSocket 也直接信任查询参数中的 `userId`。这会产生读取他人消息、订单或浏览记录的风险。
|
||||
|
||||
上线前必须:
|
||||
|
||||
1. 用户接口强制校验商城 `HSM-AUTH` 登录态。
|
||||
2. 后端从认证上下文取得会员 ID,忽略客户端提交的 `userId`。
|
||||
3. 会话、订单、浏览记录均按认证用户和商户双重校验。
|
||||
4. WebSocket 握手必须校验短期 token 或商城登录 token,并由服务端解析用户身份。
|
||||
5. 禁止仅通过 `userType=member&userId=...` 建立会员连接。
|
||||
6. 限制允许来源,正式环境不应保持 `AllowedOriginPatterns("*")`。
|
||||
|
||||
### 12.2 历史消息分页(阻断完整体验)
|
||||
|
||||
当前消息按 ID 正序分页,直接请求第一页可能得到最早的 80 条而不是最新消息。需要提供以下任一方案:
|
||||
|
||||
- 游标分页:`beforeMessageId` + `limit`,默认返回最新一页。
|
||||
- 倒序查询最新消息,返回前端后再按时间正序展示。
|
||||
|
||||
接口还应支持断线补偿参数 `afterMessageId`,只拉取最后已知消息之后的数据。
|
||||
|
||||
### 12.3 幂等与消息状态
|
||||
|
||||
发送接口建议新增:
|
||||
|
||||
- `clientMessageId`:客户端生成且在用户维度唯一。
|
||||
- 服务端唯一索引或幂等检查。
|
||||
- 统一 `createdAt` 服务端时间。
|
||||
- 明确发送失败错误码。
|
||||
|
||||
### 12.4 用户侧已读
|
||||
|
||||
当前 `readStatus` 主要表达后台是否已读会员消息,不能完整表达会员是否已读客服回复。若 P1 要展示用户未读角标,需要新增用户侧未读数或双向已读字段及接口。
|
||||
|
||||
### 12.5 内容安全与限制
|
||||
|
||||
- 后端校验允许的 `contentType`。
|
||||
- 文本长度限制并过滤危险 HTML,前端不得使用未净化的 `v-html`。
|
||||
- 图片 URL 只允许可信 CDN/OSS 域名。
|
||||
- 商品、订单卡片必须由服务端校验归属和关键展示信息。
|
||||
- 按用户和 IP 做发送频率限制。
|
||||
- 对恶意内容、重复刷屏和超大请求保留审计日志。
|
||||
|
||||
## 13. 前台项目技术边界
|
||||
|
||||
插件实现遵循:
|
||||
|
||||
- 技术栈与商城统一采用 uni-app,避免维护两套终端代码。
|
||||
- 客服核心逻辑封装在 `hashmall-customer-service` 组件中,由商城客服页直接承载。
|
||||
- 请求层复用商城 `HSM-AUTH` 与统一错误处理。
|
||||
- Socket 层使用 `uni.connectSocket` 封装单例连接。
|
||||
- 不在组件内硬编码 API、WSS、用户 ID、商户 ID 或客服号码。
|
||||
- H5 与小程序平台差异通过条件编译或适配器隔离。
|
||||
- 公共请求、认证和上传能力直接复用宿主工程;平台差异由插件内部适配。
|
||||
|
||||
插件代码目录结构:
|
||||
|
||||
```text
|
||||
frontend-web/uni_modules/hashmall-customer-service/
|
||||
├─ components/
|
||||
│ ├─ hashmall-customer-service/
|
||||
│ ├─ kefu-message-item/
|
||||
│ └─ kefu-context-card/
|
||||
├─ js_sdk/
|
||||
│ ├─ api.js
|
||||
│ └─ kefu-socket.js
|
||||
├─ static/
|
||||
├─ docs/
|
||||
├─ package.json
|
||||
└─ readme.md
|
||||
```
|
||||
|
||||
## 14. 埋点与可观测性
|
||||
|
||||
建议埋点:
|
||||
|
||||
- `customer_service_entry_click`:入口与场景来源。
|
||||
- `customer_service_page_view`:页面打开成功。
|
||||
- `customer_service_ws_connected`:连接耗时与重连次数。
|
||||
- `customer_service_message_send`:消息类型、结果、耗时,不记录正文。
|
||||
- `customer_service_card_send`:商品或订单卡片发送。
|
||||
- `customer_service_history_load`:分页结果与耗时。
|
||||
- `customer_service_error`:接口、Socket、解析、上传错误码。
|
||||
|
||||
日志和埋点不得记录 token、完整消息正文、订单隐私信息和图片内容。
|
||||
|
||||
## 15. 验收标准
|
||||
|
||||
### 15.1 功能验收
|
||||
|
||||
- 已登录用户能从“我的”、商品、订单三个场景进入客服页。
|
||||
- 未登录用户被引导登录,登录后能返回原咨询场景。
|
||||
- 首次进入能创建或恢复会话,并展示最新历史消息。
|
||||
- 文本消息双向实时送达。
|
||||
- 图片可选择、上传、发送、接收和预览。
|
||||
- 商品和订单卡片可发送、正确展示并跳回相应详情。
|
||||
- 发送失败可重试,网络超时不会生成重复消息。
|
||||
- 向上滚动能连续加载更早消息且无重复、无跳动。
|
||||
- WebSocket 断开后自动重连,断线消息能够补齐。
|
||||
- WebSocket 不可用时轮询兜底可继续收取消息。
|
||||
- 页面重复进入退出不会产生多个 Socket 或重复轮询。
|
||||
|
||||
### 15.2 安全验收
|
||||
|
||||
- 修改 URL、请求体或 Socket 参数不能访问其他会员数据。
|
||||
- 失效 token 无法查询和发送消息。
|
||||
- 用户不能发送不属于自己的订单卡片。
|
||||
- 用户不能伪造商品关键字段欺骗后台客服。
|
||||
- 非法消息类型、超长文本、非可信图片 URL 被后端拒绝。
|
||||
|
||||
### 15.3 兼容验收
|
||||
|
||||
- 微信开发者工具基础功能通过。
|
||||
- 至少一台 iOS 微信真机和一台 Android 微信真机通过。
|
||||
- iOS Safari 与 Android Chrome H5 通过。
|
||||
- 键盘弹起、图片选择、后台切换、弱网和断网恢复通过。
|
||||
- 小程序合法域名配置完整,正式包无“域名不合法”错误。
|
||||
|
||||
## 16. 实施阶段建议
|
||||
|
||||
### 阶段 A:接口收口与安全改造
|
||||
|
||||
- 用户接口改为从 token 获取用户身份。
|
||||
- WebSocket 增加认证。
|
||||
- 消息分页改为最新页/游标模式。
|
||||
- 增加发送幂等字段。
|
||||
- 固化商品、订单卡片协议。
|
||||
|
||||
### 阶段 B:独立前台客服 MVP
|
||||
|
||||
- 完成登录适配、聊天页、文本、图片、历史消息。
|
||||
- 完成 WebSocket、心跳、重连、轮询兜底。
|
||||
- 在 H5 和微信小程序独立联调。
|
||||
|
||||
### 阶段 C:商城场景接入
|
||||
|
||||
- 替换“我的”页电话弹窗和第三方客服 H5。
|
||||
- 接入商品详情与订单详情上下文。
|
||||
- 商品详情统一上报浏览记录。
|
||||
- 完成小程序分包与合法域名配置。
|
||||
|
||||
### 阶段 D:灰度与上线
|
||||
|
||||
- 测试环境全链路验收。
|
||||
- 小范围用户灰度。
|
||||
- 观察连接率、发送成功率和错误率。
|
||||
- 达标后全量替换旧客服入口。
|
||||
|
||||
## 17. 待产品与研发确认
|
||||
|
||||
1. 首期是否只支持平台客服,还是区分平台客服、云仓客服和商家客服。
|
||||
2. 客服服务时间、非工作时间欢迎语及联系电话由谁配置。
|
||||
3. 商品和订单卡片是否纳入 P0。
|
||||
4. 是否需要用户侧消息未读角标和小程序订阅消息。
|
||||
5. 图片上传沿用哪个现有接口及 OSS/CDN 域名。
|
||||
6. 客服聊天记录保留期限和用户侧是否允许删除。
|
||||
7. 旧第三方客服系统是一次性替换,还是保留灰度开关。
|
||||
8. 独立项目最终以分包、源码模块还是私有组件包的方式并入商城。
|
||||
|
||||
## 18. 结论
|
||||
|
||||
现有后端与管理后台已经具备客服前台 MVP 所需的大部分业务基础,前台开发的主要工作集中在跨端聊天体验、可靠消息状态、Socket 生命周期和商城场景接入。
|
||||
|
||||
正式编码前应优先完成用户身份校验、WebSocket 鉴权和历史消息分页三项后端收口。否则即使前台页面能够运行,也不满足正式上线的数据安全和完整体验要求。
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "hashmall-customer-service",
|
||||
"displayName": "HashMall 前台客服",
|
||||
"version": "0.1.0",
|
||||
"description": "内嵌于 HashMall App、H5 和微信小程序的客服聊天插件",
|
||||
"keywords": ["客服", "聊天", "WebSocket", "uni-app"],
|
||||
"engines": {
|
||||
"HBuilderX": "^4.0.0"
|
||||
},
|
||||
"dcloudext": {
|
||||
"category": ["前端组件", "通用组件"],
|
||||
"sale": { "regular": { "price": "0.00" }, "sourcecode": { "price": "0.00" } },
|
||||
"contact": { "qq": "" },
|
||||
"declaration": { "ads": "无", "data": "插件使用商城现有用户登录态和客服消息接口", "permissions": "相册、相机(仅在用户主动发送图片时)" }
|
||||
},
|
||||
"uni_modules": {
|
||||
"dependencies": [],
|
||||
"encrypt": [],
|
||||
"platforms": {
|
||||
"cloud": { "tcb": "u", "aliyun": "u" },
|
||||
"client": {
|
||||
"App": { "app-vue": "y", "app-nvue": "u" },
|
||||
"H5-mobile": { "Safari": "y", "Android Browser": "y", "微信浏览器(Android)": "y", "QQ浏览器(Android)": "y" },
|
||||
"H5-pc": { "Chrome": "y", "IE": "u", "Edge": "y", "Firefox": "y", "Safari": "y" },
|
||||
"小程序": { "微信": "y", "阿里": "u", "百度": "u", "字节跳动": "u", "QQ": "u", "京东": "u" },
|
||||
"Vue": { "vue2": "u", "vue3": "y" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# HashMall 前台客服插件
|
||||
|
||||
该插件直接内嵌在商城工程中,复用:
|
||||
|
||||
- `fcc-token` 登录 Token
|
||||
- `/user/getUserInfo` 当前会员接口
|
||||
- `utils/config.js` 的 API 和应用 Authorization
|
||||
- `/common/upload/image` 图片上传接口
|
||||
- `/kefu/user/*` 客服用户接口
|
||||
- `/ws/kefu` WebSocket
|
||||
|
||||
页面中使用:
|
||||
|
||||
```vue
|
||||
<hashmall-customer-service ref="customerService" :entry-options="entryOptions" />
|
||||
```
|
||||
|
||||
宿主页面在 `onShow` 调用 `resume()`,在 `onHide` 调用 `suspend()`。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Reference in New Issue
Block a user