Files
frontend-app/uni_modules/hashmall-customer-service/components/hashmall-customer-service/hashmall-customer-service.vue
T
2026-08-25 17:52:05 +08:00

2345 lines
76 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="chat-page">
<view v-if="statusBanner" class="connection-banner" :class="`connection-banner--${socketStatus}`">
<view class="status-dot" />
<text>{{ statusBanner }}</text>
</view>
<view v-if="member" class="service-route-banner" :class="`service-route-banner--${serviceMode.toLowerCase()}`">
<view class="service-route-banner__copy">
<text class="service-route-banner__title">{{ serviceModeLabel }}</text>
<text v-if="aiTyping" class="service-route-banner__typing">正在输入…</text>
</view>
<button v-if="serviceMode === 'AI_ACTIVE'" class="service-route-banner__action" @click="requestHumanService">转人工</button>
</view>
<scroll-view
class="message-scroll"
scroll-y
:scroll-top="scrollCommandTop"
:scroll-into-view="scrollTarget"
:scroll-with-animation="shouldAnimateScroll"
:upper-threshold="80"
@scrolltoupper="loadEarlierMessages"
@scroll="handleScroll"
>
<view class="message-list">
<view v-if="loadingInitial" class="message-skeleton" aria-label="正在加载消息">
<view v-for="index in 5" :key="index" class="skeleton-row" :class="{ 'skeleton-row--self': index % 2 === 0 }">
<view class="skeleton-bubble" />
</view>
</view>
<template v-else>
<button v-if="hasEarlier" class="history-button" :disabled="loadingEarlier" @click="loadEarlierMessages">
{{ loadingEarlier ? '正在加载' : '查看更早消息' }}
</button>
<customer-service-message-item
v-if="!displayMessages.length && member"
id="message-welcome"
:message="welcomeMessage"
:show-time="true"
service-avatar="/uni_modules/hashmall-customer-service/static/tb-service-avatar.png"
:member-avatar="memberAvatar"
:member-name="memberName"
/>
<view v-if="!displayMessages.length && !member" class="empty-state empty-state--login">
<image class="empty-avatar" src="/uni_modules/hashmall-customer-service/static/tb-service-avatar.png" mode="aspectFit" />
<text class="empty-title">登录后联系在线客服</text>
<text class="empty-copy">登录后可继续历史咨询,并发送相关商品或订单信息。</text>
<button class="login-button" @click="goToLogin">登录商城账号</button>
</view>
<view v-if="virtualTopSpacerHeight" class="virtual-list-spacer" :style="{ height: `${virtualTopSpacerHeight}px` }" />
<view
v-for="(message, visibleIndex) in virtualMessages"
:id="messageDomId(message)"
:key="messageKey(message)"
class="virtual-message-row"
:data-message-index="virtualStart + visibleIndex"
>
<customer-service-message-item
:message="message"
:show-time="shouldShowTime(message, virtualStart + visibleIndex)"
service-avatar="/uni_modules/hashmall-customer-service/static/tb-service-avatar.png"
:member-avatar="memberAvatar"
:member-name="memberName"
:transcribing="Boolean(transcribingMessageIds[String(message.id)])"
@retry="retryMessage"
@open-card="openMessageCard"
@actions="handleMessageActions"
@transcribe="transcribeAudioMessage"
@layout-change="scheduleVirtualMeasure"
/>
</view>
<view v-if="virtualBottomSpacerHeight" class="virtual-list-spacer" :style="{ height: `${virtualBottomSpacerHeight}px` }" />
<view id="message-list-bottom" class="message-list-bottom" />
</template>
</view>
</scroll-view>
<button v-if="showNewMessageButton" class="new-message-button" @click="scrollToBottom(true)">有新消息</button>
<view class="composer-shell" :class="{ 'composer-shell--panel': actionPanelVisible }">
<customer-service-context-card
v-if="pendingContext"
:context="pendingContext"
:sending="sendingContext"
@send="sendPendingContext"
@remove="pendingContext = null"
/>
<view v-if="quotedMessage" class="composer-quote">
<view class="composer-quote__content">
<text class="composer-quote__title">回复 {{ messageSenderName(quotedMessage) }}</text>
<text class="composer-quote__preview">{{ messagePreview(quotedMessage) }}</text>
</view>
<button class="composer-quote__close" aria-label="取消引用" @click="quotedMessage = null">
<u-icon name="close" size="18" color="#686970" />
</button>
</view>
<view v-if="recording" class="voice-recording-mask" :class="{ 'voice-recording-mask--cancel': recordingCancelled }">
<view class="voice-recording-card">
<view class="voice-recording-icon">
<u-icon :name="recordingCancelled ? 'close-circle' : 'mic'" size="42" color="#fff" />
</view>
<text class="voice-recording-time">{{ recordingSeconds }}″</text>
<text class="voice-recording-tip">{{ recordingCancelled ? '松开手指,取消发送' : '上滑取消发送' }}</text>
</view>
</view>
<view class="composer">
<button class="composer-icon-button" :aria-label="voiceMode ? '切换文字输入' : '切换语音输入'" @click="toggleVoiceMode">
<u-icon :name="voiceMode ? 'edit-pen' : 'mic'" size="27" color="#292a31" />
</button>
<textarea
v-if="!voiceMode"
v-model="inputText"
class="message-input"
:maxlength="1000"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="true"
:cursor-spacing="18"
placeholder="请描述您的问题"
placeholder-class="message-input__placeholder"
confirm-type="send"
@confirm="sendTextMessage"
@focus="closeActionPanel"
/>
<button
v-else
class="hold-to-talk-button"
:class="{ 'hold-to-talk-button--active': recording, 'hold-to-talk-button--cancel': recordingCancelled }"
aria-label="按住说话"
@touchstart.stop.prevent="handleVoicePressStart"
@touchmove.stop.prevent="handleVoicePressMove"
@touchend.stop.prevent="handleVoicePressEnd"
@touchcancel.stop.prevent="handleVoicePressCancel"
@mousedown.prevent="handleVoiceMouseDown"
@mouseup.prevent="handleVoiceMouseUp"
@mouseleave="handleVoiceMouseLeave"
>{{ recording ? (recordingCancelled ? '松开取消' : '松开发送') : '按住说话' }}</button>
<button v-if="!voiceMode && canSendText" class="send-button" @click="sendTextMessage">发送</button>
<template v-else-if="!voiceMode">
<button class="composer-icon-button" aria-label="表情" @click="handleEmojiTap">
<u-icon name="more-circle" size="29" color="#292a31" />
</button>
<button class="composer-icon-button" aria-label="更多发送方式" :disabled="uploadingImage" @click="chooseAndSendImage">
<u-icon name="plus-circle" size="30" color="#292a31" />
</button>
</template>
</view>
<view class="composer-safe-area" />
</view>
<view v-if="actionPanelVisible" class="action-panel">
<view class="action-grid">
<button class="action-item" @click="chooseImageFromPanel">
<view class="action-icon"><u-icon name="photo" size="32" color="#303138" /></view>
<text>相册</text>
</button>
<button class="action-item" @click="chooseVideoFromPanel">
<view class="action-icon"><u-icon name="movie" size="32" color="#303138" /></view>
<text>视频</text>
</button>
<button class="action-item" @click="chooseFileFromPanel">
<view class="action-icon"><u-icon name="folder" size="32" color="#303138" /></view>
<text>文件</text>
</button>
<button class="action-item" @click="closeConversation">
<view class="action-icon"><u-icon name="close-circle" size="32" color="#303138" /></view>
<text>关闭会话</text>
</button>
</view>
</view>
<view v-if="pickerVisible" class="panel-mask" @click="closePicker" />
<view v-if="pickerVisible" class="picker-panel">
<view class="panel-header">
<text class="panel-title">{{ pickerType === 'product' ? '最近浏览' : '选择订单' }}</text>
<button class="panel-close" @click="closePicker">关闭</button>
</view>
<scroll-view class="picker-list" scroll-y>
<view v-if="pickerLoading" class="picker-status">正在加载</view>
<view v-else-if="!pickerItems.length" class="picker-status">暂无可发送的{{ pickerType === 'product' ? '商品' : '订单' }}</view>
<button v-for="item in pickerItems" :key="pickerItemKey(item)" class="picker-item" @click="selectPickerItem(item)">
<image class="picker-image" :src="pickerItemImage(item)" mode="aspectFill" />
<view class="picker-copy">
<text class="picker-title">{{ pickerItemTitle(item) }}</text>
<text class="picker-meta">{{ pickerItemMeta(item) }}</text>
</view>
<text class="picker-action">选择</text>
</button>
</scroll-view>
</view>
</view>
</template>
<script>
import CustomerServiceMessageItem from '../kefu-message-item/kefu-message-item.vue'
import CustomerServiceContextCard from '../kefu-context-card/kefu-context-card.vue'
import {
CONTENT_TYPE,
deleteMessageForMember,
getBrowseHistory,
getCurrentMember,
getMessages,
markMemberMessagesRead,
getOrders,
recallMessage,
sendMessage,
transferToHuman,
transcribeMessage,
uploadMessageAudio,
uploadMessageFile,
uploadMessageImage
} from '../../js_sdk/api.js'
import { getStorageFun, TOKEN_NAME, USER_DATA } from '@/utils/auth.js'
import { KefuSocket } from '../../js_sdk/kefu-socket.js'
function getToken() {
return getStorageFun(TOKEN_NAME) || ''
}
function getCachedMember() {
return getStorageFun(USER_DATA) || null
}
function redirectToLogin() {
uni.navigateTo({ url: '/pages/login_package/login/login' })
}
const PAGE_SIZE = 80
const POLLING_INTERVAL = 4000
const VIRTUAL_LIST_THRESHOLD = 100
const VIRTUAL_OVERSCAN_VIEWPORTS = 1.25
function createPreviewMessages(state, now) {
if (state === 'history') {
return Array.from({ length: 12 }, (_, index) => ({
id: `preview-history-${index + 1}`,
senderType: index === 9 ? 2 : 1,
senderName: index === 9 ? '体验用户' : '系统',
contentType: CONTENT_TYPE.TEXT,
content: index === 9 ? '申请退款,一直审核失败' : (index % 4 === 3 ? '11' : '1'),
ctdate: index === 9 ? now - 60000 : now - (12 - index) * 60000
}))
}
return [
{ id: 'preview-service', senderType: 1, senderName: '系统', contentType: CONTENT_TYPE.TEXT, content: '无坐席在线,请留言。', ctdate: now - 60000 },
{ id: 'preview-member', senderType: 2, senderName: '体验用户', contentType: CONTENT_TYPE.TEXT, content: '申请退款,一直审核失败', ctdate: now }
]
}
export default {
name: 'HashmallCustomerService',
components: {
CustomerServiceMessageItem,
CustomerServiceContextCard
},
props: {
entryOptions: { type: Object, default: () => ({}) }
},
data() {
return {
member: null,
conversation: null,
messages: [],
inputText: '',
loadingInitial: true,
loadingEarlier: false,
currentPage: 1,
totalPage: 1,
hasEarlier: false,
scrollTarget: '',
shouldAnimateScroll: false,
userNearBottom: true,
showNewMessageButton: false,
socketStatus: 'connecting',
socket: null,
pollingTimer: null,
pageHidden: false,
uploadingImage: false,
uploadingAttachment: false,
recording: false,
voiceMode: false,
recordingCancelled: false,
voiceGestureActive: false,
voiceStartY: 0,
lastVoiceTouchAt: 0,
recordingSeconds: 0,
recordingStartedAt: 0,
recordingTimer: null,
recorderManager: null,
webMediaRecorder: null,
webMediaStream: null,
webAudioChunks: [],
discardRecording: false,
sendingContext: false,
pendingContext: null,
quotedMessage: null,
lastActionSheetAt: 0,
actionPanelVisible: false,
pickerVisible: false,
pickerType: 'product',
pickerItems: [],
pickerLoading: false,
currentEntryOptions: {},
transcribingMessageIds: {},
aiTyping: false,
previewNow: Date.now(),
virtualStart: 0,
virtualEnd: 0,
currentScrollTop: 0,
scrollCommandTop: 0,
scrollViewportHeight: 0,
messageHeightCache: {},
virtualMeasureTimer: null,
scrollCommandNonce: false
}
},
computed: {
canSendText() {
return Boolean(this.member && this.inputText.trim())
},
memberAvatar() {
return (this.member && (this.member.avatarUrl || this.member.avatar || this.member.userImage)) || ''
},
memberName() {
return (this.member && (this.member.nickname || this.member.name || this.member.userName)) || '我'
},
previewMode() {
// #ifdef H5
return typeof window !== 'undefined' && window.location.hash.includes('preview=design')
// #endif
// #ifndef H5
return false
// #endif
},
displayMessages() {
if (!this.previewMode || (typeof window !== 'undefined' && !window.location.hash.includes('state=message'))) return this.messages
return createPreviewMessages('message', this.previewNow)
},
messageCount() {
return this.displayMessages.length
},
virtualizationEnabled() {
return this.messageCount > VIRTUAL_LIST_THRESHOLD
},
virtualMetrics() {
const offsets = [0]
this.displayMessages.forEach((message, index) => {
const key = String(this.messageKey(message))
const height = Number(this.messageHeightCache[key]) || this.estimateMessageHeight(message, index)
offsets.push(offsets[offsets.length - 1] + height)
})
return { offsets, totalHeight: offsets[offsets.length - 1] || 0 }
},
virtualMessages() {
if (!this.virtualizationEnabled) return this.displayMessages
const end = this.virtualEnd > this.virtualStart
? this.virtualEnd
: Math.min(this.messageCount, 40)
return this.displayMessages.slice(this.virtualStart, end)
},
virtualTopSpacerHeight() {
if (!this.virtualizationEnabled) return 0
return this.virtualMetrics.offsets[this.virtualStart] || 0
},
virtualBottomSpacerHeight() {
if (!this.virtualizationEnabled) return 0
const renderedEnd = this.virtualEnd > this.virtualStart ? this.virtualEnd : Math.min(this.messageCount, 40)
return Math.max(0, this.virtualMetrics.totalHeight - (this.virtualMetrics.offsets[renderedEnd] || 0))
},
welcomeMessage() {
return {
id: 'welcome',
senderType: 1,
senderName: '系统',
contentType: CONTENT_TYPE.TEXT,
content: '无坐席在线,请留言。',
ctdate: Date.now()
}
},
statusBanner() {
if (this.socketStatus === 'connecting') return '正在连接客服'
if (this.socketStatus === 'offline') return '实时连接中断,正在使用消息同步'
if (this.socketStatus === 'polling') return '网络不稳定,消息将自动同步'
return ''
},
serviceMode() {
return (this.conversation && this.conversation.serviceMode) || 'AI_ACTIVE'
},
serviceModeLabel() {
return {
AI_ACTIVE: 'AI 智能客服为您服务',
HUMAN_PENDING: '已通知人工客服,请稍候',
HUMAN_ACTIVE: '人工客服正在为您服务',
CLOSED: '本次会话已结束'
}[this.serviceMode] || 'AI 智能客服为您服务'
}
},
watch: {
messageCount() {
this.refreshVirtualLayout()
}
},
mounted() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const page = pages.length ? pages[pages.length - 1] : null
this.currentEntryOptions = { ...((page && page.options) || {}), ...(this.entryOptions || {}) }
this.restorePendingContext()
this.appShowHandler = () => {
if (!this.pageHidden) this.resume({ forceReconnect: true })
}
this.appHideHandler = () => {
if (!this.pageHidden) this.suspend()
}
uni.$on('kefu-app-show', this.appShowHandler)
uni.$on('kefu-app-hide', this.appHideHandler)
this.initialize()
this.$nextTick(() => this.measureScrollViewport())
},
beforeUnmount() {
this.unbindAppLifecycle()
this.cancelVoiceRecording()
this.stopRealtime()
this.clearVirtualListTimers()
},
beforeDestroy() {
this.unbindAppLifecycle()
this.cancelVoiceRecording()
this.stopRealtime()
this.clearVirtualListTimers()
},
methods: {
unbindAppLifecycle() {
if (this.appShowHandler) uni.$off('kefu-app-show', this.appShowHandler)
if (this.appHideHandler) uni.$off('kefu-app-hide', this.appHideHandler)
this.appShowHandler = null
this.appHideHandler = null
},
async initialize() {
// #ifdef H5
const previewHash = typeof window !== 'undefined' ? window.location.hash : ''
if (this.currentEntryOptions.preview === 'design' || previewHash.includes('preview=design')) {
const now = Date.now()
this.member = {
id: 10001,
nickname: '体验用户',
avatarUrl: 'https://static.tbmall.xin/static/avater_not.png'
}
const previewState = this.currentEntryOptions.state || (previewHash.includes('state=history') ? 'history' : previewHash.includes('state=message') ? 'message' : '')
this.messages = previewState ? createPreviewMessages(previewState, now) : []
this.loadingInitial = false
this.socketStatus = 'closed'
this.$nextTick(() => this.scrollToBottom(false))
return
}
// #endif
if (!getToken()) {
this.loadingInitial = false
this.socketStatus = 'closed'
return
}
try {
this.member = getCachedMember()
const current = await getCurrentMember()
if (current) this.member = current
if (!this.member || !this.member.id) throw new Error('无法获取当前用户')
await this.loadLatestMessages()
await this.resolveEntryContext()
this.connectSocket()
} catch (error) {
this.showError(error)
} finally {
this.loadingInitial = false
this.$nextTick(() => this.scrollToBottom(false))
}
},
async loadLatestMessages({ silent = false } = {}) {
if (!this.member || !this.member.id) return
try {
const knownLastPage = this.conversation && this.conversation.id && this.totalPage
? Number(this.totalPage)
: 1
let result = await getMessages({
userId: this.member.id,
conversationId: this.conversation && this.conversation.id,
page: knownLastPage,
pageSize: PAGE_SIZE
})
let pageResult = result.messages || {}
let totalPage = Number(pageResult.totalPage || 1)
// 首次加载从第 1 页取得会话信息;只有确实存在后续页时才再查末页。
// 后续轮询直接查已知末页,避免每 4 秒重复请求首页和末页。
if (totalPage > knownLastPage) {
result = await getMessages({
userId: this.member.id,
conversationId: result.conversation && result.conversation.id,
page: totalPage,
pageSize: PAGE_SIZE
})
pageResult = result.messages || {}
totalPage = Number(pageResult.totalPage || totalPage)
}
this.conversation = result.conversation || this.conversation
const page = pageResult
this.currentPage = Number(page.curPage || totalPage || 1)
this.totalPage = Number(page.totalPage || totalPage || 1)
this.hasEarlier = this.currentPage > 1
const incoming = page.entitys || []
const added = this.mergeMessages(incoming)
if (added && this.conversation && this.conversation.id && incoming.some(item => Number(item.senderType) !== 2)) {
await markMemberMessagesRead({ userId: this.member.id, conversationId: this.conversation.id }).catch(() => {})
}
if (added && this.userNearBottom) this.$nextTick(() => this.scrollToBottom(false))
if (added && !this.userNearBottom) this.showNewMessageButton = true
} catch (error) {
if (!silent) throw error
}
},
async loadEarlierMessages() {
if (!this.hasEarlier || this.loadingEarlier || !this.member) return
this.loadingEarlier = true
try {
const targetPage = Math.max(1, this.currentPage - 1)
const result = await getMessages({
userId: this.member.id,
conversationId: this.conversation && this.conversation.id,
page: targetPage,
pageSize: PAGE_SIZE
})
const page = result.messages || {}
const rows = page.entitys || []
const known = new Set(this.messages.map(item => String(item.id || item.clientMessageId)))
const earlier = rows.filter(item => !known.has(String(item.id)))
const previousScrollTop = this.currentScrollTop
this.messages = [...earlier, ...this.messages]
this.currentPage = targetPage
this.hasEarlier = targetPage > 1 && rows.length > 0
if (earlier.length) {
const prependedHeight = earlier.reduce((total, message, index) => total + this.estimateMessageHeight(message, index), 0)
const targetScrollTop = previousScrollTop + prependedHeight
this.updateVirtualWindow(targetScrollTop)
this.$nextTick(() => {
this.setScrollTop(targetScrollTop)
this.scheduleVirtualMeasure(40)
})
}
} catch (error) {
this.showError(error)
} finally {
this.loadingEarlier = false
}
},
mergeMessages(incoming) {
if (!Array.isArray(incoming) || !incoming.length) return false
let changed = false
incoming.forEach(message => {
if (!message || message.memberDeleted) return
const savedIndex = message.id == null ? -1 : this.messages.findIndex(item => String(item.id || '') === String(message.id))
if (savedIndex >= 0) {
this.messages.splice(savedIndex, 1, { ...this.messages[savedIndex], ...message, sendStatus: 'sent' })
return
}
const optimisticIndex = Number(message.senderType) === 2
? this.messages.findIndex(item => !item.id && item.sendStatus === 'sending' && Number(item.contentType) === Number(message.contentType) && String(item.content) === String(message.content))
: -1
if (optimisticIndex >= 0) {
const optimistic = this.messages[optimisticIndex]
this.messages.splice(optimisticIndex, 1, { ...message, clientMessageId: optimistic.clientMessageId, sendStatus: 'sent' })
} else {
this.messages.push(message)
}
changed = true
})
if (changed) this.messages.sort((a, b) => this.messageTimestamp(a) - this.messageTimestamp(b))
return changed
},
connectSocket() {
if (!this.member || !this.member.id || this.socket) return
this.socket = new KefuSocket({
onStatus: status => this.handleSocketStatus(status),
onEvent: event => this.handleSocketEvent(event),
onReconnect: () => {
if (!this.socket) return
this.socket.connect({
userId: this.member.id,
mchId: this.conversation && this.conversation.mchId,
conversationId: this.conversation && this.conversation.id
})
}
})
this.socket.connect({
userId: this.member.id,
mchId: this.conversation && this.conversation.mchId,
conversationId: this.conversation && this.conversation.id
})
},
handleSocketStatus(status) {
this.socketStatus = status
if (status === 'online') {
this.stopPolling()
this.loadLatestMessages({ silent: true })
}
if (status === 'connecting' || status === 'offline') this.startPolling()
},
handleSocketEvent(event) {
if (!event) return
if (event.type === 'kefu_message_type' && event.content) {
const added = this.mergeMessages([event.content])
if ([1, 3, 4].includes(Number(event.content.senderType)) && this.member && this.conversation) {
markMemberMessagesRead({ userId: this.member.id, conversationId: this.conversation.id }).catch(() => {})
}
if (added && this.userNearBottom) this.$nextTick(() => this.scrollToBottom(true))
if (added && !this.userNearBottom) this.showNewMessageButton = true
}
if (event.type === 'kefu_conversation_change' && event.content) {
this.conversation = event.content
}
if (event.type === 'kefu_service_state' && event.content) {
this.conversation = event.content
}
if (event.type === 'kefu_ai_typing' && event.content) {
this.aiTyping = Boolean(event.content.typing)
}
if (event.type === 'kefu_message_recall' && event.content) {
this.replaceMessage(event.content)
}
},
async requestHumanService() {
if (!this.member || !this.conversation) return
try {
this.conversation = await transferToHuman({
userId: this.member.id,
conversationId: this.conversation.id
})
uni.showToast({ title: '已通知人工客服', icon: 'none' })
} catch (error) {
uni.showToast({ title: error.message || '转人工失败', icon: 'none' })
}
},
startPolling() {
if (this.pageHidden || this.pollingTimer) return
this.socketStatus = 'polling'
this.loadLatestMessages({ silent: true })
this.pollingTimer = setInterval(() => this.loadLatestMessages({ silent: true }), POLLING_INTERVAL)
},
stopPolling() {
if (this.pollingTimer) clearInterval(this.pollingTimer)
this.pollingTimer = null
},
stopRealtime() {
this.stopPolling()
if (this.socket) this.socket.close()
this.socket = null
},
async sendTextMessage() {
const content = this.inputText.trim()
if (!content || !this.member) return
const quoted = this.quotedMessage
this.inputText = ''
this.quotedMessage = null
if (quoted) {
const payload = JSON.stringify({
text: content,
quote: {
messageId: quoted.id || quoted.clientMessageId,
senderName: this.messageSenderName(quoted),
contentType: Number(quoted.contentType),
content: this.messageCopyText(quoted)
}
})
await this.sendOptimisticMessage(CONTENT_TYPE.QUOTE, payload)
return
}
await this.sendOptimisticMessage(CONTENT_TYPE.TEXT, content)
},
async sendOptimisticMessage(contentType, content) {
const clientMessageId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`
const localMessage = {
clientMessageId,
senderType: 2,
senderId: this.member.id,
senderName: this.member.nickname || this.member.name || '我',
contentType,
content,
ctdate: Date.now(),
sendStatus: 'sending'
}
this.messages.push(localMessage)
this.$nextTick(() => this.scrollToBottom(true))
try {
const saved = await sendMessage({
userId: this.member.id,
userNickname: this.member.nickname || this.member.name,
userAvatar: this.member.avatarUrl || this.member.avatar,
contentType,
content
})
this.resolveOptimisticMessage(clientMessageId, saved)
// AI reply is persisted before the send API returns. Pull the latest page here as
// a delivery fallback so a short WebSocket disconnect cannot hide the reply.
await this.loadLatestMessages({ silent: true })
return saved
} catch (error) {
const index = this.messages.findIndex(item => item.clientMessageId === clientMessageId)
if (index >= 0) this.messages.splice(index, 1, { ...localMessage, sendStatus: 'failed', errorMessage: error.message })
this.showError(error)
return null
}
},
async retryMessage(message) {
const index = this.messages.findIndex(item => item.clientMessageId === message.clientMessageId)
if (index >= 0) this.messages.splice(index, 1)
await this.sendOptimisticMessage(message.contentType, message.content)
},
resolveOptimisticMessage(clientMessageId, saved) {
const localIndex = this.messages.findIndex(item => item.clientMessageId === clientMessageId)
const savedIndex = saved && saved.id != null
? this.messages.findIndex(item => String(item.id || '') === String(saved.id))
: -1
if (savedIndex >= 0) {
this.messages.splice(savedIndex, 1, { ...this.messages[savedIndex], ...saved, sendStatus: 'sent' })
if (localIndex >= 0 && localIndex !== savedIndex) {
this.messages.splice(localIndex, 1)
}
return
}
if (localIndex >= 0) {
this.messages.splice(localIndex, 1, { ...saved, clientMessageId, sendStatus: 'sent' })
return
}
if (saved) this.mergeMessages([saved])
},
replaceMessage(message) {
if (!message || message.id == null) return
const index = this.messages.findIndex(item => String(item.id || '') === String(message.id))
if (index >= 0) {
this.messages.splice(index, 1, { ...this.messages[index], ...message })
const key = String(this.messageKey(message))
const nextCache = { ...this.messageHeightCache }
delete nextCache[key]
this.messageHeightCache = nextCache
this.scheduleVirtualMeasure()
}
},
handleMessageActions(message) {
if (!message || Number(message.contentType) === CONTENT_TYPE.RECALLED) return
const now = Date.now()
if (now - this.lastActionSheetAt < 600) return
this.lastActionSheetAt = now
const actions = [{ key: 'copy', label: '复制' }]
if (this.canRecallMessage(message)) actions.push({ key: 'recall', label: '撤回' })
actions.push({ key: 'quote', label: '引用' })
actions.push({ key: 'delete', label: '删除' })
uni.showActionSheet({
itemList: actions.map(item => item.label),
success: result => this.runMessageAction(actions[result.tapIndex] && actions[result.tapIndex].key, message)
})
},
runMessageAction(action, message) {
if (action === 'copy') this.copyMessage(message)
if (action === 'recall') this.confirmRecallMessage(message)
if (action === 'quote') this.quoteMessage(message)
if (action === 'delete') this.confirmDeleteMessage(message)
},
copyMessage(message) {
uni.setClipboardData({ data: this.messageCopyText(message), showToast: false })
uni.showToast({ title: '已复制', icon: 'none' })
},
quoteMessage(message) {
this.closeActionPanel()
this.quotedMessage = message
},
canRecallMessage(message) {
if (!message || Number(message.senderType) !== 2 || !message.id) return false
const elapsed = Date.now() - this.messageTimestamp(message)
return elapsed >= 0 && elapsed <= 3 * 60 * 1000
},
confirmRecallMessage(message) {
uni.showModal({
title: '撤回消息',
content: '撤回后双方都将看到撤回提示。',
confirmText: '撤回',
confirmColor: '#7934f6',
success: result => {
if (result.confirm) this.recallOwnMessage(message)
}
})
},
async recallOwnMessage(message) {
if (!this.canRecallMessage(message)) {
uni.showToast({ title: '消息已超过 3 分钟,无法撤回', icon: 'none' })
return
}
if (this.previewMode) {
this.replaceMessage({ ...message, contentType: CONTENT_TYPE.RECALLED, content: '用户撤回了一条消息' })
return
}
try {
const recalled = await recallMessage({ userId: this.member.id, messageId: message.id })
this.replaceMessage(recalled)
} catch (error) {
this.showError(error)
}
},
confirmDeleteMessage(message) {
uni.showModal({
title: '删除消息',
content: '只会从你的聊天记录中删除,客服仍可查看。',
confirmText: '删除',
confirmColor: '#d14343',
success: result => {
if (result.confirm) this.deleteOwnRecord(message)
}
})
},
async deleteOwnRecord(message) {
const remove = () => {
const index = this.messages.findIndex(item => this.messageKey(item) === this.messageKey(message))
if (index >= 0) this.messages.splice(index, 1)
}
if (!message.id) {
remove()
return
}
if (this.previewMode) {
remove()
return
}
try {
await deleteMessageForMember({ userId: this.member.id, messageId: message.id })
remove()
} catch (error) {
this.showError(error)
}
},
messageSenderName(message) {
return Number(message.senderType) === 2
? (this.memberName || message.senderName || '我')
: (message.senderName || '客服')
},
messageCopyText(message) {
if ([CONTENT_TYPE.FILE, CONTENT_TYPE.AUDIO].includes(Number(message.contentType))) {
try {
const data = typeof message.content === 'string' ? JSON.parse(message.content) : message.content
return data.name || data.url || ''
} catch (error) {
return String(message.content || '')
}
}
if (Number(message.contentType) === CONTENT_TYPE.QUOTE) {
try {
const data = typeof message.content === 'string' ? JSON.parse(message.content) : message.content
return String((data && data.text) || '')
} catch (error) {
return String(message.content || '')
}
}
return String(message.content || '')
},
messagePreview(message) {
const type = Number(message.contentType)
if (type === CONTENT_TYPE.FILE) return '[文件]'
if (type === CONTENT_TYPE.AUDIO) return '[语音]'
if (type === CONTENT_TYPE.IMAGE) return '[图片]'
if (type === CONTENT_TYPE.PRODUCT) return '[商品]'
if (type === CONTENT_TYPE.ORDER) return '[订单]'
if (type === CONTENT_TYPE.QUOTE) return `[引用] ${this.messageCopyText(message)}`
return this.messageCopyText(message).slice(0, 80)
},
chooseAndSendImage() {
if (this.uploadingImage) return
if (this.actionPanelVisible) {
this.closeActionPanel()
return
}
// Stop pinning the scroll view to the bottom while the action panel changes
// the available height; otherwise the first message metadata can be clipped.
this.scrollTarget = ''
this.actionPanelVisible = true
},
async chooseImageFromPanel() {
this.closeActionPanel()
await this.pickAndUploadImage()
},
checkMicrophoneAuthorized() {
// #ifdef H5
return true
// #endif
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
uni.getSetting({
success: (res) => {
resolve(res.authSetting['scope.record'] === true)
},
fail: () => resolve(false)
})
})
// #endif
// #ifdef APP-PLUS
return new Promise((resolve) => {
const platform = uni.getSystemInfoSync().platform
if (platform === 'android') {
try {
const main = plus.android.runtimeMainActivity()
const PackageManager = plus.android.importClass('android.content.pm.PackageManager')
const permission = main.checkSelfPermission('android.permission.RECORD_AUDIO')
resolve(permission === PackageManager.PERMISSION_GRANTED)
} catch (e) {
resolve(false)
}
} else if (platform === 'ios') {
try {
const avSession = plus.ios.importClass('AVAudioSession')
const audioSession = avSession.sharedInstance()
const permissionStatus = audioSession.recordPermission()
resolve(permissionStatus === 1970168948)
} catch (e) {
resolve(false)
}
} else {
resolve(true)
}
})
// #endif
},
async requestMicrophonePermission() {
// #ifdef H5
if (typeof navigator !== 'undefined' && navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function') {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach(track => track.stop())
return true
} catch (e) {
uni.showModal({
title: '麦克风权限提示',
content: '请允许浏览器访问麦克风以使用语音录制功能。',
showCancel: false
})
return false
}
}
return true
// #endif
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
uni.getSetting({
success: (res) => {
const recordAuth = res.authSetting['scope.record']
if (recordAuth === true) {
resolve(true)
} else if (recordAuth === false) {
uni.showModal({
title: '麦克风权限未开启',
content: '需要访问您的麦克风发送语音消息,请前往设置开启权限。',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting()
}
resolve(false)
}
})
} else {
uni.authorize({
scope: 'scope.record',
success: () => resolve(true),
fail: () => {
uni.showModal({
title: '授权提示',
content: '麦克风授权失败,无法使用语音功能。',
showCancel: false
})
resolve(false)
}
})
}
},
fail: () => resolve(true)
})
})
// #endif
// #ifdef APP-PLUS
return new Promise((resolve) => {
const platform = uni.getSystemInfoSync().platform
if (platform === 'android') {
try {
plus.android.requestPermissions(
['android.permission.RECORD_AUDIO'],
(result) => {
if (result.granted && result.granted.length > 0) {
resolve(true)
} else {
uni.showModal({
title: '麦克风权限未开启',
content: 'App 需要您的同意才能访问麦克风以发送语音消息。',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
const Intent = plus.android.importClass('android.content.Intent')
const Settings = plus.android.importClass('android.provider.Settings')
const Uri = plus.android.importClass('android.net.Uri')
const main = plus.android.runtimeMainActivity()
const intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
const uri = Uri.fromParts('package', main.getPackageName(), null)
intent.setData(uri)
main.startActivity(intent)
}
resolve(false)
}
})
}
},
(error) => {
console.error('requestPermissions error:', error)
resolve(true)
}
)
} catch (e) {
resolve(true)
}
} else if (platform === 'ios') {
try {
const avSession = plus.ios.importClass('AVAudioSession')
const audioSession = avSession.sharedInstance()
const permissionStatus = audioSession.recordPermission()
if (permissionStatus === 1970168948) { // AVAudioSessionRecordPermissionGranted
resolve(true)
} else if (permissionStatus === 1970168947) { // AVAudioSessionRecordPermissionDenied
uni.showModal({
title: '麦克风权限未开启',
content: 'App 需要您的同意才能访问麦克风,请前往设置开启麦克风权限。',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
const UIApplication = plus.ios.importClass('UIApplication')
const NSURL = plus.ios.importClass('NSURL')
const app = UIApplication.sharedApplication()
const settingsUrl = NSURL.URLWithString('app-settings:')
if (app.canOpenURL(settingsUrl)) {
app.openURL(settingsUrl)
}
}
resolve(false)
}
})
} else { // Undetermined
audioSession.requestRecordPermission((granted) => {
resolve(granted)
})
}
} catch (e) {
resolve(true)
}
} else {
resolve(true)
}
})
// #endif
},
toggleVoiceMode() {
if (this.uploadingAttachment) return
if (this.recording) this.cancelVoiceRecording()
this.voiceMode = !this.voiceMode
this.recordingCancelled = false
this.closeActionPanel()
},
voiceEventY(event) {
const touch = event && event.touches && event.touches[0]
? event.touches[0]
: event && event.changedTouches && event.changedTouches[0]
return Number((touch && touch.clientY) || (event && event.clientY) || 0)
},
async handleVoicePressStart(event) {
if (this.uploadingAttachment || this.voiceGestureActive || this.recording) return
if (event && event.touches) this.lastVoiceTouchAt = Date.now()
this.voiceGestureActive = true
this.recordingCancelled = false
this.voiceStartY = this.voiceEventY(event)
const ok = await this.startVoiceRecording()
if (!ok || (!this.voiceGestureActive && this.recording)) {
this.cancelVoiceRecording()
}
},
handleVoicePressMove(event) {
if (!this.voiceGestureActive) return
const currentY = this.voiceEventY(event)
if (!currentY || !this.voiceStartY) return
const shouldCancel = this.voiceStartY - currentY > 60
if (shouldCancel !== this.recordingCancelled) {
this.recordingCancelled = shouldCancel
if (shouldCancel && typeof uni.vibrateShort === 'function') uni.vibrateShort({ type: 'light' })
}
},
handleVoicePressEnd() {
if (!this.voiceGestureActive) return
this.voiceGestureActive = false
if (!this.recording) return
const tooShort = Date.now() - this.recordingStartedAt < 800
if (this.recordingCancelled || tooShort) {
this.cancelVoiceRecording()
if (tooShort && !this.recordingCancelled) uni.showToast({ title: '说话时间太短', icon: 'none' })
return
}
this.stopVoiceRecording()
},
handleVoicePressCancel() {
if (!this.voiceGestureActive && !this.recording) return
this.voiceGestureActive = false
this.recordingCancelled = true
this.cancelVoiceRecording()
},
handleVoiceMouseDown(event) {
if (Date.now() - this.lastVoiceTouchAt < 600) return
this.handleVoicePressStart(event)
},
handleVoiceMouseUp() {
if (Date.now() - this.lastVoiceTouchAt < 600) return
this.handleVoicePressEnd()
},
handleVoiceMouseLeave() {
if (!this.voiceGestureActive || Date.now() - this.lastVoiceTouchAt < 600) return
this.handleVoicePressCancel()
},
handleEmojiTap() {
uni.showToast({ title: '表情功能暂未开放', icon: 'none' })
},
chooseVideoFromPanel() {
this.closeActionPanel()
uni.showToast({ title: '视频消息接口待接入', icon: 'none' })
},
chooseFileFromPanel() {
this.closeActionPanel()
this.pickAndUploadFile()
return
uni.showToast({ title: '文件消息接口待接入', icon: 'none' })
},
async startVoiceRecording() {
this.discardRecording = false
this.recordingSeconds = 0
this.recordingStartedAt = Date.now()
// #ifdef H5
if (typeof navigator !== 'undefined' && navigator.mediaDevices && typeof MediaRecorder !== 'undefined') {
try {
this.webMediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
const preferred = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type))
this.webAudioChunks = []
this.webMediaRecorder = new MediaRecorder(this.webMediaStream, preferred ? { mimeType: preferred } : undefined)
this.webMediaRecorder.ondataavailable = event => { if (event.data && event.data.size) this.webAudioChunks.push(event.data) }
this.webMediaRecorder.onstop = () => {
if (this.discardRecording) {
this.discardRecording = false
this.releaseWebRecorder()
this.finishRecordingState()
return
}
const duration = Math.max(1, Math.round((Date.now() - this.recordingStartedAt) / 1000))
const mimeType = this.webMediaRecorder && this.webMediaRecorder.mimeType || 'audio/webm'
const extension = mimeType.includes('mp4') ? 'm4a' : 'webm'
const blob = new Blob(this.webAudioChunks, { type: mimeType })
this.releaseWebRecorder()
if (blob.size) this.uploadAndSendAudio(new File([blob], `voice-${Date.now()}.${extension}`, { type: mimeType }), duration)
}
this.webMediaRecorder.start(500)
this.markRecordingStarted()
return true
} catch (error) {
this.releaseWebRecorder()
this.showError(new Error('无法使用麦克风,请检查浏览器权限'))
return false
}
}
// #endif
if (typeof uni.getRecorderManager !== 'function') {
uni.showToast({ title: '当前设备暂不支持录音', icon: 'none' })
return false
}
if (!this.recorderManager) {
this.recorderManager = uni.getRecorderManager()
this.recorderManager.onStop(result => {
if (this.discardRecording) {
this.discardRecording = false
this.finishRecordingState()
return
}
const duration = Math.max(1, Math.round(Number(result.duration || Date.now() - this.recordingStartedAt) / 1000))
this.finishRecordingState()
if (result.tempFilePath) this.uploadAndSendAudio(result.tempFilePath, duration)
})
this.recorderManager.onError(() => {
this.finishRecordingState()
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' })
})
}
this.recorderManager.start({ duration: 60000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' })
this.markRecordingStarted()
return true
},
markRecordingStarted() {
this.recording = true
this.recordingStartedAt = Date.now()
clearInterval(this.recordingTimer)
this.recordingTimer = setInterval(() => {
this.recordingSeconds = Math.min(60, Math.floor((Date.now() - this.recordingStartedAt) / 1000))
if (this.recordingSeconds >= 60) {
this.voiceGestureActive = false
this.stopVoiceRecording()
}
}, 500)
},
stopVoiceRecording() {
if (!this.recording) return
this.recording = false
clearInterval(this.recordingTimer)
this.recordingTimer = null
// #ifdef H5
if (this.webMediaRecorder && this.webMediaRecorder.state !== 'inactive') {
this.webMediaRecorder.stop()
return
}
// #endif
if (this.recorderManager) this.recorderManager.stop()
},
cancelVoiceRecording() {
this.discardRecording = true
this.voiceGestureActive = false
clearInterval(this.recordingTimer)
this.recordingTimer = null
this.recording = false
// #ifdef H5
if (this.webMediaRecorder) this.webMediaRecorder.onstop = null
if (this.webMediaRecorder && this.webMediaRecorder.state !== 'inactive') this.webMediaRecorder.stop()
this.releaseWebRecorder()
// #endif
if (this.recorderManager) this.recorderManager.stop()
},
releaseWebRecorder() {
if (this.webMediaStream) this.webMediaStream.getTracks().forEach(track => track.stop())
this.webMediaStream = null
this.webMediaRecorder = null
this.webAudioChunks = []
},
finishRecordingState() {
this.recording = false
this.recordingCancelled = false
this.voiceGestureActive = false
clearInterval(this.recordingTimer)
this.recordingTimer = null
},
async uploadAndSendAudio(source, duration) {
if (!source || duration < 1) return
this.uploadingAttachment = true
uni.showLoading({ title: '正在发送语音', mask: true })
try {
const url = await uploadMessageAudio(source)
await this.sendOptimisticMessage(CONTENT_TYPE.AUDIO, JSON.stringify({ url, duration, name: source.name || `voice-${Date.now()}.mp3` }))
} catch (error) {
this.showError(error)
} finally {
this.finishRecordingState()
uni.hideLoading()
this.uploadingAttachment = false
}
},
async transcribeAudioMessage(message) {
if (!message || !message.id || !this.member) return
const key = String(message.id)
if (this.transcribingMessageIds[key]) return
this.transcribingMessageIds = { ...this.transcribingMessageIds, [key]: true }
try {
const updated = await transcribeMessage({ userId: this.member.id, messageId: message.id })
if (updated) this.replaceMessage(updated)
} catch (error) {
this.showError(error)
} finally {
const next = { ...this.transcribingMessageIds }
delete next[key]
this.transcribingMessageIds = next
}
},
pickChatFile() {
return new Promise((resolve, reject) => {
const success = result => {
const file = result.tempFiles && result.tempFiles[0]
if (!file) return reject(new Error('未选择文件'))
if (Number(file.size || 0) > 50 * 1024 * 1024) return reject(new Error('文件不能超过 50MB'))
resolve({ source: file.file || file.tempFilePath || file.path || file, name: file.name || '聊天文件', size: Number(file.size || 0) })
}
if (typeof uni.chooseMessageFile === 'function') {
uni.chooseMessageFile({ count: 1, type: 'file', success, fail: reject })
return
}
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.onchange = () => success({ tempFiles: input.files && input.files.length ? [input.files[0]] : [] })
input.click()
// #endif
// #ifndef H5
reject(new Error('当前设备暂不支持选择文件'))
// #endif
})
},
async pickAndUploadFile() {
if (this.uploadingAttachment) return
try {
const selected = await this.pickChatFile()
this.uploadingAttachment = true
uni.showLoading({ title: '正在上传文件', mask: true })
const url = await uploadMessageFile(selected.source)
await this.sendOptimisticMessage(CONTENT_TYPE.FILE, JSON.stringify({ url, name: selected.name, size: selected.size }))
} catch (error) {
if (!String(error.errMsg || '').includes('cancel')) this.showError(error)
} finally {
uni.hideLoading()
this.uploadingAttachment = false
}
},
closeConversation() {
this.closeActionPanel()
uni.showModal({
title: '关闭会话',
content: '确定结束本次客服会话吗?',
confirmColor: '#7934f6',
success: result => {
if (!result.confirm) return
this.stopRealtime()
uni.navigateBack()
}
})
},
pickImage() {
return new Promise((resolve, reject) => {
const success = result => {
const file = result.tempFiles && result.tempFiles[0]
const path = (file && (file.tempFilePath || file.path)) || (result.tempFilePaths && result.tempFilePaths[0])
if (!path) return reject(new Error('未选择图片'))
const size = Number((file && file.size) || 0)
if (size > 10 * 1024 * 1024) return reject(new Error('图片不能超过 10MB'))
resolve(path)
}
if (typeof uni.chooseMedia === 'function') {
uni.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['album', 'camera'], success, fail: reject })
} else {
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success, fail: reject })
}
})
},
compressImage(filePath) {
if (typeof uni.compressImage !== 'function') return Promise.resolve(filePath)
return new Promise(resolve => {
uni.compressImage({ src: filePath, quality: 78, success: result => resolve(result.tempFilePath), fail: () => resolve(filePath) })
})
},
async pickAndUploadImage() {
this.uploadingImage = true
uni.showLoading({ title: '正在上传图片', mask: true })
try {
const selected = await this.pickImage()
const compressed = await this.compressImage(selected)
const imageUrl = await uploadMessageImage(compressed)
await this.sendOptimisticMessage(CONTENT_TYPE.IMAGE, imageUrl)
} catch (error) {
if (!String(error.errMsg || '').includes('cancel')) this.showError(error)
} finally {
uni.hideLoading()
this.uploadingImage = false
}
},
async openPicker(type) {
this.closeActionPanel()
this.pickerType = type
this.pickerItems = []
this.pickerVisible = true
this.pickerLoading = true
try {
const page = type === 'product'
? await getBrowseHistory({ userId: this.member.id, page: 1, pageSize: 20 })
: await getOrders({ userId: this.member.id, page: 1, pageSize: 20 })
this.pickerItems = (page && page.entitys) || []
} catch (error) {
this.showError(error)
} finally {
this.pickerLoading = false
}
},
selectPickerItem(item) {
this.pendingContext = { type: this.pickerType, data: item }
this.pickerVisible = false
},
async sendPendingContext() {
if (!this.pendingContext || this.sendingContext) return
this.sendingContext = true
try {
const type = this.pendingContext.type === 'product' ? CONTENT_TYPE.PRODUCT : CONTENT_TYPE.ORDER
const data = this.pendingContext.data || {}
const content = JSON.stringify({ schemaVersion: 1, ...data })
const saved = await this.sendOptimisticMessage(type, content)
if (saved) this.pendingContext = null
} finally {
this.sendingContext = false
}
},
restorePendingContext() {
const stored = uni.getStorageSync('kefu-pending-context')
if (stored && stored.type && stored.data) {
this.pendingContext = stored
uni.removeStorageSync('kefu-pending-context')
}
},
async resolveEntryContext() {
if (this.pendingContext || !this.currentEntryOptions.scene) return
const type = this.currentEntryOptions.scene === 'order' ? 'order' : 'product'
const id = String(type === 'order' ? this.currentEntryOptions.orderId || '' : this.currentEntryOptions.goodsId || '')
if (!id) return
try {
const page = type === 'product'
? await getBrowseHistory({ userId: this.member.id, page: 1, pageSize: 50 })
: await getOrders({ userId: this.member.id, page: 1, pageSize: 50 })
const rows = (page && page.entitys) || []
const match = rows.find(item => String(type === 'product' ? item.goodsId : item.id) === id)
if (match) this.pendingContext = { type, data: match }
} catch (error) {
console.warn('[customer-service] unable to resolve context', error)
}
},
openMessageCard({ type, data }) {
const isProduct = Number(type) === CONTENT_TYPE.PRODUCT
const id = isProduct ? data.goodsId || data.id : data.id || data.orderId
if (!id) return
const url = isProduct
? `/pages/other_package/productInfo/productInfo?id=${encodeURIComponent(id)}`
: `/pages/mine_package/mine_order_info/mine_order_info?id=${encodeURIComponent(id)}`
uni.navigateTo({ url, fail: () => uni.showToast({ title: '当前独立项目未接入详情页', icon: 'none' }) })
},
closeActionPanel() {
this.actionPanelVisible = false
},
closePicker() {
this.pickerVisible = false
},
handleScroll(event) {
const detail = event.detail || {}
const systemInfo = uni.getSystemInfoSync()
const viewportHeight = Number(detail.clientHeight || (systemInfo.windowHeight || 0) * 0.62)
const scrollTop = Math.max(0, Number(detail.scrollTop || 0))
this.currentScrollTop = scrollTop
if (viewportHeight > 0) this.scrollViewportHeight = viewportHeight
this.updateVirtualWindow(scrollTop)
const remaining = Number(detail.scrollHeight || 0) - scrollTop - viewportHeight
this.userNearBottom = remaining < 140
if (this.userNearBottom) this.showNewMessageButton = false
},
scrollToBottom(animate) {
this.shouldAnimateScroll = Boolean(animate)
this.scrollTarget = ''
this.updateVirtualWindow(this.virtualMetrics.totalHeight)
this.$nextTick(() => {
this.scrollTarget = 'message-list-bottom'
this.userNearBottom = true
this.showNewMessageButton = false
this.scheduleVirtualMeasure(30)
})
},
refreshVirtualLayout() {
if (!this.virtualizationEnabled) {
this.virtualStart = 0
this.virtualEnd = this.messageCount
return
}
const target = this.userNearBottom ? this.virtualMetrics.totalHeight : this.currentScrollTop
this.updateVirtualWindow(target)
this.scheduleVirtualMeasure()
},
updateVirtualWindow(scrollTop) {
if (!this.virtualizationEnabled) return
const viewport = this.scrollViewportHeight || Math.max(320, Number(uni.getSystemInfoSync().windowHeight || 0) * 0.62)
const overscan = viewport * VIRTUAL_OVERSCAN_VIEWPORTS
const offsets = this.virtualMetrics.offsets
const start = this.findVirtualIndex(offsets, Math.max(0, Number(scrollTop || 0) - overscan))
const end = Math.min(this.messageCount, this.findVirtualIndex(offsets, Number(scrollTop || 0) + viewport + overscan) + 1)
if (start === this.virtualStart && end === this.virtualEnd) return
this.virtualStart = start
this.virtualEnd = Math.max(start + 1, end)
this.$nextTick(() => this.scheduleVirtualMeasure())
},
findVirtualIndex(offsets, targetOffset) {
if (!offsets.length || targetOffset <= 0) return 0
let low = 0
let high = Math.max(0, offsets.length - 2)
while (low < high) {
const middle = Math.floor((low + high) / 2)
if (offsets[middle + 1] < targetOffset) low = middle + 1
else high = middle
}
return low
},
estimateMessageHeight(message, index) {
const systemInfo = uni.getSystemInfoSync()
const rpx = Math.max(0.42, Number(systemInfo.windowWidth || 375) / 750)
const type = Number(message && message.contentType)
const isSelf = Number(message && message.senderType) === 2
const showTime = index === 0 || this.shouldShowTime(message, index)
let heightRpx = 128
if (type === CONTENT_TYPE.RECALLED) heightRpx = 70
else if (type === CONTENT_TYPE.IMAGE) heightRpx = 310
else if (type === CONTENT_TYPE.PRODUCT || type === CONTENT_TYPE.ORDER) heightRpx = 196
else if (type === CONTENT_TYPE.FILE) heightRpx = 152
else if (type === CONTENT_TYPE.AUDIO) {
const content = this.parseAttachmentContent(message && message.content)
heightRpx = content.transcript ? 190 : 142
} else {
const contentLength = String((message && message.content) || '').length
const lines = Math.max(1, Math.min(12, Math.ceil(contentLength / 18)))
heightRpx = 104 + (lines - 1) * 45
}
if (showTime || isSelf) heightRpx += 48
if (isSelf) heightRpx += 40
return Math.max(42, Math.round(heightRpx * rpx))
},
parseAttachmentContent(content) {
if (!content || typeof content !== 'string' || content.charAt(0) !== '{') return {}
try {
return JSON.parse(content) || {}
} catch (error) {
return {}
}
},
measureScrollViewport() {
const query = uni.createSelectorQuery().in(this)
query.select('.message-scroll').boundingClientRect(rect => {
if (rect && Number(rect.height) > 0) {
this.scrollViewportHeight = Number(rect.height)
this.refreshVirtualLayout()
}
}).exec()
},
scheduleVirtualMeasure(delay = 16) {
if (!this.virtualizationEnabled) return
if (this.virtualMeasureTimer) clearTimeout(this.virtualMeasureTimer)
this.virtualMeasureTimer = setTimeout(() => {
this.virtualMeasureTimer = null
this.measureVisibleMessages()
}, Math.max(0, Number(delay) || 0))
},
measureVisibleMessages() {
if (!this.virtualizationEnabled || !this.virtualMessages.length) return
const beforeMetrics = this.virtualMetrics
const query = uni.createSelectorQuery().in(this)
query.selectAll('.virtual-message-row').fields({ size: true, dataset: true }, rows => {
if (!Array.isArray(rows) || !rows.length) return
const nextCache = { ...this.messageHeightCache }
let changed = false
let correction = 0
rows.forEach(row => {
const index = Number(row && row.dataset && row.dataset.messageIndex)
const message = this.displayMessages[index]
const measuredHeight = Math.round(Number(row && row.height) || 0)
if (!message || !measuredHeight) return
const key = String(this.messageKey(message))
const previousHeight = Number(nextCache[key]) || this.estimateMessageHeight(message, index)
if (Math.abs(previousHeight - measuredHeight) <= 1) return
nextCache[key] = measuredHeight
changed = true
if ((beforeMetrics.offsets[index + 1] || 0) <= this.currentScrollTop) {
correction += measuredHeight - previousHeight
}
})
if (!changed) return
this.messageHeightCache = nextCache
const correctedScrollTop = Math.max(0, this.currentScrollTop + correction)
this.currentScrollTop = correctedScrollTop
this.updateVirtualWindow(correctedScrollTop)
if (this.userNearBottom && this.virtualEnd >= this.messageCount) {
this.$nextTick(() => this.scrollToBottom(false))
} else if (correction) {
this.$nextTick(() => this.setScrollTop(correctedScrollTop))
}
}).exec()
},
setScrollTop(scrollTop) {
const value = Math.max(0, Number(scrollTop || 0))
this.currentScrollTop = value
this.scrollCommandNonce = !this.scrollCommandNonce
this.scrollCommandTop = value + (this.scrollCommandNonce ? 0.01 : 0)
},
clearVirtualListTimers() {
if (this.virtualMeasureTimer) clearTimeout(this.virtualMeasureTimer)
this.virtualMeasureTimer = null
},
shouldShowTime(message, index) {
if (index === 0) return true
return this.messageTimestamp(message) - this.messageTimestamp(this.displayMessages[index - 1]) > 5 * 60 * 1000
},
messageTimestamp(message) {
const raw = Number(message.ctdate || message.createdAt || 0)
return raw && raw < 100000000000 ? raw * 1000 : raw
},
messageKey(message) {
return message.id || message.clientMessageId
},
messageDomId(message) {
return `message-${this.messageKey(message)}`
},
pickerItemKey(item) {
return this.pickerType === 'product' ? item.goodsId || item.id : item.id || item.orderNo
},
pickerItemImage(item) {
return item.goodsMainGraph || item.mainGraph || item.image || '/uni_modules/hashmall-customer-service/static/image-placeholder.png'
},
pickerItemTitle(item) {
return item.goodsName || item.name || (this.pickerType === 'product' ? '商品信息' : '订单信息')
},
pickerItemMeta(item) {
if (this.pickerType === 'order') return item.orderNo || `订单 #${item.id}`
const price = item.currentPrice || item.price
return price ? `¥${price}` : '查看商品'
},
showError(error) {
uni.showToast({ title: (error && error.message) || '操作失败,请稍后重试', icon: 'none', duration: 2500 })
},
goToLogin() {
redirectToLogin()
},
async resume({ forceReconnect = false, pageVisible = false } = {}) {
if (pageVisible) this.pageHidden = false
if (this.pageHidden) return
if (this.previewMode) return
// The socket is intentionally closed while the page is hidden. Reconcile any AI
// messages created during that gap before reconnecting realtime delivery.
if (this.member) await this.loadLatestMessages({ silent: true })
if (!this.member) return
if (!this.socket) {
this.connectSocket()
return
}
this.socket.reconnectNow(forceReconnect)
},
suspend({ pageHidden = false } = {}) {
if (pageHidden) this.pageHidden = true
this.stopRealtime()
},
async showHistory() {
if (this.hasEarlier) {
await this.loadEarlierMessages()
return
}
if (this.messages.length) {
this.updateVirtualWindow(0)
this.$nextTick(() => {
this.scrollTarget = this.messageDomId(this.messages[0])
this.setScrollTop(0)
})
return
}
uni.showToast({ title: '暂无聊天记录', icon: 'none' })
}
}
}
</script>
<style scoped lang="scss">
.chat-page {
position: relative;
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
overflow: hidden;
background: #f4f5fa;
}
.connection-banner {
display: flex;
min-height: 56rpx;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 8rpx 24rpx;
background: #fff4e8;
color: #7c4300;
font-size: 23rpx;
}
.connection-banner--connecting {
background: #f2ebff;
color: #5320a8;
}
.service-route-banner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64rpx;
padding: 10rpx 28rpx;
background: #f4efff;
color: #6f2dde;
box-sizing: border-box;
}
.service-route-banner--human_pending {
background: #fff7e6;
color: #a85b00;
}
.service-route-banner--human_active {
background: #ecfdf3;
color: #087443;
}
.service-route-banner__copy {
display: flex;
align-items: center;
gap: 12rpx;
}
.service-route-banner__title,
.service-route-banner__typing {
font-size: 24rpx;
}
.service-route-banner__typing {
opacity: 0.68;
}
.service-route-banner__action {
margin: 0;
padding: 0 18rpx;
min-height: 46rpx;
border: 1px solid currentColor;
border-radius: 24rpx;
background: transparent;
color: inherit;
font-size: 23rpx;
line-height: 44rpx;
}
.service-route-banner__action::after {
border: 0;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: currentColor;
}
.message-scroll {
min-height: 0;
flex: 1;
}
.message-list {
box-sizing: border-box;
width: 100%;
max-width: 1440rpx;
min-height: 100%;
margin: 0 auto;
padding: 36rpx 0 34rpx;
}
.message-list-bottom {
height: 2rpx;
}
.virtual-list-spacer {
width: 100%;
pointer-events: none;
}
.virtual-message-row {
width: 100%;
contain: layout style;
}
.message-skeleton {
padding-top: 40rpx;
}
.skeleton-row {
display: flex;
padding: 18rpx 30rpx;
}
.skeleton-row--self {
justify-content: flex-end;
}
.skeleton-bubble {
width: 52%;
height: 92rpx;
border-radius: 20rpx;
background: #e8e8ed;
opacity: 0.75;
}
.history-button {
display: block;
min-height: 64rpx;
margin: 0 auto 20rpx;
padding: 0 24rpx;
border-radius: 32rpx;
background: transparent;
color: #5f6068;
font-size: 23rpx;
line-height: 64rpx;
}
.history-end {
display: block;
margin-bottom: 20rpx;
color: #777983;
font-size: 22rpx;
text-align: center;
}
.empty-state {
display: flex;
width: 100%;
max-width: 580rpx;
margin: 18vh auto 0;
align-items: center;
flex-direction: column;
padding: 0 30rpx;
text-align: center;
}
.empty-avatar {
width: 112rpx;
height: 112rpx;
}
.empty-title {
margin-top: 24rpx;
color: #1a1a1d;
font-size: 32rpx;
font-weight: 600;
}
.empty-copy {
margin-top: 12rpx;
color: #5f6068;
font-size: 26rpx;
line-height: 1.6;
}
.login-button {
min-width: 260rpx;
min-height: 76rpx;
margin-top: 30rpx;
padding: 0 34rpx;
border-radius: 38rpx;
background: #7934f6;
color: #fff;
font-size: 27rpx;
font-weight: 600;
line-height: 76rpx;
}
.new-message-button {
position: absolute;
right: 28rpx;
bottom: 190rpx;
z-index: 20;
min-height: 62rpx;
padding: 0 24rpx;
border-radius: 31rpx;
background: #7934f6;
color: #fff;
font-size: 23rpx;
line-height: 62rpx;
box-shadow: 0 4rpx 12rpx rgba(72, 33, 139, 0.22);
}
.composer-shell {
flex: 0 0 auto;
border-top: 0;
background: #f8f9fc;
}
.composer-shell--panel {
padding-bottom: 246rpx;
}
.composer-quote {
display: flex;
width: 100%;
box-sizing: border-box;
align-items: center;
gap: 16rpx;
padding: 14rpx 28rpx 4rpx;
}
.composer-quote__content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4rpx;
padding: 12rpx 16rpx;
border-radius: 12rpx;
background: #eeeff4;
}
.composer-quote__title {
color: #5e3b9b;
font-size: 21rpx;
font-weight: 600;
}
.composer-quote__preview {
overflow: hidden;
color: #686970;
font-size: 22rpx;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-quote__close {
display: flex;
width: 56rpx;
height: 56rpx;
flex: 0 0 56rpx;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border-radius: 28rpx;
background: transparent;
}
.composer {
display: flex;
width: 100%;
box-sizing: border-box;
max-width: 1440rpx;
min-height: 112rpx;
margin: 0 auto;
align-items: center;
gap: 14rpx;
padding: 16rpx 24rpx;
}
.voice-recording-mask {
position: fixed;
inset: 0;
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
background: rgba(18, 18, 22, 0.12);
pointer-events: none;
}
.voice-recording-card {
display: flex;
width: 260rpx;
min-height: 244rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14rpx;
padding: 30rpx 24rpx;
border-radius: 24rpx;
background: rgba(35, 36, 42, 0.9);
color: #fff;
}
.voice-recording-mask--cancel .voice-recording-card {
background: rgba(190, 49, 49, 0.94);
}
.voice-recording-icon {
animation: recording-pulse 1s ease-in-out infinite;
}
.voice-recording-time {
font-size: 30rpx;
font-weight: 600;
}
.voice-recording-tip {
font-size: 24rpx;
line-height: 1.4;
}
@keyframes recording-pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.voice-recording-icon { animation: none; }
}
.composer-icon-button,
.send-button {
min-width: 66rpx;
height: 72rpx;
flex: 0 0 auto;
margin: 0;
padding: 0;
border-radius: 36rpx;
line-height: 72rpx;
}
.composer-icon-button {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.message-input {
box-sizing: border-box;
width: auto;
min-width: 0;
// uni-textarea applies min-height to its content box on H5. Keeping the
// content minimum at one text line prevents vertical padding being counted
// on top of a second 72rpx minimum.
min-height: 40rpx;
max-height: 176rpx;
flex: 1;
padding: 16rpx 24rpx;
border-radius: 18rpx;
background: #fff;
color: #1a1a1d;
font-size: 29rpx;
line-height: 40rpx;
}
.hold-to-talk-button {
box-sizing: border-box;
min-width: 0;
height: 72rpx;
flex: 1;
margin: 0;
padding: 0 24rpx;
border-radius: 18rpx;
background: #fff;
color: #292a31;
font-size: 29rpx;
font-weight: 600;
line-height: 72rpx;
user-select: none;
}
.hold-to-talk-button--active {
background: #e6e7eb;
}
.hold-to-talk-button--cancel {
background: #f7e5e5;
color: #a52e2e;
}
.message-input__placeholder {
color: #9d9ea5;
}
.send-button {
min-width: 104rpx;
padding: 0 20rpx;
background: #7934f6;
color: #fff;
font-size: 27rpx;
font-weight: 600;
}
.send-button[disabled] {
background: #d8d8de;
color: #70717a;
}
.composer-safe-area {
height: env(safe-area-inset-bottom);
background: #fff;
}
.panel-mask {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(18, 18, 22, 0.38);
}
.action-panel,
.picker-panel {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 90;
border-radius: 0;
background: #f8f9fc;
padding-bottom: env(safe-area-inset-bottom);
}
.panel-header {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: space-between;
padding: 0 28rpx;
border-bottom: 1rpx solid #e7e7ec;
}
.panel-title {
color: #1a1a1d;
font-size: 30rpx;
font-weight: 600;
}
.panel-close {
min-width: 88rpx;
min-height: 64rpx;
margin-right: -14rpx;
padding: 0 14rpx;
background: transparent;
color: #5f6068;
font-size: 25rpx;
line-height: 64rpx;
}
.action-grid {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 34rpx 28rpx 42rpx;
}
.action-item {
display: flex;
min-width: 140rpx;
min-height: 164rpx;
align-items: center;
flex-direction: column;
justify-content: center;
gap: 12rpx;
margin: 0;
padding: 0;
background: transparent;
color: #33343a;
font-size: 24rpx;
}
.action-icon {
display: flex;
width: 100rpx;
height: 100rpx;
align-items: center;
justify-content: center;
border-radius: 22rpx;
background: #fff;
}
.picker-panel {
max-height: 72vh;
}
.picker-list {
height: 58vh;
}
.picker-status {
padding: 120rpx 30rpx;
color: #5f6068;
font-size: 26rpx;
text-align: center;
}
.picker-item {
display: flex;
width: 100%;
min-height: 138rpx;
align-items: center;
gap: 18rpx;
margin: 0;
padding: 18rpx 28rpx;
border-bottom: 1rpx solid #ededf1;
border-radius: 0;
background: #fff;
text-align: left;
}
.picker-image {
width: 100rpx;
height: 100rpx;
flex: 0 0 100rpx;
border-radius: 14rpx;
background: #f0f0f3;
}
.picker-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.picker-title,
.picker-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-title {
color: #1a1a1d;
font-size: 27rpx;
font-weight: 600;
}
.picker-meta {
color: #5f6068;
font-size: 23rpx;
}
.picker-action {
color: #7934f6;
font-size: 24rpx;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.new-message-button {
transition: none;
}
}
.composer-icon-button::after,
.send-button::after,
.hold-to-talk-button::after,
.composer-quote__close::after,
.action-item::after,
.history-button::after,
.login-button::after,
.new-message-button::after {
border: 0;
}
</style>