923 lines
30 KiB
Vue
923 lines
30 KiB
Vue
<template>
|
||
<view class="chat-container">
|
||
<!-- 聊天头部 -->
|
||
<view :style="{ paddingTop: top + 'px', height: localHeight + 'px' }" class="chat-header">
|
||
<view class="back-btn" @tap="goBack">
|
||
<uni-icons color="#333" size="28" type="left"></uni-icons>
|
||
</view>
|
||
<view class="chat-title">{{ chatTarget.title || '客服' }}</view>
|
||
<view class="change-service-btn" @tap="goToChangeService">
|
||
<uni-icons color="#333" size="22" type="switch"></uni-icons>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 连接状态提示 -->
|
||
<view v-if="connectingStatus" class="connecting-status">{{ connectingStatus }}</view>
|
||
|
||
<!-- 聊天消息区域 -->
|
||
<scroll-view class="chat-messages" scroll-y="true"
|
||
upper-threshold="50" @scrolltoupper="loadMoreHistory" @scroll="onScroll">
|
||
<!-- 加载历史消息提示 -->
|
||
<view v-if="isLoadingHistory" class="message-time">加载历史消息...</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<block v-for="(message, index) in messages" :key="index">
|
||
<!-- 时间分割线 -->
|
||
<view v-if="needShowTime(index)" class="message-time">{{ message.times }}</view>
|
||
|
||
<!-- 消息项 -->
|
||
<view :id="'msg-' + index" :class="{
|
||
'self': message.isSelf,
|
||
'other': !message.isSelf,
|
||
'loading': message.isLoading
|
||
}" class="message-item">
|
||
<image :src="message.isSelf ? userAvatar : getAvatarUrl(message)" class="message-avatar" mode="aspectFill">
|
||
</image>
|
||
<!-- 文字 -->
|
||
<view v-if="!message.type || message.type === 1" class="message-content">
|
||
{{ message.content }}
|
||
</view>
|
||
<!-- 图片 -->
|
||
<view v-else-if="message.type === 2" class="message-media">
|
||
<image v-if="message.mediaUrl" :src="message.mediaUrl" class="chat-img" mode="aspectFit"
|
||
@tap="previewImage(message.mediaUrl)" />
|
||
<view v-else class="media-loading">图片加载中…</view>
|
||
</view>
|
||
<!-- 视频 -->
|
||
<view v-else-if="message.type === 3" class="message-media">
|
||
<video v-if="message.mediaUrl" :src="message.mediaUrl" class="chat-video" controls></video>
|
||
<view v-else class="media-loading">视频加载中…</view>
|
||
</view>
|
||
<!-- 商品/购物车卡片 -->
|
||
<view v-else-if="message.type === 4" class="message-card" @tap="openCardLink(message.card)">
|
||
<image v-if="message.card.pic && !message.card._picErr" :src="message.card.pic" class="card-pic"
|
||
mode="aspectFill" @error="onCardPicError(message)" />
|
||
<view v-else class="card-pic card-pic--ph">商品</view>
|
||
<view class="card-info">
|
||
<view class="card-name">{{ message.card.name }}</view>
|
||
<view class="card-price">¥{{ message.card.price }}</view>
|
||
<view class="card-tag">商品</view>
|
||
</view>
|
||
</view>
|
||
<!-- 订单卡片 -->
|
||
<view v-else-if="message.type === 5" class="message-card" @tap="openCardLink(message.card)">
|
||
<image v-if="message.card.pic && !message.card._picErr" :src="message.card.pic" class="card-pic"
|
||
mode="aspectFill" @error="onCardPicError(message)" />
|
||
<view v-else class="card-pic card-pic--ph">订单</view>
|
||
<view class="card-info">
|
||
<view class="card-name">订单 {{ message.card.order_no }}</view>
|
||
<view class="card-price">¥{{ message.card.amount }}</view>
|
||
<view class="card-tag">订单 · 共{{ message.card.count }}件</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="message-content">{{ message.content }}</view>
|
||
</view>
|
||
</block>
|
||
</scroll-view>
|
||
|
||
<!-- 输入区域 -->
|
||
<view class="chat-input-area">
|
||
<view class="input-container">
|
||
<textarea v-model="inputMessage" :adjust-position="true" auto-height class="message-input" cursor-spacing="10"
|
||
enable-keyboard-accessory-view="true" hold-keyboard="true" maxlength="500" placeholder="请输入消息..."
|
||
@blur="onInputBlur" @confirm="sendMessage" @focus="onInputFocus" @input="handleInput"></textarea>
|
||
<view class="plus-btn" @tap="togglePanel">
|
||
<uni-icons color="#666" size="30" type="plusempty"></uni-icons>
|
||
</view>
|
||
<button :disabled="inputMessage.trim() === ''" class="send-btn" @tap="sendMessage">
|
||
发送
|
||
</button>
|
||
</view>
|
||
<!-- 更多功能面板 -->
|
||
<view v-if="showPanel" class="more-panel">
|
||
<view class="panel-item" @tap="chooseMedia('image')">
|
||
<view class="panel-icon">🖼️</view>
|
||
<text>相册图片</text>
|
||
</view>
|
||
<view class="panel-item" @tap="chooseMedia('camera')">
|
||
<view class="panel-icon">📷</view>
|
||
<text>拍摄</text>
|
||
</view>
|
||
<view class="panel-item" @tap="chooseMedia('video')">
|
||
<view class="panel-icon">🎬</view>
|
||
<text>视频</text>
|
||
</view>
|
||
<view class="panel-item" @tap="openGoodsPicker">
|
||
<view class="panel-icon">🛒</view>
|
||
<text>商品/购物车</text>
|
||
</view>
|
||
<view class="panel-item" @tap="openOrderPicker">
|
||
<view class="panel-icon">📦</view>
|
||
<text>订单</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { menuButtonInfo, picUrl, request, NavgateTo } from '@/utils'
|
||
import mqttTool from '@/utils/mqtt'
|
||
import { apiArr } from '../../../api/customerService'
|
||
import { uploadOSS, signPrivateView } from '@/utils/uploadOSS'
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
localHeight: '',
|
||
top: '',
|
||
// 聊天目标信息
|
||
chatTarget: {
|
||
mchId: '',
|
||
bindId: 0,
|
||
title: '',
|
||
avatar: '',
|
||
openId: '' // 接收方的open_id
|
||
},
|
||
// 用户头像
|
||
userAvatar: '',
|
||
// 消息列表
|
||
messages: [],
|
||
|
||
// 输入的消息
|
||
inputMessage: '',
|
||
// 更多功能面板
|
||
showPanel: false,
|
||
// 是否可以发送消息
|
||
canSend: false,
|
||
// 连接状态
|
||
isConnected: false,
|
||
// 连接状态文本
|
||
connectingStatus: '',
|
||
// 滚动到指定视图
|
||
scrollToView: '',
|
||
// 是否加载历史消息
|
||
isLoadingHistory: false,
|
||
// 心跳包定时器
|
||
keepaliveTimer: null,
|
||
selfClientId: uni.getStorageSync('openId'),
|
||
// MQTT工具实例
|
||
client: null,
|
||
// 重连失败提示定时器
|
||
reconnectFailedTimer: null,
|
||
// 分页参数
|
||
pageNum: 1,
|
||
pageSize: 10,
|
||
// 是否还有更多历史消息
|
||
hasMoreHistory: true,
|
||
// 滚动到底部的标记
|
||
scrollToBottomFlag: false
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
console.log('客服聊天页面onLoad触发')
|
||
const meun = menuButtonInfo()
|
||
this.top = meun.top
|
||
this.localHeight = meun.height
|
||
console.log('导航栏信息:top:', this.top, 'height:', this.localHeight)
|
||
// 获取聊天对象信息
|
||
if (options.item) {
|
||
const item = JSON.parse(options.item)
|
||
console.log('参数接收:', item)
|
||
if (Number(item.type) === 1) {
|
||
// 客户选择客服跳转进来
|
||
this.chatTarget = item
|
||
console.log('客户找客服跳转:', this.chatTarget)
|
||
this.chatTarget.title = this.chatTarget.employee_name
|
||
this.getMqttConfig().then(() => {
|
||
// 获取配置后再初始化聊天
|
||
console.log('获取MQTT配置成功,开始初始化聊天')
|
||
this.initChat()
|
||
}).catch(error => {
|
||
console.error('获取MQTT配置失败:', error)
|
||
})
|
||
} else {
|
||
// 客服选择聊天列表进来
|
||
this.chatTarget = item
|
||
console.log('客服找客户进来:', this.chatTarget)
|
||
this.chatTarget.title = this.chatTarget.server_name
|
||
this.chatTarget.bindId = this.chatTarget.id
|
||
if (this.chatTarget.client_id_one === this.selfClientId) {
|
||
this.chatTarget.openId = this.chatTarget.client_id_two
|
||
} else {
|
||
this.chatTarget.openId = this.chatTarget.client_id_one
|
||
}
|
||
// 初始化MQTT连接
|
||
console.log('开始初始化聊天')
|
||
this.initChat()
|
||
}
|
||
} else {
|
||
console.log('没有接收到参数item')
|
||
}
|
||
// 初始化用户头像
|
||
this.userAvatar = picUrl + uni.getStorageSync('headPhoto')
|
||
console.log('用户头像:', this.userAvatar)
|
||
},
|
||
onShow() {
|
||
// 监听 picker 页回传的商品/订单卡片(只注册一次)
|
||
if (!this._onPickCard) {
|
||
this._onPickCard = ({ type, card }) => {
|
||
this.publishMsg(type, JSON.stringify(card))
|
||
}
|
||
uni.$on('chat:pickCard', this._onPickCard)
|
||
}
|
||
},
|
||
methods: {
|
||
getAvatarUrl(record) {
|
||
return this.chatTarget.employee_image ? this.chatTarget.employee_image : 'https://static.hshuishang.com/defaultTx.png'
|
||
},
|
||
async connect() {
|
||
this.client = null
|
||
const options = {
|
||
clientId: this.selfClientId
|
||
}
|
||
console.log('clientId:', options.clientId)
|
||
|
||
// 添加连接状态回调
|
||
const callbacks = {
|
||
onConnect: () => {
|
||
console.log('客服连接成功')
|
||
this.isConnected = true
|
||
this.connectingStatus = ''
|
||
},
|
||
onDisconnect: this.onDisconnect.bind(this),
|
||
onError: (error) => {
|
||
console.error('客服连接错误:', error)
|
||
this.isConnected = false
|
||
this.connectingStatus = '连接错误,请重试'
|
||
},
|
||
onReconnect: () => {
|
||
console.log('客服正在重连...')
|
||
this.isConnected = false
|
||
this.connectingStatus = '连接已断开,正在重连...'
|
||
}
|
||
}
|
||
|
||
this.client = mqttTool.connect(options, callbacks)
|
||
this.isConnected = !!this.client
|
||
|
||
await this.subscribe()
|
||
this.client.on('message', (topic, message) => {
|
||
const msgStr = Buffer.from(message).toString('utf8') // 二进制转UTF-8字符串
|
||
const msg = JSON.parse(msgStr) // 后续解析逻辑不变
|
||
|
||
let jsMsg = msg // 直接使用已解析的对象,无需再次解析
|
||
console.log('收到消息', topic, msg)
|
||
if (jsMsg.send_client === this.selfClientId || jsMsg.receive_client === this.selfClientId) {
|
||
if (jsMsg.send_client === this.chatTarget.openId || jsMsg.receive_client === this.chatTarget.openId) {
|
||
this.appendMessage(jsMsg, jsMsg.send_client === this.selfClientId)
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
// 把一条原始消息(含 type/content)转成可渲染的消息对象并追加;图片/视频异步签名 URL
|
||
async appendMessage(raw, isSelf) {
|
||
const type = Number(raw.type) || 1
|
||
const item = {
|
||
type,
|
||
content: raw.content,
|
||
time: Date.now(),
|
||
isSelf,
|
||
isLoading: false,
|
||
mediaUrl: '',
|
||
card: null
|
||
}
|
||
if (type === 4 || type === 5) {
|
||
try { item.card = typeof raw.content === 'string' ? JSON.parse(raw.content) : raw.content } catch (e) { item.card = {} }
|
||
}
|
||
const idx = this.messages.push(item) - 1
|
||
this.scrollToView = 'msg-' + idx
|
||
// 图片/视频:content 是私密 object_key,需签名后显示
|
||
if (type === 2 || type === 3) {
|
||
try {
|
||
const r = await signPrivateView(raw.content)
|
||
this.$set(this.messages[idx], 'mediaUrl', r.url)
|
||
} catch (e) {
|
||
console.error('签发聊天媒体URL失败', e)
|
||
}
|
||
}
|
||
},
|
||
async subscribe() {
|
||
if (this.isConnected && this.client) {
|
||
const topic = 'contact/message/send_msg/' + this.chatTarget.bindId // 按会话精确订阅,天然隔离(沿用 contact/message 授权前缀)
|
||
this.client.subscribe(topic, { qos: 0 }, (err) => {
|
||
if (!err) {
|
||
console.log('订阅成功', topic, { qos: 0 })
|
||
this.connectingStatus = ''
|
||
} else {
|
||
console.log('订阅失败:', err)
|
||
this.connectingStatus = '订阅失败,请重试'
|
||
}
|
||
})
|
||
} else {
|
||
console.log('连接失败', this.isConnected, this.client)
|
||
this.connectingStatus = '连接失败,请重试'
|
||
}
|
||
},
|
||
// 初始化聊天
|
||
async initChat() {
|
||
try {
|
||
// 显示连接状态
|
||
this.connectingStatus = '正在连接客服...'
|
||
await this.connect()
|
||
// 连接成功后启动心跳包
|
||
this.startKeepalive()
|
||
// 连接成功后立即加载历史消息
|
||
console.log('连接成功,开始加载历史消息')
|
||
await this.loadHistoryMessages()
|
||
console.log('历史消息加载完成,消息数量:', this.messages.length)
|
||
|
||
|
||
} catch (error) {
|
||
console.error('初始化聊天失败', error)
|
||
this.connectingStatus = '连接失败,请检查网络'
|
||
|
||
// 失败后尝试重新连接
|
||
this.reconnectFailedTimer = setTimeout(() => {
|
||
this.initChat()
|
||
}, 3000)
|
||
}
|
||
},
|
||
|
||
// 获取MQTT连接配置
|
||
async getMqttConfig() {
|
||
console.log('🚀 ~ onLoad ~ this.chatTarget.open_id:', this.chatTarget.open_id)
|
||
try {
|
||
// 如果没有已创建的实例或clientId,则通过API获取
|
||
return new Promise((resolve, reject) => {
|
||
const params = {
|
||
worker_id: this.chatTarget.id || '',
|
||
open_id: this.selfClientId || ''
|
||
}
|
||
request(apiArr.csGetToClientId, 'POST', params).then((res) => {
|
||
console.log('聊天列表:', res)
|
||
// 检查响应数据格式是否正确
|
||
if (res && res.client_bind && res.client_bind.client_id_one && res.client_bind.client_id_two) {
|
||
if (res.client_bind.client_id_one === this.selfClientId) {
|
||
this.chatTarget.openId = res.client_bind.client_id_two
|
||
} else {
|
||
this.chatTarget.openId = res.client_bind.client_id_one
|
||
}
|
||
this.chatTarget.bindId = res.client_bind.id
|
||
resolve()
|
||
} else {
|
||
console.error('MQTT配置响应格式不正确:', res)
|
||
reject(new Error('未获取到有效的MQTT配置'))
|
||
}
|
||
}).catch(error => {
|
||
console.error('获取MQTT配置失败', error)
|
||
reject(error)
|
||
})
|
||
})
|
||
} catch (error) {
|
||
console.error('获取MQTT配置失败', error)
|
||
throw error
|
||
}
|
||
},
|
||
|
||
// MQTT断开连接回调
|
||
onDisconnect(packet) {
|
||
console.log('MQTT连接断开', packet)
|
||
this.isConnected = false
|
||
this.client = null
|
||
|
||
// 根据断开原因设置不同的连接状态文本
|
||
if (packet && packet.error) {
|
||
// 连接失败的情况
|
||
this.connectingStatus = '连接失败,请检查网络'
|
||
} else {
|
||
// 其他断开连接的情况
|
||
this.connectingStatus = '连接已断开,正在重连...'
|
||
}
|
||
|
||
// 停止心跳包
|
||
this.stopKeepalive()
|
||
},
|
||
|
||
// 加载历史消息
|
||
async loadHistoryMessages() {
|
||
console.log('loadHistoryMessages方法调用')
|
||
console.log('加载条件检查:hasMoreHistory:', this.hasMoreHistory, 'isLoadingHistory:', this.isLoadingHistory)
|
||
|
||
if (!this.hasMoreHistory || this.isLoadingHistory) {
|
||
console.log('不满足加载条件:hasMoreHistory:', this.hasMoreHistory, 'isLoadingHistory:', this.isLoadingHistory)
|
||
return
|
||
}
|
||
|
||
try {
|
||
this.isLoadingHistory = true
|
||
console.log('开始加载历史消息,当前页码:', this.pageNum)
|
||
|
||
// 确保已经获取了mqttConfig.bindId
|
||
if (!this.chatTarget.bindId) {
|
||
console.log('没有bindId,开始获取MQTT配置')
|
||
await this.getMqttConfig()
|
||
console.log('获取MQTT配置成功,bindId:', this.chatTarget.bindId)
|
||
}
|
||
|
||
const params = {
|
||
bindId: this.chatTarget.bindId,
|
||
order: 'desc', // 按时间降序排列
|
||
page_num: this.pageNum,
|
||
page_size: this.pageSize
|
||
}
|
||
|
||
console.log('请求历史消息参数:', params)
|
||
const res = await request(apiArr.csGetMsgRecord, 'POST', params)
|
||
|
||
console.log('历史消息返回结果:', res)
|
||
if (res && res.msg_record) {
|
||
const historyMessages = res.msg_record
|
||
|
||
console.log('原始历史消息数量:', historyMessages.length)
|
||
// 如果没有更多历史消息了
|
||
if (historyMessages.length === 0) {
|
||
console.log('没有更多历史消息了')
|
||
this.hasMoreHistory = false
|
||
return
|
||
}
|
||
|
||
// 处理历史消息,转换为需要的格式
|
||
const formattedMessages = historyMessages.map(msg => {
|
||
const type = Number(msg.type) || 1
|
||
const item = {
|
||
type,
|
||
content: msg.content,
|
||
time: new Date(msg.create_time).getTime(),
|
||
times: msg.update_time,
|
||
isSelf: msg.send_client === this.selfClientId,
|
||
isLoading: false,
|
||
mediaUrl: '',
|
||
card: null
|
||
}
|
||
if (type === 4 || type === 5) {
|
||
try { item.card = typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content } catch (e) { item.card = {} }
|
||
}
|
||
return item
|
||
}).reverse(); // 反转消息顺序,确保最早的消息在最前面
|
||
|
||
// 图片/视频历史消息:异步签发私密 URL
|
||
formattedMessages.forEach(item => {
|
||
if (item.type === 2 || item.type === 3) {
|
||
signPrivateView(item.content).then(r => {
|
||
this.$set(item, 'mediaUrl', r.url)
|
||
}).catch(e => console.error('历史媒体签名失败', e))
|
||
}
|
||
})
|
||
|
||
console.log('格式化后的历史消息:', formattedMessages)
|
||
// 将格式化后的历史消息添加到消息列表开头
|
||
const previousMessageCount = this.messages.length;
|
||
console.log('添加前消息数量:', previousMessageCount)
|
||
this.messages = [...formattedMessages, ...this.messages];
|
||
console.log('添加后消息数量:', this.messages.length)
|
||
|
||
// 增加页码
|
||
this.pageNum++;
|
||
console.log('下一页页码:', this.pageNum)
|
||
|
||
// 如果是首次加载,滚动到底部显示最新消息
|
||
if (previousMessageCount === 0) {
|
||
setTimeout(() => {
|
||
console.log('首次加载,滚动到底部,消息数量:', this.messages.length)
|
||
// 使用更可靠的滚动方式
|
||
uni.pageScrollTo({
|
||
scrollTop: 999999,
|
||
duration: 300
|
||
});
|
||
}, 100);
|
||
} else {
|
||
// 不是首次加载时,保持当前滚动位置,不自动滚动到底部
|
||
// 确保新加载的历史消息在顶部可见
|
||
console.log('非首次加载,新增消息数量:', formattedMessages.length)
|
||
}
|
||
} else {
|
||
console.log('接口返回数据格式不正确或无消息记录')
|
||
// 仅当不是第一页时才设置hasMoreHistory为false
|
||
if (this.pageNum > 1) {
|
||
this.hasMoreHistory = false
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载历史消息失败', error)
|
||
} finally {
|
||
this.isLoadingHistory = false
|
||
console.log('加载历史消息结束')
|
||
}
|
||
},
|
||
|
||
// 滚动事件监听
|
||
onScroll(e) {
|
||
console.log('滚动事件触发,scrollTop:', e.detail.scrollTop)
|
||
},
|
||
|
||
// 加载更多历史消息
|
||
loadMoreHistory() {
|
||
console.log('滚动到顶部事件触发')
|
||
// 滚动到顶部时加载更多历史消息
|
||
console.log('当前条件:isLoadingHistory:', this.isLoadingHistory, 'hasMoreHistory:', this.hasMoreHistory, 'pageNum:', this.pageNum)
|
||
|
||
// 重置hasMoreHistory为true,确保可以继续加载
|
||
if (!this.hasMoreHistory && this.pageNum === 1) {
|
||
console.log('重置hasMoreHistory为true')
|
||
this.hasMoreHistory = true
|
||
}
|
||
|
||
if (!this.isLoadingHistory && this.hasMoreHistory) {
|
||
console.log('开始加载更多历史消息')
|
||
this.loadHistoryMessages()
|
||
} else {
|
||
console.log('不满足加载更多条件:isLoadingHistory:', this.isLoadingHistory, 'hasMoreHistory:', this.hasMoreHistory)
|
||
}
|
||
},
|
||
|
||
// 发送消息
|
||
sendMessage() {
|
||
const content = this.inputMessage.trim()
|
||
console.log('发送消息', content)
|
||
if (!content || !this.client || !this.isConnected) return
|
||
// 滚动到底部
|
||
this.scrollToBottom()
|
||
console.log('需要发送的对象', this.chatTarget)
|
||
// 按照用户提供的格式构建发送消息
|
||
const msgData = {
|
||
bind_id: this.chatTarget.bindId, // 聊天列表的ID
|
||
send_client: this.selfClientId, // 消息发送方open_id
|
||
receive_client: this.chatTarget.openId, // 消息接收方open_id
|
||
type: 1, // 消息类型,1表示文字消息
|
||
content: content, // 消息内容
|
||
receive_read_status: 2 // 接收方阅读状态
|
||
}
|
||
console.log('发送消息', msgData)
|
||
this.client.publish(
|
||
'contact/message/send_msg/' + this.chatTarget.bindId, // 按会话精确主题,双方订阅同一主题
|
||
JSON.stringify(msgData),
|
||
{ Qos: 0 },
|
||
(err) => {
|
||
if (err) {
|
||
console.error('发送消息失败', err)
|
||
// 可以在这里添加消息发送失败的处理逻辑
|
||
} else {
|
||
console.log('发送消息成功')
|
||
}
|
||
}
|
||
)
|
||
|
||
// 清空输入框
|
||
this.inputMessage = ''
|
||
},
|
||
|
||
// 切换"+"功能面板
|
||
togglePanel() {
|
||
this.showPanel = !this.showPanel
|
||
},
|
||
|
||
// 统一发布消息(type: 1文字 2图片 3视频 4商品 5订单;content 为文本/object_key/JSON字符串)
|
||
publishMsg(type, content) {
|
||
if (!this.client || !this.isConnected) {
|
||
uni.showToast({ title: '连接断开,请重试', icon: 'none' })
|
||
return
|
||
}
|
||
const msgData = {
|
||
bind_id: this.chatTarget.bindId,
|
||
send_client: this.selfClientId,
|
||
receive_client: this.chatTarget.openId,
|
||
type,
|
||
content,
|
||
receive_read_status: 2
|
||
}
|
||
this.client.publish('contact/message/send_msg/' + this.chatTarget.bindId, JSON.stringify(msgData), { Qos: 0 }, (err) => {
|
||
if (err) console.error('发送消息失败', err)
|
||
})
|
||
},
|
||
|
||
// 选择图片/拍摄/视频并上传(私密,按会话授权)
|
||
chooseMedia(source) {
|
||
this.showPanel = false
|
||
if (source === 'video') {
|
||
uni.chooseVideo({
|
||
sourceType: ['album', 'camera'],
|
||
maxDuration: 60,
|
||
success: (res) => this.uploadAndSend(res.tempFilePath, 'chat_video', 3)
|
||
})
|
||
} else {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
sourceType: source === 'camera' ? ['camera'] : ['album'],
|
||
success: (res) => {
|
||
const fp = res.tempFilePaths && res.tempFilePaths[0]
|
||
if (fp) this.uploadAndSend(fp, 'chat_image', 2)
|
||
}
|
||
})
|
||
}
|
||
},
|
||
|
||
// 上传到私密 bucket 后,把 object_key 作为消息内容发布
|
||
async uploadAndSend(filePath, scene, type) {
|
||
try {
|
||
const { objectKey } = await uploadOSS({ filePath, scene, bindId: this.chatTarget.bindId })
|
||
this.publishMsg(type, objectKey)
|
||
} catch (e) {
|
||
console.error('上传失败', e)
|
||
uni.showToast({ title: '上传失败,请重试', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
// 预览图片
|
||
previewImage(url) {
|
||
if (url) uni.previewImage({ urls: [url], current: url })
|
||
},
|
||
|
||
// 点击商品/订单卡片跳转
|
||
openCardLink(card) {
|
||
if (!card) return
|
||
// 兼容老消息:仍带 path 的直接跳
|
||
if (!card.page && card.path) {
|
||
NavgateTo(card.path, { isLogin: false })
|
||
return
|
||
}
|
||
if (!card.page) return
|
||
// 完整 item 经本地存储中转,避免 URL 过长被截断(详情页优先读 storage)
|
||
const item = card.item || {}
|
||
try {
|
||
uni.setStorageSync('chatCardItem', item)
|
||
} catch (e) {
|
||
console.error('暂存卡片数据失败', e)
|
||
}
|
||
NavgateTo(card.page + '?item=' + encodeURIComponent(JSON.stringify(item)) + '&fromChat=1', { isLogin: false })
|
||
},
|
||
|
||
// 卡片图片加载失败 -> 显示占位(用 $set 保证响应式)
|
||
onCardPicError(message) {
|
||
if (message && message.card) {
|
||
this.$set(message.card, '_picErr', true)
|
||
}
|
||
},
|
||
|
||
// 选择商品/购物车发送
|
||
openGoodsPicker() {
|
||
this.showPanel = false
|
||
// 跳转到选择页(复用购物车/商品列表),选完回传后 publishMsg(4, JSON)
|
||
NavgateTo('/packages/customerService/picker/index?type=goods&bindId=' + this.chatTarget.bindId, { isLogin: false })
|
||
},
|
||
|
||
// 选择订单发送
|
||
openOrderPicker() {
|
||
this.showPanel = false
|
||
NavgateTo('/packages/customerService/picker/index?type=order&bindId=' + this.chatTarget.bindId, { isLogin: false })
|
||
},
|
||
|
||
// 处理输入事件
|
||
handleInput() {
|
||
this.canSend = this.inputMessage.trim().length > 0
|
||
},
|
||
|
||
onInputFocus() {
|
||
// 输入框获取焦点时,设置滚动到底部的标记
|
||
this.scrollToBottomFlag = true
|
||
setTimeout(() => {
|
||
if (this.scrollToBottomFlag) {
|
||
this.scrollToBottom()
|
||
}
|
||
}, 300)
|
||
},
|
||
|
||
onInputBlur() {
|
||
// 输入框失去焦点时,重置滚动标记
|
||
this.scrollToBottomFlag = false
|
||
},
|
||
|
||
// 是否需要显示时间分割线
|
||
needShowTime(index) {
|
||
if (index === 0) return true
|
||
|
||
const currentMsg = this.messages[index]
|
||
const prevMsg = this.messages[index - 1]
|
||
|
||
// 如果两条消息间隔超过5分钟,则显示时间分割线
|
||
return (currentMsg.time - prevMsg.time) > 5 * 60 * 1000
|
||
},
|
||
|
||
// 格式化时间
|
||
formatTime(time) {
|
||
const date = new Date(time)
|
||
const hours = date.getHours().toString().padStart(2, '0')
|
||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||
|
||
return `${hours}:${minutes}`
|
||
},
|
||
|
||
// 滚动到底部
|
||
scrollToBottom() {
|
||
setTimeout(() => {
|
||
console.log('手动滚动到底部')
|
||
uni.pageScrollTo({
|
||
scrollTop: 999999,
|
||
duration: 300
|
||
});
|
||
}, 100)
|
||
},
|
||
|
||
// 返回上一页
|
||
goBack() {
|
||
uni.navigateBack()
|
||
},
|
||
|
||
// 启动心跳包 - 增强版:添加错误处理和状态检查
|
||
startKeepalive() {
|
||
// 停止之前的定时器
|
||
this.stopKeepalive()
|
||
|
||
// 每30秒发送一次心跳包
|
||
this.keepaliveTimer = setInterval(() => {
|
||
if (this.client && this.isConnected) {
|
||
const keepaliveData = {
|
||
client_id: this.selfClientId // 自己的client_id
|
||
}
|
||
|
||
this.client.publish(
|
||
'contact/message/keep_time',
|
||
JSON.stringify(keepaliveData),
|
||
{},
|
||
(err) => {
|
||
if (err) {
|
||
console.error('发送心跳包失败', err)
|
||
// 心跳包发送失败可能表示连接有问题,可以考虑触发重连
|
||
if (!this.isConnected) {
|
||
return
|
||
}
|
||
console.log('心跳包发送失败,尝试检查连接状态')
|
||
// 这里可以添加额外的连接检查逻辑
|
||
}
|
||
}
|
||
)
|
||
} else {
|
||
console.warn('MQTT未连接,停止心跳包')
|
||
this.stopKeepalive()
|
||
}
|
||
}, 30000)
|
||
},
|
||
|
||
// 停止心跳包
|
||
stopKeepalive() {
|
||
if (this.keepaliveTimer) {
|
||
clearInterval(this.keepaliveTimer)
|
||
this.keepaliveTimer = null
|
||
}
|
||
},
|
||
|
||
// 跳转到切换客服页面
|
||
goToChangeService() {
|
||
uni.navigateTo({
|
||
url: '/packages/customerService/changeService/index?currentMchId=' + this.chatTarget.mchId
|
||
})
|
||
}
|
||
},
|
||
|
||
// 页面卸载时停止心跳包
|
||
onUnload() {
|
||
// 断开MQTT连接
|
||
if (this.client) {
|
||
this.client.end()
|
||
}
|
||
|
||
// 停止心跳包
|
||
this.stopKeepalive()
|
||
|
||
// 清除重连失败提示定时器
|
||
if (this.reconnectFailedTimer) {
|
||
clearTimeout(this.reconnectFailedTimer)
|
||
this.reconnectFailedTimer = null
|
||
}
|
||
|
||
// 移除卡片选择监听
|
||
if (this._onPickCard) {
|
||
uni.$off('chat:pickCard', this._onPickCard)
|
||
this._onPickCard = null
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
@import url("./index.css");
|
||
</style>
|
||
<style scoped>
|
||
:root {
|
||
--header-height: 80px; /* 头部高度 */
|
||
--input-height: 80px; /* 输入区域高度 */
|
||
}
|
||
|
||
/* 图片/视频消息 */
|
||
.message-media {
|
||
max-width: 60%;
|
||
}
|
||
.chat-img {
|
||
width: 320rpx;
|
||
height: 320rpx;
|
||
border-radius: 12rpx;
|
||
background: #f0f0f0;
|
||
}
|
||
.chat-video {
|
||
width: 400rpx;
|
||
height: 300rpx;
|
||
border-radius: 12rpx;
|
||
}
|
||
.media-loading {
|
||
padding: 30rpx 40rpx;
|
||
background: #fff;
|
||
border-radius: 12rpx;
|
||
color: #999;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
/* 商品/订单卡片 */
|
||
.message-card {
|
||
display: flex;
|
||
width: 520rpx;
|
||
background: #fff;
|
||
border-radius: 14rpx;
|
||
overflow: hidden;
|
||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
|
||
}
|
||
.card-pic {
|
||
width: 180rpx;
|
||
height: 180rpx;
|
||
flex-shrink: 0;
|
||
background: #f5f5f5;
|
||
}
|
||
.card-pic--ph {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #bbb;
|
||
font-size: 28rpx;
|
||
}
|
||
.card-info {
|
||
flex: 1;
|
||
padding: 18rpx 20rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
min-width: 0;
|
||
}
|
||
.card-name {
|
||
font-size: 28rpx;
|
||
color: #222;
|
||
line-height: 38rpx;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.card-price {
|
||
font-size: 32rpx;
|
||
color: #FF370B;
|
||
font-weight: 600;
|
||
}
|
||
.card-tag {
|
||
align-self: flex-start;
|
||
font-size: 20rpx;
|
||
color: #999;
|
||
border: 1rpx solid #eee;
|
||
border-radius: 6rpx;
|
||
padding: 0 10rpx;
|
||
}
|
||
|
||
/* "+" 按钮与更多面板 */
|
||
.plus-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
}
|
||
.more-panel {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
padding: 24rpx 12rpx;
|
||
background: #f7f7f7;
|
||
}
|
||
.panel-item {
|
||
width: 20%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
font-size: 22rpx;
|
||
color: #666;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.panel-icon {
|
||
width: 96rpx;
|
||
height: 96rpx;
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 44rpx;
|
||
}
|
||
</style> |