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,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>
@@ -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>
@@ -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