diff --git a/api/product.js b/api/product.js
index 7a3512b..fe2cfaa 100644
--- a/api/product.js
+++ b/api/product.js
@@ -1,5 +1,5 @@
import request from '@/utils/request.js';
-import { getStorageFun, TOKEN_NAME } from '@/utils/auth.js';
+import {getStorageFun,TOKEN_NAME} from '@/utils/auth.js';
/**
* 搜索商品
@@ -7,9 +7,9 @@ import { getStorageFun, TOKEN_NAME } from '@/utils/auth.js';
*/
export function searchList(params) {
return request({
- url: "/goods/searchList",
- method: "get",
- data: params,
+ url: "/goods/searchList",
+ method: "get",
+ data:params,
});
}
@@ -20,10 +20,10 @@ export function searchList(params) {
*/
export function getGoodsDetail(params) {
return request({
- url: "/goods/getGoodsDetail",
- method: "get",
- data: params,
- });
+ url: "/goods/getGoodsDetail",
+ method: "get",
+ data:params,
+ });
}
@@ -33,22 +33,22 @@ export function getGoodsDetail(params) {
*/
export function addGoods(params) {
return request({
- url: "/cart/addGoods",
- method: "post",
- data: params,
- });
+ url: "/cart/addGoods",
+ method: "post",
+ data:params,
+ });
}
-/**
- * 查询规格
- * @param {Object} params
- */
+ /**
+ * 查询规格
+ * @param {Object} params
+ */
export function getGoodsSpecs(params) {
return request({
- url: "/goods/getGoodsSpecs",
- method: "get",
- data: params,
- });
-}
+ url: "/goods/getGoodsSpecs",
+ method: "get",
+ data:params,
+ });
+ }
// 查询商品评价
export function getGoodsReviewsList(params) {
return request({
@@ -119,6 +119,14 @@ export function getSeckillGoodsList(params) {
});
}
+export function getGoodsSpecsInfo(params) {
+ return request({
+ url: "/goods/getGoodsSpecsInfo",
+ method: "get",
+ data: params,
+ });
+}
+
// 商品标签
export function getGoodsTags(params) {
return request({
diff --git a/main.js b/main.js
index e6b4156..ec1e5e4 100644
--- a/main.js
+++ b/main.js
@@ -38,6 +38,23 @@ import { createI18n } from 'vue-i18n'
const i18n = createI18n(i18nConfig)
export function createApp() {
const app = createSSRApp(App)
+ // #ifdef H5
+ app.component('AsyncError', {
+ props: ['error'],
+ mounted() {
+ console.error('[AsyncPageError]', this.error)
+ },
+ render() {
+ const detail = import.meta.env.DEV && this.error
+ ? `:${this.error.message || String(this.error)}`
+ : ''
+ return h('div', {
+ class: 'uni-async-error',
+ onClick: () => window.location.reload()
+ }, `页面加载失败${detail},点击屏幕重试`)
+ }
+ })
+ // #endif
// #ifndef MP-WEIXIN
app.use(VueClipboard);
// #endif
diff --git a/package.json b/package.json
index d01314e..ec4feed 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
"echarts": "^5.1.2",
"qs-canvas": "^1.0.11",
"tabber-component": "^1.1.1",
- "vue-clipboard2": "^0.3.3"
+ "vue-clipboard2": "^0.3.3",
+ "vue-i18n": "^9.14.1"
}
}
diff --git a/pages/mine/mine.vue b/pages/mine/mine.vue
index 2257b7d..447f3e8 100644
--- a/pages/mine/mine.vue
+++ b/pages/mine/mine.vue
@@ -5,37 +5,25 @@
+ shape="circle" width="54" height="54" bgColor="#f1f6ff00">
{{ currentUserData.nickname || '信任桥用户' }}
-
+
{{ currentUserData.agentName }}
-
+
+ style="margin-right: 8rpx; margin-top: 6rpx" shape="circle" width="14"
+ height="14" bgColor="#f1f6ff00">
{{ currentUserData.gradeName }}
@@ -58,17 +46,20 @@
邀请码:{{ currentUserData.inviteCode }}
-
+
-
+
-
+
未登录
@@ -84,20 +75,24 @@
我的订单
全部
-
+
-
+
{{ item.title }}
-
+
-
+
{{ item.title }}
@@ -168,7 +163,11 @@
服务工具
-
+
+
+
+
{{ item.title }}
@@ -236,6 +235,7 @@ import CustomTabBar from '@/components/custom-tab-bar.vue';
import { getStatistics } from '@/api/order.js';
import { getUserInfo } from '@/api/auth.js';
import { CUSTOMER_SERVICE_PHONE_NUMBER } from '@/utils/config.js';
+import { getUnreadCount as getKefuUnreadCount } from "@/uni_modules/hashmall-customer-service/js_sdk/api.js";
export default {
computed: {
@@ -411,7 +411,9 @@ export default {
}, // 角标统计
serviceShow: false, // 我的客服弹框
openShow: false, // 暂未开放
- platformType: true
+ platformType: true,
+ kefuUnreadCount: 0,
+ kefuUnreadTimer: null,
};
},
onShow() {
@@ -426,16 +428,45 @@ export default {
this.getStatistics();
this.getUserInfo();
+ this.startKefuUnreadPolling();
},
- mounted() {},
+ onHide() {
+ this.stopKefuUnreadPolling();
+ },
+ onUnload() {
+ this.stopKefuUnreadPolling();
+ },
+ mounted() { },
methods: {
copyData,
+ async refreshKefuUnreadCount() {
+ const userId = this.currentUserData && this.currentUserData.id;
+ if (!userId) {
+ this.kefuUnreadCount = 0;
+ return;
+ }
+ try {
+ this.kefuUnreadCount = await getKefuUnreadCount(userId);
+ } catch (error) {
+ this.kefuUnreadCount = 0;
+ }
+ },
+ startKefuUnreadPolling() {
+ this.stopKefuUnreadPolling();
+ this.refreshKefuUnreadCount();
+ this.kefuUnreadTimer = setInterval(() => this.refreshKefuUnreadCount(), 15000);
+ },
+ stopKefuUnreadPolling() {
+ if (this.kefuUnreadTimer) clearInterval(this.kefuUnreadTimer);
+ this.kefuUnreadTimer = null;
+ },
// 查询用户等级
async getUserInfo() {
const result = await getUserInfo();
if (result && result.bizcode === 100) {
const data = result.data;
this.currentUserData = result.data || {};
+ this.refreshKefuUnreadCount();
if (!this.currentUserData?.isNewUser) {
this.applyList = this.applyList.filter((item) => item.id !== 5);
}
@@ -464,7 +495,7 @@ export default {
// 客服
if (item.id === 7) {
uni.navigateTo({
- url: '/pages/rwa_package/setting/kefu?type=5257'
+ url: "/pages/rwa_package/setting/kefu",
});
} else if (item.id === 9) {
// 实名认证
@@ -557,274 +588,299 @@ export default {
\ No newline at end of file
diff --git a/pages/mine_package/mine_order/mine_order.vue b/pages/mine_package/mine_order/mine_order.vue
index b6e2132..1d9670c 100644
--- a/pages/mine_package/mine_order/mine_order.vue
+++ b/pages/mine_package/mine_order/mine_order.vue
@@ -134,7 +134,8 @@
@click="onAfterSaleDetail(item)">
售后详情
- 评价
+ 评价
¥{{ orderInfo.adPrice }}
-
+
@@ -232,13 +233,13 @@
{{ item.type }}
-
+ " v-for="(oItem, oIndex) in item.items" :key="oItem.id || oItem.n"
+ @click="clickSpecification(index, item, oItem)">
{{ oItem.n }}
@@ -338,7 +339,7 @@
-
-
diff --git a/uni_modules/hashmall-customer-service/components/hashmall-customer-service/hashmall-customer-service.vue b/uni_modules/hashmall-customer-service/components/hashmall-customer-service/hashmall-customer-service.vue
new file mode 100644
index 0000000..24903ef
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/components/hashmall-customer-service/hashmall-customer-service.vue
@@ -0,0 +1,2124 @@
+
+
+
+
+ {{ statusBanner }}
+
+
+
+ {{ serviceModeLabel }}
+ 正在输入…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 登录后联系在线客服
+ 登录后可继续历史咨询,并发送相关商品或订单信息。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 回复 {{ messageSenderName(quotedMessage) }}
+ {{ messagePreview(quotedMessage) }}
+
+
+
+
+
+
+
+
+
+ {{ recordingSeconds }}″
+ {{ recordingCancelled ? '松开手指,取消发送' : '上滑取消发送' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 正在加载
+ 暂无可发送的{{ pickerType === 'product' ? '商品' : '订单' }}
+
+
+
+
+
+
+
+
+
diff --git a/uni_modules/hashmall-customer-service/components/kefu-context-card/kefu-context-card.vue b/uni_modules/hashmall-customer-service/components/kefu-context-card/kefu-context-card.vue
new file mode 100644
index 0000000..b2f1122
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/components/kefu-context-card/kefu-context-card.vue
@@ -0,0 +1,129 @@
+
+
+
+
+ {{ kindLabel }}
+ {{ title }}
+ {{ meta }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uni_modules/hashmall-customer-service/components/kefu-message-item/kefu-message-item.vue b/uni_modules/hashmall-customer-service/components/kefu-message-item/kefu-message-item.vue
new file mode 100644
index 0000000..0a22910
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/components/kefu-message-item/kefu-message-item.vue
@@ -0,0 +1,580 @@
+
+
+ {{ isSelf ? '你撤回了一条消息' : '客服撤回了一条消息' }}
+
+
+
+ {{ displayName }}
+ {{ formattedTime }}
+
+
+
+
+
+ {{ message.content }}
+
+
+
+ {{ attachment.transcript }}
+
+
+
+
+
+
+ {{ normalizedType === contentType.PRODUCT ? '咨询商品' : '咨询订单' }}
+ {{ card.title }}
+ {{ card.meta }}
+
+
+
+
+ {{ quote.senderName }}
+ {{ quote.preview }}
+
+ {{ quote.text }}
+
+ 暂不支持的消息类型
+
+
+ 发送中
+
+ 已发送
+
+
+
+
+
+
+
+
+
+
diff --git a/uni_modules/hashmall-customer-service/docs/AI_CUSTOMER_SERVICE_PRODUCT_REQUIREMENTS.md b/uni_modules/hashmall-customer-service/docs/AI_CUSTOMER_SERVICE_PRODUCT_REQUIREMENTS.md
new file mode 100644
index 0000000..e992a85
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/docs/AI_CUSTOMER_SERVICE_PRODUCT_REQUIREMENTS.md
@@ -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 回答能力。
+
diff --git a/uni_modules/hashmall-customer-service/docs/DESIGN.md b/uni_modules/hashmall-customer-service/docs/DESIGN.md
new file mode 100644
index 0000000..88fd3f4
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/docs/DESIGN.md
@@ -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 作为核心能力。
diff --git a/uni_modules/hashmall-customer-service/docs/INTEGRATION.md b/uni_modules/hashmall-customer-service/docs/INTEGRATION.md
new file mode 100644
index 0000000..d3b5a6c
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/docs/INTEGRATION.md
@@ -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`。
diff --git a/uni_modules/hashmall-customer-service/docs/PRODUCT.md b/uni_modules/hashmall-customer-service/docs/PRODUCT.md
new file mode 100644
index 0000000..44410dc
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/docs/PRODUCT.md
@@ -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;支持系统减少动态效果;消息内容允许系统字体缩放,并兼顾长文本、无图片和慢网用户。
diff --git a/uni_modules/hashmall-customer-service/docs/PRODUCT_REQUIREMENTS.md b/uni_modules/hashmall-customer-service/docs/PRODUCT_REQUIREMENTS.md
new file mode 100644
index 0000000..5e98e44
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/docs/PRODUCT_REQUIREMENTS.md
@@ -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 鉴权和历史消息分页三项后端收口。否则即使前台页面能够运行,也不满足正式上线的数据安全和完整体验要求。
diff --git a/uni_modules/hashmall-customer-service/js_sdk/api.js b/uni_modules/hashmall-customer-service/js_sdk/api.js
new file mode 100644
index 0000000..e379240
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/js_sdk/api.js
@@ -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')
diff --git a/uni_modules/hashmall-customer-service/js_sdk/kefu-socket.js b/uni_modules/hashmall-customer-service/js_sdk/kefu-socket.js
new file mode 100644
index 0000000..f09c18b
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/js_sdk/kefu-socket.js
@@ -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')
+ }
+}
diff --git a/uni_modules/hashmall-customer-service/package.json b/uni_modules/hashmall-customer-service/package.json
new file mode 100644
index 0000000..a5490f9
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/package.json
@@ -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" }
+ }
+ }
+ }
+}
diff --git a/uni_modules/hashmall-customer-service/readme.md b/uni_modules/hashmall-customer-service/readme.md
new file mode 100644
index 0000000..041d289
--- /dev/null
+++ b/uni_modules/hashmall-customer-service/readme.md
@@ -0,0 +1,18 @@
+# HashMall 前台客服插件
+
+该插件直接内嵌在商城工程中,复用:
+
+- `fcc-token` 登录 Token
+- `/user/getUserInfo` 当前会员接口
+- `utils/config.js` 的 API 和应用 Authorization
+- `/common/upload/image` 图片上传接口
+- `/kefu/user/*` 客服用户接口
+- `/ws/kefu` WebSocket
+
+页面中使用:
+
+```vue
+
+```
+
+宿主页面在 `onShow` 调用 `resume()`,在 `onHide` 调用 `suspend()`。
diff --git a/uni_modules/hashmall-customer-service/static/image-placeholder.png b/uni_modules/hashmall-customer-service/static/image-placeholder.png
new file mode 100644
index 0000000..fb7bb07
Binary files /dev/null and b/uni_modules/hashmall-customer-service/static/image-placeholder.png differ
diff --git a/uni_modules/hashmall-customer-service/static/service-avatar.png b/uni_modules/hashmall-customer-service/static/service-avatar.png
new file mode 100644
index 0000000..fb7bb07
Binary files /dev/null and b/uni_modules/hashmall-customer-service/static/service-avatar.png differ
diff --git a/uni_modules/hashmall-customer-service/static/tb-service-avatar.png b/uni_modules/hashmall-customer-service/static/tb-service-avatar.png
new file mode 100644
index 0000000..cd3b92a
Binary files /dev/null and b/uni_modules/hashmall-customer-service/static/tb-service-avatar.png differ
diff --git a/utils/config.js b/utils/config.js
index 115a8aa..7f45c68 100644
--- a/utils/config.js
+++ b/utils/config.js
@@ -1,14 +1,15 @@
-
-// export const BASE_URL="https://frontend.jdns360.com/api"; // 正式环境
-// export const BASE_URL="http://cl55un59859.vicp.fun:24346"; // 测试环境
-
-
// export const BASE_URL = "https://api.tbmall.xin"; //生产
// export const MILD_BASE_URL = "https://agent.tbmall.xin/"; //中台生产
+// export const kefuBaseUrl = "https://admin.tbmall.xin/api";
export const BASE_URL ='https://api.o.tbmall.xin'; // 本地测试
export const MILD_BASE_URL = "https://agent.o.tbmall.xin/"; //中台测试
+export const kefuBaseUrl = "https://admin.o.tbmall.xin/api";
+
// https://h.o.tbmall.xin/#/
+// 客服接口属于管理后台服务,用户端接口已在后端单独放行。
+
+export const KEFU_BASE_URL = kefuBaseUrl;
export const SHARE_URL = 'https://h.tbmall.xin';