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
+15 -7
View File
@@ -1,5 +1,5 @@
import request from '@/utils/request.js'; import request from '@/utils/request.js';
import { getStorageFun, TOKEN_NAME } from '@/utils/auth.js'; import {getStorageFun,TOKEN_NAME} from '@/utils/auth.js';
/** /**
* 搜索商品 * 搜索商品
@@ -9,7 +9,7 @@ export function searchList(params) {
return request({ return request({
url: "/goods/searchList", url: "/goods/searchList",
method: "get", method: "get",
data: params, data:params,
}); });
} }
@@ -22,7 +22,7 @@ export function getGoodsDetail(params) {
return request({ return request({
url: "/goods/getGoodsDetail", url: "/goods/getGoodsDetail",
method: "get", method: "get",
data: params, data:params,
}); });
} }
@@ -35,10 +35,10 @@ export function addGoods(params) {
return request({ return request({
url: "/cart/addGoods", url: "/cart/addGoods",
method: "post", method: "post",
data: params, data:params,
}); });
} }
/** /**
* 查询规格 * 查询规格
* @param {Object} params * @param {Object} params
*/ */
@@ -46,9 +46,9 @@ export function getGoodsSpecs(params) {
return request({ return request({
url: "/goods/getGoodsSpecs", url: "/goods/getGoodsSpecs",
method: "get", method: "get",
data: params, data:params,
}); });
} }
// 查询商品评价 // 查询商品评价
export function getGoodsReviewsList(params) { export function getGoodsReviewsList(params) {
return request({ 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) { export function getGoodsTags(params) {
return request({ return request({
+17
View File
@@ -38,6 +38,23 @@ import { createI18n } from 'vue-i18n'
const i18n = createI18n(i18nConfig) const i18n = createI18n(i18nConfig)
export function createApp() { export function createApp() {
const app = createSSRApp(App) 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 // #ifndef MP-WEIXIN
app.use(VueClipboard); app.use(VueClipboard);
// #endif // #endif
+2 -1
View File
@@ -79,6 +79,7 @@
"echarts": "^5.1.2", "echarts": "^5.1.2",
"qs-canvas": "^1.0.11", "qs-canvas": "^1.0.11",
"tabber-component": "^1.1.1", "tabber-component": "^1.1.1",
"vue-clipboard2": "^0.3.3" "vue-clipboard2": "^0.3.3",
"vue-i18n": "^9.14.1"
} }
} }
+87 -31
View File
@@ -5,37 +5,25 @@
<view class="mine_data"> <view class="mine_data">
<up-image <up-image
:src="currentUserData.avatarUrl ? currentUserData.avatarUrl : 'https://static.tbmall.xin/static/mine/avatar.png'" :src="currentUserData.avatarUrl ? currentUserData.avatarUrl : 'https://static.tbmall.xin/static/mine/avatar.png'"
shape="circle" shape="circle" width="54" height="54" bgColor="#f1f6ff00"></up-image>
width="54"
height="54"
bgColor="#f1f6ff00"
></up-image>
<view class="mine_info"> <view class="mine_info">
<view class="mine_info_name"> <view class="mine_info_name">
<text style="margin-right: 20rpx">{{ currentUserData.nickname || '信任桥用户' }}</text> <text style="margin-right: 20rpx">{{ currentUserData.nickname || '信任桥用户' }}</text>
<view class="level level-grade" @click="jumpToTrustBridge" v-if="currentUserData.agentName"> <view class="level level-grade" @click="jumpToTrustBridge" v-if="currentUserData.agentName">
<up-image <up-image src="https://static.tbmall.xin/static/mine/rz.png"
src="https://static.tbmall.xin/static/mine/rz.png" style="margin-right: 8rpx; margin-top: 6rpx" shape="circle" width="13" height="13"
style="margin-right: 8rpx; margin-top: 6rpx" bgColor="#f1f6ff00"></up-image>
shape="circle"
width="13"
height="13"
bgColor="#f1f6ff00"
></up-image>
<view>{{ currentUserData.agentName }}</view> <view>{{ currentUserData.agentName }}</view>
</view> </view>
<view :class="['level', currentUserData.grade ? 'level-name' : 'level-grade']" v-if="!currentUserData.agentName && currentUserData.gradeName"> <view :class="['level', currentUserData.grade ? 'level-name' : 'level-grade']"
v-if="!currentUserData.agentName && currentUserData.gradeName">
<view style="display: flex; align-items: center"> <view style="display: flex; align-items: center">
<up-image <up-image
:src="currentUserData.grade ? '/static/common/level.png' : 'https://static.tbmall.xin/static/mine/rz.png'" :src="currentUserData.grade ? '/static/common/level.png' : 'https://static.tbmall.xin/static/mine/rz.png'"
style="margin-right: 8rpx; margin-top: 6rpx" style="margin-right: 8rpx; margin-top: 6rpx" shape="circle" width="14"
shape="circle" height="14" bgColor="#f1f6ff00"></up-image>
width="14"
height="14"
bgColor="#f1f6ff00"
></up-image>
<view>{{ currentUserData.gradeName }}</view> <view>{{ currentUserData.gradeName }}</view>
</view> </view>
</view> </view>
@@ -58,17 +46,20 @@
</view> </view>
<view class="mine_info_id"> <view class="mine_info_id">
<text style="margin-right: 20rpx">邀请码:{{ currentUserData.inviteCode }}</text> <text style="margin-right: 20rpx">邀请码:{{ currentUserData.inviteCode }}</text>
<up-image src="/static/common/copy.png" width="14" height="14" bgColor="#f1f6ff00" @click="copyData(currentUserData.inviteCode)"></up-image> <up-image src="/static/common/copy.png" width="14" height="14" bgColor="#f1f6ff00"
@click="copyData(currentUserData.inviteCode)"></up-image>
</view> </view>
</view> </view>
</view> </view>
<view @click="jumpSetting"> <view @click="jumpSetting">
<up-image src="https://static.tbmall.xin/static/mine/icon_arrow_black_off.png" width="22" height="22" bgColor="#f1f6ff00"></up-image> <up-image src="https://static.tbmall.xin/static/mine/icon_arrow_black_off.png" width="22"
height="22" bgColor="#f1f6ff00"></up-image>
</view> </view>
</view> </view>
<view class="mine_no_login_data" v-else> <view class="mine_no_login_data" v-else>
<view class="mine_data"> <view class="mine_data">
<up-image src="https://static.tbmall.xin/static/avater_not.png" shape="circle" width="80" height="80" bgColor="#f1f6ff00"></up-image> <up-image src="https://static.tbmall.xin/static/avater_not.png" shape="circle" width="80"
height="80" bgColor="#f1f6ff00"></up-image>
<view class="mine_info_not_login">未登录</view> <view class="mine_info_not_login">未登录</view>
</view> </view>
<view @click="jumpLogin" class="login_warp"> <view @click="jumpLogin" class="login_warp">
@@ -84,20 +75,24 @@
<view>我的订单</view> <view>我的订单</view>
<view class="mine_order_title_all" @click="jumpOrder({})"> <view class="mine_order_title_all" @click="jumpOrder({})">
<view style="margin-right: 10rpx; font-size: 28rpx; font-weight: normal">全部</view> <view style="margin-right: 10rpx; font-size: 28rpx; font-weight: normal">全部</view>
<up-image style="margin-top: 10rpx" src="/static/common/right.png" width="15" height="15" bgColor="#f1f6ff00"></up-image> <up-image style="margin-top: 10rpx" src="/static/common/right.png" width="15" height="15"
bgColor="#f1f6ff00"></up-image>
</view> </view>
</view> </view>
<view class="mine_order_list"> <view class="mine_order_list">
<view class="mine_fontsize_comm_warp order_list_item" v-for="item in orderTemplList" :key="item.id" @click="jumpOrder(item)"> <view class="mine_fontsize_comm_warp order_list_item" v-for="item in orderTemplList" :key="item.id"
@click="jumpOrder(item)">
<up-image :src="item.url" width="28" height="28" bgColor="#f1f6ff00"></up-image> <up-image :src="item.url" width="28" height="28" bgColor="#f1f6ff00"></up-image>
<view>{{ item.title }}</view> <view>{{ item.title }}</view>
<up-badge :absolute="true" :offset="[0, 8]" max="99" :value="statisticsObj[item.note]"></up-badge> <up-badge :absolute="true" :offset="[0, 8]" max="99"
:value="statisticsObj[item.note]"></up-badge>
</view> </view>
</view> </view>
</view> </view>
<!-- 应用 --> <!-- 应用 -->
<view class="mine_content_comm_warp mine_top_comm_warp apply_list"> <view class="mine_content_comm_warp mine_top_comm_warp apply_list">
<view class="apply_item mine_fontsize_comm_warp" v-for="item in applyList" :key="item.id" @click="jumpTool(item)"> <view class="apply_item mine_fontsize_comm_warp" v-for="item in applyList" :key="item.id"
@click="jumpTool(item)">
<up-image :src="item.url" width="36" height="36" bgColor="#f1f6ff00"></up-image> <up-image :src="item.url" width="36" height="36" bgColor="#f1f6ff00"></up-image>
<view>{{ item.title }}</view> <view>{{ item.title }}</view>
</view> </view>
@@ -168,7 +163,11 @@
<view class="mine_tool_title">服务工具</view> <view class="mine_tool_title">服务工具</view>
<view class="tool_list"> <view class="tool_list">
<view class="tool_item" v-for="item in toolList" :key="item.id" @click="jumpTool(item)"> <view class="tool_item" v-for="item in toolList" :key="item.id" @click="jumpTool(item)">
<view class="tool-icon-wrap">
<up-image :src="item.url" width="28" height="28" bgColor="#f1f6ff00"></up-image> <up-image :src="item.url" width="28" height="28" bgColor="#f1f6ff00"></up-image>
<view v-if="item.id === 7 && kefuUnreadCount > 0" class="kefu-unread-dot"
aria-label="有新的客服消息"></view>
</view>
<text class="mine_fontsize_comm_warp">{{ item.title }}</text> <text class="mine_fontsize_comm_warp">{{ item.title }}</text>
</view> </view>
</view> </view>
@@ -236,6 +235,7 @@ import CustomTabBar from '@/components/custom-tab-bar.vue';
import { getStatistics } from '@/api/order.js'; import { getStatistics } from '@/api/order.js';
import { getUserInfo } from '@/api/auth.js'; import { getUserInfo } from '@/api/auth.js';
import { CUSTOMER_SERVICE_PHONE_NUMBER } from '@/utils/config.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 { export default {
computed: { computed: {
@@ -411,7 +411,9 @@ export default {
}, // 角标统计 }, // 角标统计
serviceShow: false, // 我的客服弹框 serviceShow: false, // 我的客服弹框
openShow: false, // 暂未开放 openShow: false, // 暂未开放
platformType: true platformType: true,
kefuUnreadCount: 0,
kefuUnreadTimer: null,
}; };
}, },
onShow() { onShow() {
@@ -426,16 +428,45 @@ export default {
this.getStatistics(); this.getStatistics();
this.getUserInfo(); this.getUserInfo();
this.startKefuUnreadPolling();
}, },
mounted() {}, onHide() {
this.stopKefuUnreadPolling();
},
onUnload() {
this.stopKefuUnreadPolling();
},
mounted() { },
methods: { methods: {
copyData, 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() { async getUserInfo() {
const result = await getUserInfo(); const result = await getUserInfo();
if (result && result.bizcode === 100) { if (result && result.bizcode === 100) {
const data = result.data; const data = result.data;
this.currentUserData = result.data || {}; this.currentUserData = result.data || {};
this.refreshKefuUnreadCount();
if (!this.currentUserData?.isNewUser) { if (!this.currentUserData?.isNewUser) {
this.applyList = this.applyList.filter((item) => item.id !== 5); this.applyList = this.applyList.filter((item) => item.id !== 5);
} }
@@ -464,7 +495,7 @@ export default {
// 客服 // 客服
if (item.id === 7) { if (item.id === 7) {
uni.navigateTo({ uni.navigateTo({
url: '/pages/rwa_package/setting/kefu?type=5257' url: "/pages/rwa_package/setting/kefu",
}); });
} else if (item.id === 9) { } else if (item.id === 9) {
// 实名认证 // 实名认证
@@ -573,7 +604,10 @@ export default {
.mine_header { .mine_header {
// height: 300rpx; // height: 300rpx;
background: linear-gradient(180deg, #f2ebff 0%, rgba(245, 245, 247, 0) 100%, #ffffff 100%); background: linear-gradient(180deg,
#f2ebff 0%,
rgba(245, 245, 247, 0) 100%,
#ffffff 100%);
/* 将图片居中显示 */ /* 将图片居中显示 */
padding: 90rpx 20rpx 0; padding: 90rpx 20rpx 0;
@@ -760,6 +794,7 @@ export default {
width: 25%; width: 25%;
text-align: center; text-align: center;
position: relative; position: relative;
} }
.apply_list { .apply_list {
@@ -822,6 +857,27 @@ export default {
width: 25%; width: 25%;
text-align: center; text-align: center;
margin-top: 20rpx; margin-top: 20rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.tool-icon-wrap {
position: relative;
display: inline-flex;
}
.kefu-unread-dot {
position: absolute;
top: -5rpx;
right: -7rpx;
width: 14rpx;
height: 14rpx;
border: 3rpx solid #ffffff;
border-radius: 50%;
background: #f04444;
box-sizing: content-box;
} }
::v-deep .u-modal__content { ::v-deep .u-modal__content {
+7 -4
View File
@@ -134,7 +134,8 @@
@click="onAfterSaleDetail(item)"> @click="onAfterSaleDetail(item)">
售后详情 售后详情
</view> </view>
<view class="operate_comm operate_but_1 operate_comm_evaluate" v-if="item.status ==4 && !item.isReview " @click="evaluate(item)">评价</view> <view class="operate_comm operate_but_1 operate_comm_evaluate" v-if="item.status == 4 && !item.isReview"
@click="evaluate(item)">评价</view>
<!-- <view class="operate_comm operate_but_1" v-if="item.status === ORDER_TYPE.AFTER_SALE">查看售后</view> --> <!-- <view class="operate_comm operate_but_1" v-if="item.status === ORDER_TYPE.AFTER_SALE">查看售后</view> -->
<view class="operate_comm operate_but_1 operate_comm_delete" v-if=" <view class="operate_comm operate_but_1 operate_comm_delete" v-if="
[ [
@@ -421,7 +422,7 @@ export default {
}, },
onKefu() { onKefu() {
uni.navigateTo({ uni.navigateTo({
url: "/pages/rwa_package/setting/kefu?type=5261", url: "/pages/rwa_package/setting/kefu?scene=order",
}); });
}, },
// 售后 // 售后
@@ -484,10 +485,10 @@ export default {
}, },
// 评价 // 评价
evaluate(item) { evaluate(item) {
console.log('订单id',item); console.log('订单id', item);
// return // return
uni.navigateTo({ uni.navigateTo({
url:"/pages/order_package/evaluate/evaluate?id=" + item.id+"&title="+item.items[0].title+"&mainGraph="+item.items[0].mainGraph+"&goodsSpece="+item.items[0].goodsSpece+"&goodsId="+item.items[0].goodsId, url: "/pages/order_package/evaluate/evaluate?id=" + item.id + "&title=" + item.items[0].title + "&mainGraph=" + item.items[0].mainGraph + "&goodsSpece=" + item.items[0].goodsSpece + "&goodsId=" + item.items[0].goodsId,
}); });
}, },
async againDeleteOrder() { async againDeleteOrder() {
@@ -1177,10 +1178,12 @@ export default {
color: #f24a00; color: #f24a00;
border: 1rpx solid #f24a00; border: 1rpx solid #f24a00;
} }
.operate_comm_evaluate { .operate_comm_evaluate {
color: #333333; color: #333333;
border: 1rpx solid #8b8b8b; border: 1rpx solid #8b8b8b;
} }
.sure_pay { .sure_pay {
::v-deep(.u-modal__content__text) { ::v-deep(.u-modal__content__text) {
text-align: center; text-align: center;
+137 -52
View File
@@ -47,7 +47,8 @@
}}</text> }}</text>
<text class="price_o">¥{{ orderInfo.adPrice }}</text> <text class="price_o">¥{{ orderInfo.adPrice }}</text>
</view> </view>
<view class="text-24" style="color: #ff5a5a; height: 38rpx; display: flex; align-items: center; gap: 5rpx" v-if="orderInfo?.goodsTagList"> <view class="text-24" style="color: #ff5a5a; height: 38rpx; display: flex; align-items: center; gap: 5rpx"
v-if="orderInfo?.goodsTagList">
<image v-for="val in orderInfo.goodsTagList" :src="val.icon" mode="heightFix" style="height: 38rpx"> <image v-for="val in orderInfo.goodsTagList" :src="val.icon" mode="heightFix" style="height: 38rpx">
</image> </image>
</view> </view>
@@ -232,13 +233,13 @@
<view v-for="(item, index) in specificationList" :key="item.id" class="list_warp"> <view v-for="(item, index) in specificationList" :key="item.id" class="list_warp">
<view class="list_title_warp">{{ item.type }}</view> <view class="list_title_warp">{{ item.type }}</view>
<view class="specification_item_warp"> <view class="specification_item_warp">
<view :class="oItem.inventory === 0 <view :class="oItem.disabled || oItem.inventory === 0
? 'item_Warp item_Warp_no_warp' ? 'item_Warp item_Warp_no_warp'
: item.selectVal === oItem.n : item.selectVal === oItem.n
? 'item_Warp item_Warp_yes_warp' ? 'item_Warp item_Warp_yes_warp'
: 'item_Warp' : 'item_Warp'
" v-for="(oItem, oIndex) in item.items" :key="oItem.n" " v-for="(oItem, oIndex) in item.items" :key="oItem.id || oItem.n"
@click="clickSpecification(oIndex, item, oItem)"> @click="clickSpecification(index, item, oItem)">
{{ oItem.n }} {{ oItem.n }}
</view> </view>
</view> </view>
@@ -338,7 +339,7 @@
</template> </template>
<script> <script>
import { getGoodsDetail, addGoods, getGoodsSpecs } from "@/api/product.js"; import { getGoodsDetail, addGoods, getGoodsSpecs, getGoodsSpecsInfo } from "@/api/product.js";
import { getValue, PRODUCT_DELIVERY_TIME, ACTIVITY_TYPE } from "@/utils/enumUtils.js"; import { getValue, PRODUCT_DELIVERY_TIME, ACTIVITY_TYPE } from "@/utils/enumUtils.js";
import { copyData } from "@/utils/index.js"; import { copyData } from "@/utils/index.js";
import Evaluation from "@/pages/other_package/productInfo/evaluation.vue"; import Evaluation from "@/pages/other_package/productInfo/evaluation.vue";
@@ -395,6 +396,8 @@ export default {
selectSpecification: [], // 选择的规格 selectSpecification: [], // 选择的规格
specificationTempl: [], // 规格模版数据 specificationTempl: [], // 规格模版数据
selectSpecificationTempl: {}, // 选择规格模版数据 selectSpecificationTempl: {}, // 选择规格模版数据
selectedSpecValueIds: [],
skuBySpecKey: {},
serviceShow: false, // 联系客服弹框 serviceShow: false, // 联系客服弹框
openShow: false, openShow: false,
source: null, // 来源:供应链,自营 source: null, // 来源:供应链,自营
@@ -518,8 +521,17 @@ export default {
if (this.isShareUrl) { if (this.isShareUrl) {
return; return;
} }
uni.setStorageSync("kefu-pending-context", {
type: "product",
data: {
goodsId: this.id,
goodsName: this.orderInfo.name,
goodsMainGraph: this.orderInfo.mainGraph,
currentPrice: this.orderInfo.adPrice,
},
});
uni.navigateTo({ uni.navigateTo({
url: "/pages/rwa_package/setting/kefu?type=5260", url: `/pages/rwa_package/setting/kefu?scene=product&goodsId=${this.id}`,
}); });
}, },
getValue, getValue,
@@ -858,7 +870,6 @@ export default {
const resp = await getGoodsDetail(params); const resp = await getGoodsDetail(params);
if (resp && resp.bizcode === 100) { if (resp && resp.bizcode === 100) {
const data = resp.data; const data = resp.data;
// 限时秒杀 和 积分兑换 不需要购物车 // 限时秒杀 和 积分兑换 不需要购物车
this.isShowCart = this.activedType === 'seckill' || data.type == 3 ? 1 : 0; this.isShowCart = this.activedType === 'seckill' || data.type == 3 ? 1 : 0;
// 商品图 // 商品图
@@ -939,9 +950,11 @@ export default {
} }
// 需要查询一下最新的规格 // 需要查询一下最新的规格
if (val) { if (val) {
await this.querySpecification(); const hasStructuredSpecs = await this.querySpecification();
const data = this.orderInfo; if (!hasStructuredSpecs) {
this.getSpecification(data.specs); uni.showToast({ title: "SKU规格加载失败,请稍后重试", icon: "none" });
return;
}
} else { } else {
this.specificationList = []; //规格 this.specificationList = []; //规格
this.selectSpecification = []; // 选择的规格 this.selectSpecification = []; // 选择的规格
@@ -950,66 +963,138 @@ export default {
this.specificationShow = val; this.specificationShow = val;
this.specificationShowType = type; this.specificationShowType = type;
}, },
// 拆解规格
getSpecification(specs) {
const optionArr = [];
if (specs) {
const arr = JSON.parse(specs);
arr.map((item) => {
optionArr.push({
type: item.type,
items: item.items,
selectVal: null,
});
});
}
this.specificationList = optionArr;
},
// 获取规格模版数据 // 获取规格模版数据
async querySpecification() { async querySpecification() {
const params = { let infoResp = null;
id: this.id, try {
}; infoResp = await getGoodsSpecsInfo({ id: this.id });
if (this.isCoupon) { } catch (e) {
params.type = -1; return false;
} }
if (this.activedType == "seckill") { if (!infoResp || infoResp.bizcode !== 100 || !infoResp.data) {
params["activityType"] = this.activedType; // 活动类型 return false;
params["activityId"] = this.activityId;// 秒杀活动ID
} }
const resp = await getGoodsSpecs(params); const groups = infoResp.data.specGroups || [];
if (resp && resp.bizcode === 100) { const skus = infoResp.data.skus || [];
const data = resp.data; if (!groups.length || !skus.length) {
let stock = 0; return false;
data.map((item) => { }
stock = stock + item.stock;
const skuBySpecKey = {};
skus.forEach((sku) => {
const valueIds = this.getSkuValueIds(sku, groups);
if (valueIds.length !== groups.length) {
return;
}
const normalizedSku = Object.assign({}, sku, {
id: sku.id,
valueIds,
minBuyNum: sku.minBuyNum || 1,
combTypes: this.formatSkuSelection(groups, valueIds),
}); });
this.specificationTempl = data; skuBySpecKey[valueIds.join("_")] = normalizedSku;
this.orderInfo.inventory = stock; // 库存 });
if (!Object.keys(skuBySpecKey).length) {
return false;
} }
this.skuBySpecKey = skuBySpecKey;
this.specificationTempl = Object.keys(skuBySpecKey).map((key) => skuBySpecKey[key]);
this.orderInfo.inventory = this.specificationTempl.reduce(
(total, sku) => total + Number(sku.stock || 0),
0
);
this.selectedSpecValueIds = groups.map(() => null);
this.selectSpecification = groups.map(() => null);
this.selectSpecificationTempl = {};
this.specificationList = groups.map((group) => ({
id: group.id,
type: group.name,
selectVal: null,
items: (group.values || []).map((value) => ({
id: value.id,
groupId: group.id,
n: value.name,
image: value.image,
disabled: false,
})),
}));
this.refreshSpecOptionState();
return true;
},
getSkuValueIds(sku, groups) {
if (sku.specValueIds) {
try {
const ids = JSON.parse(sku.specValueIds);
if (Array.isArray(ids)) {
return ids.map((id) => Number(id));
}
} catch (e) { }
}
let path = {};
try {
path = JSON.parse(sku.specValuePath || "{}") || {};
} catch (e) { }
const legacyValues = String(path.legacyName || "")
.split(/\s*(?:\/|♧)\s*/)
.filter(Boolean);
return groups.map((group, index) => {
const selectedName = path[group.name] || legacyValues[index];
const value = (group.values || []).find((item) => item.name === selectedName);
return value ? Number(value.id) : null;
}).filter((id) => id !== null);
},
formatSkuSelection(groups, valueIds) {
return groups.map((group, index) => {
const value = (group.values || []).find((item) => Number(item.id) === Number(valueIds[index]));
return value ? `${group.name}:${value.name}` : "";
}).filter(Boolean).join(";");
},
refreshSpecOptionState() {
this.specificationList.forEach((group, groupIndex) => {
group.items.forEach((value) => {
const candidate = this.selectedSpecValueIds.slice();
candidate[groupIndex] = Number(value.id);
value.disabled = !Object.keys(this.skuBySpecKey).some((key) => {
const sku = this.skuBySpecKey[key];
const skuIds = sku.valueIds || [];
return candidate.every((selectedId, index) =>
selectedId == null || Number(skuIds[index]) === Number(selectedId)
) && Number(sku.stock || 0) > 0;
});
});
});
}, },
//改变规格的样式 //改变规格的样式
clickSpecification(pIndex, item, oItem) { clickSpecification(groupIndex, item, oItem) {
console.log( console.log(
"改变规格的样式", "改变规格的样式",
pIndex, groupIndex,
item, item,
oItem, oItem,
this.specificationTempl, this.specificationTempl,
this.specificationList this.specificationList
); );
const currentSelectSpecification = this.specificationTempl[pIndex]; if (oItem.disabled) {
return;
console.log("当前规格", currentSelectSpecification); }
this.orderInfo.quantity = currentSelectSpecification?.minBuyNum || 1; this.selectedSpecValueIds.splice(groupIndex, 1, Number(oItem.id));
this.selectSpecification.splice(groupIndex, 1, oItem.n);
this.specificationList[0].selectVal = oItem.n; this.specificationList[groupIndex].selectVal = oItem.n;
this.refreshSpecOptionState();
if (this.selectedSpecValueIds.every((id) => id != null)) {
const currentSelectSpecification = this.skuBySpecKey[this.selectedSpecValueIds.join("_")] || {};
this.selectSpecificationTempl = currentSelectSpecification; this.selectSpecificationTempl = currentSelectSpecification;
this.orderInfo.inventory = currentSelectSpecification.stock; // 库存 this.orderInfo.quantity = currentSelectSpecification.minBuyNum || 1;
// 计算规格的价格 this.orderInfo.inventory = currentSelectSpecification.stock || 0;
// this.selectSpecificationFun(pIndex, oItem); } else {
this.selectSpecificationTempl = {};
}
}, },
// 选择规格 // 选择规格
selectSpecificationFun(index, item) { selectSpecificationFun(index, item) {
+99 -60
View File
@@ -1,87 +1,126 @@
<template> <template>
<view class="content"> <view class="customer-service-page">
<TopSafe></TopSafe> <TopSafe />
<view class="setting-view" v-if="h5Url"> <view class="customer-service-nav">
<iframe :src="h5Url" frameborder="no" border="0" class="web-view"> <button class="nav-back" aria-label="返回" @click="goBack">
<u-icon name="arrow-left" size="25" color="#2f3037" />
</iframe> </button>
<text class="nav-title">全媒体智能客服</text>
<view class="nav-placeholder" />
</view>
<button class="history-entry" @click="showHistory">点击查询聊天记录</button>
<view class="customer-service-body">
<hashmall-customer-service v-if="optionsReady" ref="customerService" :entry-options="entryOptions" />
</view> </view>
<!-- 腾讯电子签H5 → 集成到UniApp -->
</view> </view>
</template> </template>
<script> <script>
import { getUserInfo,uploadAvatar } from '@/api/auth.js';
import { uploadImage, uploadImg } from '@/api/common.js';
import { IMG_TYPE } from '@/utils/enumUtils.js';
import { BASE_URL, Authorization } from '@/utils/config.js';
import { getStorageFun, TOKEN_NAME,clear } from '@/utils/auth.js';
import Header from '@/components/common/header.vue';
import TopSafe from '@/components/common/top-safe.nvue' import TopSafe from '@/components/common/top-safe.nvue'
import MyBtn from '@/components/common/my-btn.vue' import HashmallCustomerService from '@/uni_modules/hashmall-customer-service/components/hashmall-customer-service/hashmall-customer-service.vue'
import func from '@/utils/func.js';
export default { export default {
components: { Header, TopSafe,MyBtn }, components: {
TopSafe,
HashmallCustomerService
},
data() { data() {
return { return {
h5Url:'', entryOptions: {},
optionsReady: false
}; }
},
onLoad(options) {
this.entryOptions = options || {}
this.optionsReady = true
}, },
onShow() { onShow() {
this.$nextTick(() => {
if (this.$refs.customerService) this.$refs.customerService.resume()
})
}, },
onLoad(option) { onHide() {
if(option.type ){ if (this.$refs.customerService) this.$refs.customerService.suspend()
this.init(option.type); },
} onUnload() {
if (this.$refs.customerService) this.$refs.customerService.suspend()
}, },
methods: { methods: {
init(type) { goBack() {
this.getUserInfo(type) uni.navigateBack()
}, },
async getUserInfo(type) { showHistory() {
const result = await getUserInfo(); if (this.$refs.customerService) this.$refs.customerService.showHistory()
if (result && result.bizcode === 100) {
const data = result.data;
this.currentUserData = result.data || {};
let url = `https://www.aigcyun.cn/uniChat/#/?skillGroupId=${type}&enterpriseId=1435&extraData=`;
let addHyExtraData = {
customerId:data.id,
customerName:data.nickname,
};
this.h5Url = url + encodeURIComponent(JSON.stringify(addHyExtraData))
} }
},
} }
} }
</script> </script>
<style lang="scss" scoped> <style scoped lang="scss">
.web-view{ .customer-service-page {
height:100%;
width:100%;
position: relative;
}
.setting-view {
width: 100%;
height: calc(100% - var(--status-bar-height));
display: flex; display: flex;
flex-direction: column; width: 100%;
align-items: center; height: 100vh;
overflow-y: auto; height: 100dvh;
box-sizing: border-box; box-sizing: border-box;
position: relative; flex-direction: column;
overflow: hidden;
background: #f4f5fa;
} }
</style> .customer-service-nav {
<style> display: flex;
.chatModel{ height: 92rpx;
height:calc(100% - var(--status-bar-height)); align-items: center;
margin-top:var(--status-bar-height); justify-content: space-between;
} padding: 0 28rpx;
background: #f4f5fa;
}
.nav-back,
.nav-placeholder {
width: 72rpx;
height: 72rpx;
flex: 0 0 72rpx;
}
.nav-back {
display: flex;
align-items: center;
justify-content: flex-start;
margin: 0;
padding: 0;
background: transparent;
}
.nav-title {
color: #101116;
font-size: 38rpx;
font-weight: 500;
line-height: 1.2;
}
.history-entry {
width: 100%;
height: 72rpx;
margin: 0;
padding: 0;
background: transparent;
color: #a2a3aa;
font-size: 27rpx;
font-weight: 400;
line-height: 72rpx;
}
.nav-back::after,
.history-entry::after {
border: 0;
}
.customer-service-body {
width: 100%;
min-height: 0;
flex: 1;
overflow: hidden;
}
</style> </style>
@@ -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

+6 -5
View File
@@ -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 BASE_URL = "https://api.tbmall.xin"; //生产
// export const MILD_BASE_URL = "https://agent.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 BASE_URL ='https://api.o.tbmall.xin'; // 本地测试
export const MILD_BASE_URL = "https://agent.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/#/ // https://h.o.tbmall.xin/#/
// 客服接口属于管理后台服务,用户端接口已在后端单独放行。
export const KEFU_BASE_URL = kefuBaseUrl;
export const SHARE_URL = 'https://h.tbmall.xin'; export const SHARE_URL = 'https://h.tbmall.xin';