2345 lines
76 KiB
Vue
2345 lines
76 KiB
Vue
<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>
|