From f4565b0ebd7ad6c12583c76e70be9165a38bcee6 Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 1 Jul 2026 11:59:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=9C=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E3=80=81=E5=8E=86=E5=8F=B2=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81=E6=89=AB=E7=A0=81=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E5=95=86=E5=AE=B6=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scriptService 对接话术接口,cs-chat 支持关键词自动回复 - chat-history 增加 waitChatImReady,修复私聊/群聊/客服历史加载 - 恢复 dashouduan/guanshiduan 兼容页跳转 fighter 保留旧二维码 - 商家我的页头像与标签挤压样式优化 Co-authored-by: Cursor --- app.json | 8 ++ pages/chat/chat.js | 26 +++--- pages/cs-chat/cs-chat.js | 106 +++++++++++++++++++----- pages/dashouduan/dashouduan.js | 14 ++++ pages/dashouduan/dashouduan.json | 1 + pages/dashouduan/dashouduan.wxml | 1 + pages/dashouduan/dashouduan.wxss | 1 + pages/group-chat/group-chat.js | 31 +++---- pages/guanshiduan/guanshiduan.js | 14 ++++ pages/guanshiduan/guanshiduan.json | 1 + pages/guanshiduan/guanshiduan.wxml | 1 + pages/guanshiduan/guanshiduan.wxss | 1 + pages/merchant/merchant.wxss | 36 ++++++-- utils/chat-history.js | 17 +++- utils/scriptService.js | 127 +++++++++++++++++++++++++++++ 15 files changed, 330 insertions(+), 55 deletions(-) create mode 100644 pages/dashouduan/dashouduan.js create mode 100644 pages/dashouduan/dashouduan.json create mode 100644 pages/dashouduan/dashouduan.wxml create mode 100644 pages/dashouduan/dashouduan.wxss create mode 100644 pages/guanshiduan/guanshiduan.js create mode 100644 pages/guanshiduan/guanshiduan.json create mode 100644 pages/guanshiduan/guanshiduan.wxml create mode 100644 pages/guanshiduan/guanshiduan.wxss create mode 100644 utils/scriptService.js diff --git a/app.json b/app.json index b1fe7c8..e79f7a6 100644 --- a/app.json +++ b/app.json @@ -17,6 +17,14 @@ "components/chenghao-tag/chenghao-tag" ], "subPackages": [ + { + "root": "pages/dashouduan", + "pages": ["dashouduan"] + }, + { + "root": "pages/guanshiduan", + "pages": ["guanshiduan"] + }, { "root": "pages/pick-user", "pages": ["pick-user"] diff --git a/pages/chat/chat.js b/pages/chat/chat.js index 47b8d07..3f3ec92 100644 --- a/pages/chat/chat.js +++ b/pages/chat/chat.js @@ -7,7 +7,7 @@ import { fetchHistoryMessages, getOldestHistoryTimestamp, mergeHistoryMessages, - waitImConnected, + waitChatImReady, } from '../../utils/chat-history.js'; Page({ @@ -98,27 +98,27 @@ Page({ } }, - ensureChatReady() { + async ensureChatReady() { const targetUserId = getLocalImUserId(app); if (!targetUserId) return; - const ready = () => { + const ready = async () => { this.setupAllListeners(); - this.loadHistory(true); + await this.loadHistory(true); }; - if (app.ensureConnection) { - const ret = app.ensureConnection(); - if (ret && typeof ret.then === 'function') { - ret.then(ready).catch(ready); - } else { - setTimeout(ready, 300); + try { + const ok = await waitChatImReady(app, 12000); + if (ok) { + await ready(); + return; } - return; + } catch (e) { + console.warn('[私聊] IM 连接失败', e); } if (this.isConnected() && wx.goEasy.im?.userId === targetUserId) { - ready(); + await ready(); return; } this.connectGoEasy(targetUserId); @@ -295,7 +295,7 @@ Page({ this.setData({ loadingHistory: true }); try { - const ready = await waitImConnected(app); + const ready = await waitChatImReady(app, 12000); if (!ready) { wx.showToast({ title: '消息服务未连接,请稍后重试', icon: 'none' }); return; diff --git a/pages/cs-chat/cs-chat.js b/pages/cs-chat/cs-chat.js index c8cbe93..1e1a4c8 100644 --- a/pages/cs-chat/cs-chat.js +++ b/pages/cs-chat/cs-chat.js @@ -6,8 +6,9 @@ import { fetchHistoryMessages, getOldestHistoryTimestamp, mergeHistoryMessages, - waitImConnected, + waitChatImReady, } from '../../utils/chat-history.js'; +import { matchAutoReply, getWelcomeText } from '../../utils/scriptService.js'; Page({ data: { @@ -37,6 +38,7 @@ Page({ keyboardHeight: 0, bottomSafeHeight: 140, defaultAvatarUrl: '', + welcomeText: '你好,请问有什么可以帮到您的?', }, onLoad(options) { @@ -58,6 +60,7 @@ Page({ this.setData({ teamId, teamName, teamAvatar: teamAvatarResolved, defaultAvatarUrl: defAvatar }); wx.setNavigationBarTitle({ title: teamName }); this.initCurrentUser(); + this.loadWelcomeScript(); this._unsubConfigAvatar = subscribeConfigAvatarRefresh(this, () => { const def = getDefaultAvatarUrl(app); this.setData({ @@ -100,31 +103,52 @@ Page({ this.clearAllListeners(); }, - autoConnect() { + async loadWelcomeScript() { + try { + const text = await getWelcomeText(); + if (!text) return; + this.setData({ welcomeText: text }); + const msgs = this.data.messages; + const idx = msgs.findIndex((m) => m.messageId === 'welcome-cs'); + if (idx >= 0) { + const updated = [...msgs]; + updated[idx] = { ...updated[idx], payload: { ...updated[idx].payload, text } }; + this.setData({ messages: updated }); + } + } catch (e) { + console.warn('loadWelcomeScript failed', e); + } + }, + + async autoConnect() { const targetUserId = getLocalImUserId(app); if (!targetUserId) return; - const ready = () => { + const ready = async () => { this.setupAllListeners(); - this.loadHistory(true); + await this.loadHistory(true); }; - if (app.ensureConnection) { - app.ensureConnection(); - setTimeout(ready, 400); - return; - } - - const status = wx.goEasy.getConnectionStatus ? wx.goEasy.getConnectionStatus() : 'disconnected'; - if (status === 'connected' || status === 'reconnected') { - const currentUserId = wx.goEasy.im ? wx.goEasy.im.userId : null; - if (currentUserId === targetUserId) { - ready(); + try { + const ok = await waitChatImReady(app, 12000); + if (ok) { + await ready(); return; } - wx.goEasy.disconnect(); + } catch (e) { + console.warn('[客服] IM 连接失败', e); } - this.connectGoEasy(targetUserId); + + if (this.isConnected()) { + await ready(); + } else { + this.connectGoEasy(targetUserId); + } + }, + + isConnected() { + const s = wx.goEasy.getConnectionStatus ? wx.goEasy.getConnectionStatus() : 'disconnected'; + return s === 'connected' || s === 'reconnected'; }, connectGoEasy(userId) { @@ -256,7 +280,7 @@ Page({ this.setData({ loadingHistory: true }); try { - const ready = await waitImConnected(app); + const ready = await waitChatImReady(app, 12000); if (!ready) { wx.showToast({ title: '消息服务未连接,请稍后重试', icon: 'none' }); return; @@ -317,7 +341,7 @@ Page({ name: this.data.teamName || '客服', avatar: resolveAvatarUrl(this.data.teamAvatar, app) || this.data.defaultAvatarUrl, }, - payload: { text: '你好,请问有什么可以帮到您的?' }, + payload: { text: this.data.welcomeText || '你好,请问有什么可以帮到您的?' }, status: 'success', read: false, formattedTime: formatDate(Date.now()), @@ -451,11 +475,53 @@ Page({ }); wx.goEasy.im.sendMessage({ message:msg, - onSuccess:() => this.updateMsg(id, 'success'), + onSuccess:() => { + this.updateMsg(id, 'success'); + this.tryAutoReply(text); + }, onFailed:() => this.updateMsg(id, 'failed') }); }, + async tryAutoReply(userText) { + try { + const result = await matchAutoReply(userText); + if (result && result.matched && result.content) { + this.insertCsAutoReply(result); + } + } catch (e) { + console.warn('tryAutoReply failed', e); + } + }, + + insertCsAutoReply(result) { + const replyType = (result && result.reply_type) || 'text'; + const content = (result && result.content) || ''; + if (!content) return; + const isImage = replyType === 'image'; + const msg = { + messageId: 'auto-reply-' + Date.now(), + type: isImage ? 'image' : 'text', + timestamp: Date.now(), + senderId: this.data.teamId, + receiverId: this.data.currentUser?.id, + senderData: { + name: this.data.teamName, + avatar: resolveAvatarUrl(this.data.teamAvatar, app) || this.data.defaultAvatarUrl, + }, + payload: isImage ? { url: content } : { text: content }, + status: 'success', + read: false, + formattedTime: formatDate(Date.now()), + showTime: this.needShow(Date.now()), + }; + if (isImage) msg.imageUrl = content; + this.setData({ + messages: [...this.data.messages, msg], + scrollToView: 'msg-bottom', + }); + }, + sendImageMsg(filePath, fileObj) { const { currentUser, teamId, teamName, teamAvatar } = this.data; if (!teamId || !currentUser || !filePath) return; diff --git a/pages/dashouduan/dashouduan.js b/pages/dashouduan/dashouduan.js new file mode 100644 index 0000000..395bbe5 --- /dev/null +++ b/pages/dashouduan/dashouduan.js @@ -0,0 +1,14 @@ +/** 旧二维码兼容:dashouduan → 打手中心,inviteCode 自动注册逻辑不变 */ +import { parseSceneOptions } from '../../utils/base-page.js'; + +Page({ + onLoad(options) { + const parsed = parseSceneOptions(options || {}); + const inviteCode = parsed.inviteCode || options.inviteCode || ''; + let url = '/pages/fighter/fighter'; + if (inviteCode) { + url += `?inviteCode=${encodeURIComponent(inviteCode)}`; + } + wx.reLaunch({ url }); + }, +}); diff --git a/pages/dashouduan/dashouduan.json b/pages/dashouduan/dashouduan.json new file mode 100644 index 0000000..52bc9cd --- /dev/null +++ b/pages/dashouduan/dashouduan.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "加载中", "usingComponents": {} } diff --git a/pages/dashouduan/dashouduan.wxml b/pages/dashouduan/dashouduan.wxml new file mode 100644 index 0000000..3dee721 --- /dev/null +++ b/pages/dashouduan/dashouduan.wxml @@ -0,0 +1 @@ +正在进入打手端… diff --git a/pages/dashouduan/dashouduan.wxss b/pages/dashouduan/dashouduan.wxss new file mode 100644 index 0000000..8082289 --- /dev/null +++ b/pages/dashouduan/dashouduan.wxss @@ -0,0 +1 @@ +.tip { display:flex; align-items:center; justify-content:center; min-height:100vh; color:#999; font-size:28rpx; } diff --git a/pages/group-chat/group-chat.js b/pages/group-chat/group-chat.js index 6930909..2a73f76 100644 --- a/pages/group-chat/group-chat.js +++ b/pages/group-chat/group-chat.js @@ -9,7 +9,7 @@ import { fetchHistoryMessages, getOldestHistoryTimestamp, mergeHistoryMessages, - waitImConnected, + waitChatImReady, } from '../../utils/chat-history.js'; Page({ @@ -280,29 +280,32 @@ Page({ this.stopOrderStatusPoll(); }, - autoConnect() { + async autoConnect() { const targetUserId = this.data.currentUser?.id || getLocalImUserId(app); if (!targetUserId) return; - const afterReady = () => { - this.subscribeGroupIfNeeded().then(() => { - this.setupAllListeners(); - this.loadHistory(true); - this.markGroupMessageAsRead(); - }); + const afterReady = async () => { + await this.subscribeGroupIfNeeded(); + this.setupAllListeners(); + await this.loadHistory(true); + this.markGroupMessageAsRead(); }; - if (app.ensureConnection) { - app.ensureConnection(); - setTimeout(afterReady, 400); - return; + try { + const ok = await waitChatImReady(app, 12000); + if (ok) { + await afterReady(); + return; + } + } catch (e) { + console.warn('[群聊] IM 连接失败', e); } const status = wx.goEasy.getConnectionStatus ? wx.goEasy.getConnectionStatus() : 'disconnected'; if (status === 'connected' || status === 'reconnected') { const currentUserId = wx.goEasy.im ? wx.goEasy.im.userId : null; if (currentUserId === targetUserId) { - afterReady(); + await afterReady(); return; } wx.goEasy.disconnect(); @@ -413,7 +416,7 @@ Page({ this.setData({ loadingHistory: true }); try { - const ready = await waitImConnected(app); + const ready = await waitChatImReady(app, 12000); if (!ready) { wx.showToast({ title: '消息服务未连接,请稍后重试', icon: 'none' }); return; diff --git a/pages/guanshiduan/guanshiduan.js b/pages/guanshiduan/guanshiduan.js new file mode 100644 index 0000000..9674848 --- /dev/null +++ b/pages/guanshiduan/guanshiduan.js @@ -0,0 +1,14 @@ +/** 旧二维码兼容:guanshiduan → 打手中心管事注册,逻辑不变 */ +import { parseSceneOptions } from '../../utils/base-page.js'; + +Page({ + onLoad(options) { + const parsed = parseSceneOptions(options || {}); + const inviteCode = parsed.inviteCode || options.inviteCode || ''; + let url = '/pages/fighter/fighter?registerType=guanshi'; + if (inviteCode) { + url += `&inviteCode=${encodeURIComponent(inviteCode)}`; + } + wx.reLaunch({ url }); + }, +}); diff --git a/pages/guanshiduan/guanshiduan.json b/pages/guanshiduan/guanshiduan.json new file mode 100644 index 0000000..52bc9cd --- /dev/null +++ b/pages/guanshiduan/guanshiduan.json @@ -0,0 +1 @@ +{ "navigationBarTitleText": "加载中", "usingComponents": {} } diff --git a/pages/guanshiduan/guanshiduan.wxml b/pages/guanshiduan/guanshiduan.wxml new file mode 100644 index 0000000..4a671a9 --- /dev/null +++ b/pages/guanshiduan/guanshiduan.wxml @@ -0,0 +1 @@ +正在进入管事注册… diff --git a/pages/guanshiduan/guanshiduan.wxss b/pages/guanshiduan/guanshiduan.wxss new file mode 100644 index 0000000..8082289 --- /dev/null +++ b/pages/guanshiduan/guanshiduan.wxss @@ -0,0 +1 @@ +.tip { display:flex; align-items:center; justify-content:center; min-height:100vh; color:#999; font-size:28rpx; } diff --git a/pages/merchant/merchant.wxss b/pages/merchant/merchant.wxss index c1c5b07..499b4c6 100644 --- a/pages/merchant/merchant.wxss +++ b/pages/merchant/merchant.wxss @@ -72,6 +72,21 @@ page { padding: 10rpx 30rpx; } +.user-info > .flex { + align-items: flex-start; + width: 100%; +} + +.user-info > .flex > .avatar { + flex-shrink: 0; +} + +.user-info > .flex > view:not(.avatar) { + flex: 1; + min-width: 0; + overflow: hidden; +} + .avatar { width: 110rpx; height: 110rpx; @@ -90,7 +105,12 @@ page { .nickname-row { flex-wrap: wrap; align-items: center; - gap: 8rpx; + gap: 6rpx; + max-width: 100%; +} + +.sj-identity-tag { + flex-shrink: 0; } .nickname-edit-btn { @@ -124,7 +144,7 @@ page { } .badge-zone { - padding: 0 24rpx 8rpx; + padding: 0 24rpx 4rpx; } .badge-scroll { @@ -134,19 +154,21 @@ page { .badge-tag-wrap { display: inline-block; - transform: scale(0.72); + transform: scale(0.65); transform-origin: left center; - margin-right: -8rpx; + margin-right: -12rpx; } .identity-tags-row { flex-wrap: wrap; - gap: 4rpx; - margin-top: 8rpx; + gap: 2rpx; + margin-top: 6rpx; + max-width: 100%; } .identity-tags-row .badge-tag-wrap { - transform: scale(0.68); + transform: scale(0.62); + margin-right: -14rpx; } .wallet-btns .sj-btn-outline { diff --git a/utils/chat-history.js b/utils/chat-history.js index 4946c9e..6147619 100644 --- a/utils/chat-history.js +++ b/utils/chat-history.js @@ -4,7 +4,7 @@ * - 统一 IM 连接等待与 history API 封装 */ -const SYNTHETIC_MSG_ID = /^(local-|img-|order-|welcome-)/; +const SYNTHETIC_MSG_ID = /^(local-|img-|order-|welcome-|auto-reply-)/; export function isHistorySyntheticMessage(msg) { return !!(msg && msg.messageId && SYNTHETIC_MSG_ID.test(String(msg.messageId))); @@ -22,6 +22,21 @@ export function getOldestHistoryTimestamp(messages, fallback = null) { return oldest != null ? oldest : fallback; } +/** 等待 IM 连接就绪(优先 ensureImForRole,避免未连上就拉历史) */ +export async function waitChatImReady(app, timeoutMs = 12000) { + const role = app?.globalData?.currentRole || 'normal'; + if (app?.ensureImForRole) { + try { + await app.ensureImForRole(role); + if (isImConnected()) return true; + } catch (e) { + console.warn('[IM] ensureImForRole failed', e); + } + } + if (app?.ensureConnection) app.ensureConnection(); + return waitImConnected(app, timeoutMs); +} + export function isImConnected() { const status = wx.goEasy?.getConnectionStatus?.() || 'disconnected'; return (status === 'connected' || status === 'reconnected') && !!wx.goEasy?.im; diff --git a/utils/scriptService.js b/utils/scriptService.js new file mode 100644 index 0000000..635e649 --- /dev/null +++ b/utils/scriptService.js @@ -0,0 +1,127 @@ +// utils/scriptService.js — 客服话术拉取与自动回复匹配 +import request from './request.js'; + +const DEFAULT_SCRIPTS = { + chat_image_confirm: { + title: '温馨提示', + content: '平台禁止任何违法违规行为,聊天内容仅限于商家与接单员正常业务对接。严禁赌博、诈骗及其他非法行为,违者后果自负。', + confirm_text: '确认', + cancel_text: '取消', + }, + cs_welcome: { + content: '你好,请问有什么可以帮到您的?', + }, +}; + +const textCache = {}; +const TEXT_CACHE_TTL = 5 * 60 * 1000; + +function fitModalBtn(text, fallback) { + const t = (text || fallback || '确定').trim(); + if (t && t.length <= 4) return t; + return fallback || '确定'; +} + +function openConfirmModal(script, onConfirm, onCancel) { + wx.showModal({ + title: ((script.title || '提示').trim()).slice(0, 32), + content: script.content || '', + confirmText: fitModalBtn(script.confirm_text, '确认'), + cancelText: fitModalBtn(script.cancel_text, '取消'), + success: (res) => { + if (res.confirm) onConfirm && onConfirm(); + else onCancel && onCancel(); + }, + fail: () => onCancel && onCancel(), + }); +} + +function mergeScript(sceneKey, remote) { + const base = DEFAULT_SCRIPTS[sceneKey] || {}; + if (!remote) return { ...base }; + return { ...base, ...remote }; +} + +export async function fetchScripts(sceneKeys) { + if (!sceneKeys || !sceneKeys.length) return {}; + + const now = Date.now(); + const cached = {}; + const needFetch = []; + + sceneKeys.forEach((key) => { + const entry = textCache[key]; + if (entry && now - entry.time < TEXT_CACHE_TTL) { + cached[key] = entry.data; + } else { + needFetch.push(key); + } + }); + + if (needFetch.length) { + try { + const res = await request({ + url: '/peizhi/huashuhq', + method: 'POST', + data: { scene_keys: needFetch }, + }); + const payload = (res && res.data && res.data.code === 200) ? res.data.data || {} : {}; + needFetch.forEach((key) => { + textCache[key] = { time: now, data: payload[key] }; + cached[key] = payload[key]; + }); + } catch (e) { + console.warn('fetchScripts failed', e); + } + } + + const result = {}; + sceneKeys.forEach((key) => { + result[key] = mergeScript(key, cached[key]); + }); + return result; +} + +export async function showConfirmByScene(sceneKey, onConfirm, onCancel) { + try { + const data = await fetchScripts([sceneKey]); + openConfirmModal(data[sceneKey] || mergeScript(sceneKey, null), onConfirm, onCancel); + } catch (e) { + openConfirmModal(mergeScript(sceneKey, null), onConfirm, onCancel); + } +} + +export async function matchAutoReply(userText) { + if (!userText || !userText.trim()) { + return { matched: false, content: '', reply_type: 'text' }; + } + try { + const res = await request({ + url: '/peizhi/huashu_match', + method: 'POST', + data: { + scene_key: 'cs_auto_reply', + user_text: userText.trim(), + }, + }); + if (res && res.data && res.data.code === 200 && res.data.data) { + return res.data.data; + } + } catch (e) { + console.warn('matchAutoReply failed', e); + } + return { matched: false, content: '', reply_type: 'text' }; +} + +export async function getWelcomeText() { + const data = await fetchScripts(['cs_welcome']); + const script = data.cs_welcome || DEFAULT_SCRIPTS.cs_welcome; + return script.content || DEFAULT_SCRIPTS.cs_welcome.content; +} + +export default { + fetchScripts, + showConfirmByScene, + matchAutoReply, + getWelcomeText, +};