refactor: 重构消息处理和用户交互逻辑 fix(env): 更新环境变量配置 style: 调整用户卡片样式和布局 perf: 优化消息发送和接收性能 test: 添加消息排序和去重逻辑测试 chore: 清理无用代码和文件
1008 lines
35 KiB
Vue
1008 lines
35 KiB
Vue
<template>
|
||
<div class="chat-page">
|
||
<div class="left-panel">
|
||
<WxUserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
|
||
</div>
|
||
<div class="right-panel">
|
||
<div :class="['chat-body', { 'chat-body--loading': isLoadingMessages }]" ref="chatBody" @scroll="handleScroll">
|
||
<!-- 加载更多提示 -->
|
||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||
|
||
<WxChatRecord :msgList="messages" :sendeInfo="sendeInfo" msgType="user" @retry="handleRetry" />
|
||
</div>
|
||
|
||
<div class="chat-footer">
|
||
<div class="footer-left" style="">
|
||
<div class="upload-row" style="display:flex; align-items:center; gap:12px;margin-left: 6px;position: relative; top: 10px;">
|
||
<el-icon style="font-size:24px;cursor:pointer" @click="triggerImageInput">
|
||
<svg class="icon" aria-hidden="true">
|
||
<use xlink:href="#icon-tupian1"></use>
|
||
</svg>
|
||
</el-icon>
|
||
<input ref="imageInput" type="file" accept="image/*" style="display:none"
|
||
@change="handleImageChange" />
|
||
<V3Emoji @click-emoji="onEmojiSelect" :recent="true"></V3Emoji>
|
||
</div>
|
||
<el-input type="textarea" v-model="draft" placeholder="输入消息..." :rows="2" style="flex:1"
|
||
@keydown="handleInputKeydown" />
|
||
</div>
|
||
<!-- 发送按钮 -->
|
||
<div class="send-button-container" style="margin-left: 12px;">
|
||
<el-button
|
||
type="primary"
|
||
@click="send"
|
||
:disabled="!draft.trim() || isSending || isUploading"
|
||
:loading="isSending"
|
||
style="height: 60px;"
|
||
>
|
||
{{ isSending ? '发送中...' : '发送' }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||
import WxChatRecord from '@/components/ChatRecord/index.vue'
|
||
import WxUserCard from '@/components/UserCard/index.vue'
|
||
import UserMessage from '@/components/UserMessage/index.vue'
|
||
import mihoutai from '@/assets/images/mihoutai.png'
|
||
import V3Emoji from "vue3-emoji";
|
||
import { getUserList, send_message, get_messages } from '@/api/chat'
|
||
import { useRoute } from 'vue-router'
|
||
import { uploadFile } from '@/api/upload'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const route = useRoute()
|
||
|
||
// 好友列表数据
|
||
const userList = ref([
|
||
{ user_id: 'u1', user_name: '张三', user_avatar: '' },
|
||
{ user_id: 'u2', user_name: '李四', user_avatar: '' },
|
||
{ user_id: 'u3', user_name: '王五', user_avatar: '' },
|
||
])
|
||
|
||
const activeUser = ref({})
|
||
const messages = ref([])
|
||
const draft = ref('')
|
||
const imageInput = ref(null)
|
||
const chatBody = ref(null)
|
||
const isLoadingMessages = ref(false)
|
||
|
||
// 发送者信息
|
||
const sendeInfo = reactive({
|
||
userInfo: { user_id: '', user_name: '', user_avatar: '' },
|
||
robotInfo: { wx_id: '', head_url: '' }
|
||
})
|
||
|
||
// 消息分页查询参数
|
||
const msgQuery = reactive({
|
||
page: 1,
|
||
page_size: 100,
|
||
app_id: route.query.app_id,
|
||
user_id: ''
|
||
})
|
||
|
||
// 状态变量
|
||
const loadingMore = ref(false)
|
||
const noMoreMessages = ref(false)
|
||
const isAutoScroll = ref(true) // 是否自动滚动到底部
|
||
const messageTimer = ref(null) // 定时器实例
|
||
const isSending = ref(false) // 防止重复发送
|
||
const lastSendTime = ref(0) // 最后发送时间,用于防抖
|
||
const isUploading = ref(false) // 防止重复上传
|
||
|
||
// 选择用户
|
||
const onSelectUser = async (id) => {
|
||
const u = userList.value.find((it) => it.sender_id == id)
|
||
if (u) {
|
||
userList.value.forEach((it) => (it.active = it.sender_id === id))
|
||
activeUser.value = u
|
||
sendeInfo.userInfo = { user_id: u.sender_id, user_name: u.sender_name, user_avatar: u.sender_avatar }
|
||
sendeInfo.robotInfo = { wx_id: 'robot', head_url: mihoutai }
|
||
|
||
// 重置消息列表和分页
|
||
messages.value = []
|
||
msgQuery.page = 1
|
||
msgQuery.user_id = u.sender_id
|
||
noMoreMessages.value = false
|
||
|
||
// 为避免切换用户时看到明显的滚动过程:
|
||
// - 在开始加载消息前将 chat 区域设置为不可见(但依然可滚动),
|
||
// - 加载完成后恢复显示并滚到底部(保持原有的交互顺序)。
|
||
isLoadingMessages.value = true
|
||
|
||
// 保持原有行为:先滚动到底部再加载(不 await),以免改变用户可感知的交互顺序
|
||
scrollToBottom()
|
||
// 启动加载(不 await,使界面更流畅)
|
||
getMessages().finally(() => {
|
||
// 在消息加载结束后恢复可见性并尝试滚到底部
|
||
isLoadingMessages.value = false
|
||
// 确保滚到底部以避免残留偏移
|
||
scrollToBottom()
|
||
})
|
||
|
||
// 重启定时器
|
||
restartMessageTimer()
|
||
}
|
||
}
|
||
|
||
|
||
let msgTotal = 0
|
||
// 消息排序函数
|
||
const sortMessagesByTime = (messages) => {
|
||
const sorted = messages.sort((a, b) => {
|
||
// 尝试解析时间戳
|
||
const getTimestamp = (msg) => {
|
||
// 优先使用真实的时间戳字段
|
||
if (msg.timestamp) {
|
||
const ts = new Date(msg.timestamp).getTime()
|
||
if (!isNaN(ts)) return ts
|
||
}
|
||
|
||
if (msg.created_at) {
|
||
const ts = new Date(msg.created_at).getTime()
|
||
if (!isNaN(ts)) return ts
|
||
}
|
||
|
||
// 处理send_time字段
|
||
if (msg.send_time) {
|
||
// 标准时间格式:2025-10-22 01:52:19
|
||
if (typeof msg.send_time === 'string') {
|
||
// 如果是相对时间(如"刚刚"、"3分钟前"),使用当前时间减去相应的时间
|
||
if (msg.send_time.includes('刚刚')) {
|
||
return Date.now() - 1000 // 1秒前
|
||
} else if (msg.send_time.includes('分钟前')) {
|
||
const minutes = parseInt(msg.send_time.match(/(\d+)分钟前/)?.[1] || '0')
|
||
return Date.now() - (minutes * 60 * 1000)
|
||
} else if (msg.send_time.includes('小时前')) {
|
||
const hours = parseInt(msg.send_time.match(/(\d+)小时前/)?.[1] || '0')
|
||
return Date.now() - (hours * 60 * 60 * 1000)
|
||
} else if (msg.send_time.includes('天前')) {
|
||
const days = parseInt(msg.send_time.match(/(\d+)天前/)?.[1] || '0')
|
||
return Date.now() - (days * 24 * 60 * 60 * 1000)
|
||
}
|
||
|
||
// 尝试解析标准时间格式
|
||
const timestamp = new Date(msg.send_time).getTime()
|
||
if (!isNaN(timestamp)) return timestamp
|
||
}
|
||
}
|
||
|
||
// 如果有ID,尝试从ID中提取时间信息
|
||
if (msg.id || msg._id || msg.message_id) {
|
||
const id = String(msg.id || msg._id || msg.message_id)
|
||
// 如果ID是纯数字且可能是时间戳
|
||
if (/^\d+$/.test(id)) {
|
||
const numId = parseInt(id)
|
||
// 检查是否是合理的时间戳范围(2020年到2030年)
|
||
if (numId > 1577836800000 && numId < 1893456000000) { // 13位时间戳
|
||
return numId
|
||
} else if (numId > 1577836800 && numId < 1893456000) { // 10位时间戳
|
||
return numId * 1000
|
||
}
|
||
}
|
||
// 如果ID包含时间戳
|
||
const timestampMatch = id.match(/(\d{13})/) // 13位时间戳
|
||
if (timestampMatch) return parseInt(timestampMatch[1])
|
||
}
|
||
|
||
// 默认返回当前时间
|
||
return Date.now()
|
||
}
|
||
|
||
return getTimestamp(a) - getTimestamp(b)
|
||
})
|
||
|
||
// 调试信息:显示排序后的消息时间
|
||
console.log('消息排序结果:', sorted.map(m => ({
|
||
id: m.id || m._id || m.message_id,
|
||
send_time: m.send_time,
|
||
content: typeof m.content === 'object' ?
|
||
(m.content.messages || JSON.stringify(m.content)) :
|
||
String(m.content).slice(0, 20) + '...'
|
||
})))
|
||
|
||
return sorted
|
||
}
|
||
|
||
// 获取消息(区分加载历史和最新消息)
|
||
// isHistory: 是否为向上翻页加载历史
|
||
// pageOverride: 可选的临时 page 值(不会修改 msgQuery.page),用于定时拉取最新时使用 page=1
|
||
const getMessages = async (isHistory = true, pageOverride = null) => {
|
||
if (loadingMore.value) return
|
||
if (isHistory && noMoreMessages.value) return
|
||
if (!msgQuery.user_id) return
|
||
|
||
// 只有在历史加载时才设置loadingMore状态,避免轮询时的界面抖动
|
||
const isPolling = !isHistory && pageOverride === 1
|
||
if (!isPolling) {
|
||
loadingMore.value = true
|
||
}
|
||
|
||
// 在第一次加载(page=1 且 isHistory=true)期间也将 isLoadingMessages 保持为 true
|
||
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
||
isLoadingMessages.value = true
|
||
}
|
||
const queryPage = pageOverride ?? msgQuery.page
|
||
const query = { ...msgQuery, page: queryPage }
|
||
|
||
try {
|
||
const res = await get_messages({ ...query })
|
||
msgTotal = res.total || 0
|
||
const list = res.list || []
|
||
|
||
console.log(`获取消息 - isHistory: ${isHistory}, page: ${queryPage}, 返回消息数: ${list.length}`)
|
||
|
||
if (list.length > 0) {
|
||
const newMessages = list.map(item => {
|
||
// 处理消息内容,确保与ChatRecord组件兼容
|
||
try {
|
||
item.content = JSON.parse(item.content)
|
||
} catch (e) {
|
||
// 如果解析失败,说明是纯字符串,包装为对象格式
|
||
item.content = { messages: item.content }
|
||
}
|
||
return item
|
||
})
|
||
|
||
if (isHistory) {
|
||
// 在向上加载历史时,需要在 prepend 前记录 scrollHeight 以便恢复视图位置
|
||
const el = chatBody.value
|
||
const prevScrollHeight = el ? el.scrollHeight : 0
|
||
|
||
if (queryPage === 1) {
|
||
// 第一次加载(第一页)直接替换,并排序
|
||
messages.value = sortMessagesByTime([...newMessages])
|
||
} else {
|
||
// 向上翻页,prepend,然后重新排序整个列表
|
||
const allMessages = [...newMessages, ...messages.value]
|
||
messages.value = sortMessagesByTime(allMessages)
|
||
// 等待 DOM 更新后恢复滚动位置,避免跳动
|
||
await nextTick()
|
||
if (el) {
|
||
// 保持用户看到原来位置(新内容高度增加导致的偏移)
|
||
el.scrollTop = el.scrollHeight - prevScrollHeight
|
||
}
|
||
}
|
||
} else {
|
||
// 拉取最新消息(追加到末尾),改进去重逻辑
|
||
const currentMessages = [...messages.value]
|
||
let hasUpdates = false
|
||
|
||
// 处理每条新消息
|
||
for (const newMsg of newMessages) {
|
||
let isProcessed = false
|
||
|
||
// 1. 首先尝试匹配临时消息(基于内容和时间)
|
||
const tempMsgIndex = currentMessages.findIndex(existingMsg => {
|
||
if (!existingMsg._isTemp || existingMsg._failed) return false
|
||
|
||
// 检查发送者和消息类型
|
||
const senderMatch = existingMsg.sender_id === newMsg.sender_id
|
||
const typeMatch = existingMsg.msg_type === newMsg.msg_type
|
||
|
||
// 检查时间差(10秒内)
|
||
const timeDiff = Math.abs(Date.now() - (existingMsg._timestamp || 0))
|
||
const timeMatch = timeDiff < 10000
|
||
|
||
// 根据消息类型进行不同的内容匹配
|
||
let contentMatch = false
|
||
if (existingMsg.msg_type === 1) {
|
||
// 文本消息:比较文本内容
|
||
const existingContent = existingMsg._tempContent ||
|
||
(typeof existingMsg.content === 'object' ? existingMsg.content.messages : existingMsg.content)
|
||
const newContent = typeof newMsg.content === 'object' ? newMsg.content.messages : newMsg.content
|
||
contentMatch = existingContent === newContent
|
||
} else if (existingMsg.msg_type === 2) {
|
||
// 图片消息:比较文件信息(文件名和大小)
|
||
if (existingMsg._tempImageFile) {
|
||
const newContent = typeof newMsg.content === 'object' ? newMsg.content.messages : newMsg.content
|
||
// 如果新消息包含文件名,尝试匹配
|
||
if (typeof newContent === 'string' && newContent.includes(existingMsg._tempImageFile.name.split('.')[0])) {
|
||
contentMatch = true
|
||
}
|
||
}
|
||
}
|
||
|
||
return senderMatch && typeMatch && contentMatch && timeMatch
|
||
})
|
||
|
||
if (tempMsgIndex !== -1) {
|
||
// 找到匹配的临时消息,替换它
|
||
console.log('找到匹配的临时消息,进行替换:', {
|
||
tempId: currentMessages[tempMsgIndex]._id,
|
||
realId: newMsg.id || newMsg._id || newMsg.message_id
|
||
})
|
||
currentMessages[tempMsgIndex] = {
|
||
...newMsg,
|
||
_isTemp: false // 标记为非临时消息
|
||
}
|
||
hasUpdates = true
|
||
isProcessed = true
|
||
} else {
|
||
// 2. 检查是否已存在相同的真实消息(基于ID)
|
||
const existingMsgIndex = currentMessages.findIndex(existingMsg => {
|
||
if (existingMsg._isTemp) return false // 跳过临时消息
|
||
|
||
// 优先使用真实ID字段进行匹配
|
||
const existingId = existingMsg.id || existingMsg._id || existingMsg.message_id
|
||
const newId = newMsg.id || newMsg._id || newMsg.message_id
|
||
|
||
if (existingId && newId && existingId === newId) {
|
||
return true
|
||
}
|
||
|
||
// 如果没有ID,使用多字段组合进行匹配
|
||
const existingContentStr = typeof existingMsg.content === 'object' ?
|
||
(existingMsg.content.messages || JSON.stringify(existingMsg.content)) :
|
||
String(existingMsg.content)
|
||
const newContentStr = typeof newMsg.content === 'object' ?
|
||
(newMsg.content.messages || JSON.stringify(newMsg.content)) :
|
||
String(newMsg.content)
|
||
|
||
const compositeMatch = existingMsg.sender_id === newMsg.sender_id &&
|
||
existingMsg.send_time === newMsg.send_time &&
|
||
existingMsg.msg_type === newMsg.msg_type &&
|
||
existingContentStr === newContentStr
|
||
|
||
return compositeMatch
|
||
})
|
||
|
||
if (existingMsgIndex === -1) {
|
||
// 3. 真正的新消息,添加到列表
|
||
console.log('发现新消息:', {
|
||
id: newMsg.id || newMsg._id || newMsg.message_id,
|
||
content: typeof newMsg.content === 'object' ? newMsg.content.messages : newMsg.content
|
||
})
|
||
currentMessages.push(newMsg)
|
||
hasUpdates = true
|
||
isProcessed = true
|
||
} else {
|
||
console.log('消息已存在,跳过:', {
|
||
id: newMsg.id || newMsg._id || newMsg.message_id
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUpdates) {
|
||
// 重新排序并更新消息列表
|
||
messages.value = sortMessagesByTime(currentMessages)
|
||
console.log('消息列表已更新,当前消息数:', messages.value.length)
|
||
} else {
|
||
console.log('没有新消息需要处理')
|
||
}
|
||
}
|
||
|
||
// 使用 total 判断是否还有更多历史消息(仅在已知 total 时判断)
|
||
if (msgTotal && isHistory) {
|
||
noMoreMessages.value = messages.value.length >= msgTotal
|
||
} else if (list.length < msgQuery.page_size && isHistory) {
|
||
// 如果后端没有 total,退回到 page_size 判断
|
||
noMoreMessages.value = true
|
||
}
|
||
|
||
// 非历史加载且处于自动滚动状态时滚到底部
|
||
if (!isHistory && isAutoScroll.value) {
|
||
await scrollToBottom()
|
||
}
|
||
} else if (isHistory) {
|
||
noMoreMessages.value = true
|
||
}
|
||
} catch (err) {
|
||
// 可扩展错误处理
|
||
} finally {
|
||
// 只有在非轮询时才清理loadingMore状态
|
||
const isPolling = !isHistory && pageOverride === 1
|
||
if (!isPolling) {
|
||
loadingMore.value = false
|
||
}
|
||
|
||
// 如果这是第一页历史加载,确保 loading 标志被清理(页面外也会在调用者 finally 中清理)
|
||
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
||
isLoadingMessages.value = false
|
||
}
|
||
|
||
// 如果是向上翻页(非第一页)并且 prepend 后视图仍然靠近顶部,自动继续翻页
|
||
try {
|
||
if (isHistory && queryPage !== 1) {
|
||
await nextTick()
|
||
const el2 = chatBody.value
|
||
const nearTop = el2 ? el2.scrollTop <= 50 : false
|
||
if (nearTop && !noMoreMessages.value) {
|
||
// 继续加载下一页历史(递增 page)
|
||
msgQuery.page = msgQuery.page + 1
|
||
await getMessages(true)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 忽略自动加载中的错误,保持用户可见性
|
||
}
|
||
}
|
||
}
|
||
|
||
// 滚动事件处理
|
||
const handleScroll = (e) => {
|
||
const { scrollTop, scrollHeight, clientHeight } = e.target
|
||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50 // 距离底部小于50px视为在底部
|
||
|
||
// 更新自动滚动状态
|
||
isAutoScroll.value = isNearBottom
|
||
|
||
// 控制定时器
|
||
if (isNearBottom && !messageTimer.value && msgQuery.user_id) {
|
||
// 在底部且无定时器,启动定时器
|
||
startMessageTimer()
|
||
} else if (!isNearBottom && messageTimer.value) {
|
||
// 不在底部且有定时器,停止定时器
|
||
stopMessageTimer()
|
||
}
|
||
|
||
// 滚动到顶部时加载更多历史消息
|
||
if (scrollTop <= 0 && !loadingMore.value && !noMoreMessages.value) {
|
||
loadMoreMessages()
|
||
}
|
||
}
|
||
|
||
// 加载更多历史消息
|
||
const loadMoreMessages = () => {
|
||
if (msgQuery.user_id && !loadingMore.value && !noMoreMessages.value) {
|
||
msgQuery.page++
|
||
getMessages(true)
|
||
}
|
||
}
|
||
|
||
// 发送消息
|
||
const send = async () => {
|
||
const content = draft.value.trim()
|
||
if (!content || isSending.value || isUploading.value) return
|
||
|
||
// 防抖检查
|
||
const now = Date.now()
|
||
if (now - lastSendTime.value < 500) {
|
||
console.log('发送过于频繁,已忽略')
|
||
return
|
||
}
|
||
|
||
// 检查是否有相同内容的发送中消息
|
||
const hasSendingMessage = messages.value.some(msg =>
|
||
msg._sending &&
|
||
msg.sender_id === '888888' &&
|
||
msg.msg_type === 1 &&
|
||
typeof msg.content === 'object' &&
|
||
msg.content.messages === content &&
|
||
(now - (msg._timestamp || 0)) < 5000
|
||
)
|
||
|
||
if (hasSendingMessage) {
|
||
console.log('存在相同内容的发送中消息,已忽略')
|
||
return
|
||
}
|
||
|
||
isSending.value = true
|
||
lastSendTime.value = now
|
||
|
||
// 生成临时消息ID,使用特殊前缀标识
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||
const msg = {
|
||
_id: tempId,
|
||
_sending: true,
|
||
_failed: false,
|
||
_isTemp: true, // 标记为临时消息
|
||
sender_id: '888888',
|
||
sender_name: '平台',
|
||
msg_type: 1,
|
||
content: { messages: content },
|
||
send_time: '刚刚',
|
||
_timestamp: now,
|
||
_tempContent: content // 保存原始内容用于匹配
|
||
}
|
||
|
||
messages.value.push(msg)
|
||
draft.value = ''
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => scrollToBottom(), 100)
|
||
isAutoScroll.value = true
|
||
|
||
// 发送到服务器
|
||
send_message({
|
||
app_id: route.query.app_id,
|
||
content: JSON.stringify({
|
||
messages: content
|
||
}),
|
||
msg_type: 1,
|
||
to_user_id: activeUser.value.sender_id
|
||
}).then((response) => {
|
||
msg._sending = false
|
||
msg._failed = false
|
||
console.log('消息发送成功', response)
|
||
|
||
// 发送成功后,立即轮询获取最新消息以获取真实ID
|
||
setTimeout(() => {
|
||
getMessages(false, 1) // 轮询最新消息
|
||
}, 500)
|
||
}).catch((error) => {
|
||
msg._sending = false
|
||
msg._failed = true
|
||
console.error('消息发送失败:', error)
|
||
ElMessage({ type: 'error', message: '消息发送失败' })
|
||
}).finally(() => {
|
||
// 重置发送状态
|
||
isSending.value = false
|
||
})
|
||
}
|
||
|
||
// 键盘事件处理
|
||
const handleInputKeydown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
// 添加防抖延迟
|
||
setTimeout(() => {
|
||
send()
|
||
}, 50)
|
||
}
|
||
}
|
||
|
||
// 图片上传相关
|
||
const triggerImageInput = () => {
|
||
imageInput.value && imageInput.value.click()
|
||
}
|
||
|
||
const handleImageChange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
|
||
// 防止重复上传
|
||
if (isUploading.value) {
|
||
console.log('图片正在上传中,请稍候')
|
||
return
|
||
}
|
||
|
||
// 检查文件大小(限制为10MB)
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
ElMessage({ type: 'error', message: '图片大小不能超过10MB' })
|
||
return
|
||
}
|
||
|
||
isUploading.value = true
|
||
|
||
// 本地预览
|
||
const url = URL.createObjectURL(file)
|
||
const tempId = `temp_img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||
const now = Date.now()
|
||
const msg = {
|
||
_id: tempId,
|
||
_sending: true,
|
||
_failed: false,
|
||
_isTemp: true, // 标记为临时消息
|
||
sender_id: '888888',
|
||
sender_name: '平台',
|
||
msg_type: 2,
|
||
content: { messages: url },
|
||
send_time: '刚刚',
|
||
_timestamp: now,
|
||
_tempImageFile: file // 保存文件信息用于匹配
|
||
}
|
||
messages.value.push(msg)
|
||
e.target.value = ''
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => scrollToBottom(), 100)
|
||
isAutoScroll.value = true
|
||
|
||
// 上传文件
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
form.append('app_id', route.query.app_id)
|
||
form.append('msg_type', 2)
|
||
form.append('to_user_id', activeUser.value.sender_id)
|
||
uploadFile(form, import.meta.env.VITE_APP_BASE_API + 'admin/upload/image').then((resp) => {
|
||
// 上传成功,调用send_message接口发送图片消息
|
||
const imageUrl = import.meta.env.VITE_APP_BASE_API_img + resp.preview_image_url.replace('/api/', '')
|
||
|
||
send_message({
|
||
app_id: route.query.app_id,
|
||
msg_type: 2,
|
||
content: JSON.stringify({ messages: imageUrl }),
|
||
to_user_id: activeUser.value.sender_id
|
||
}).then(() => {
|
||
// 发送成功,移除临时消息
|
||
const index = messages.value.findIndex(m => m._id === tempId)
|
||
if (index !== -1) {
|
||
messages.value.splice(index, 1)
|
||
}
|
||
|
||
// 清理本地预览URL
|
||
if (url.startsWith('blob:')) {
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
// 轮询获取最新消息
|
||
setTimeout(() => {
|
||
getMessages(false, 1)
|
||
}, 500)
|
||
}).catch((sendError) => {
|
||
// 发送失败,更新临时消息状态
|
||
msg._sending = false
|
||
msg._failed = true
|
||
console.error('发送图片消息失败:', sendError)
|
||
ElMessage({ type: 'error', message: '发送图片失败' })
|
||
})
|
||
}).catch((error) => {
|
||
msg._sending = false
|
||
msg._failed = true
|
||
console.error('图片上传失败:', error)
|
||
ElMessage({ type: 'error', message: '图片上传失败' })
|
||
}).finally(() => {
|
||
isUploading.value = false
|
||
// 清理本地预览URL
|
||
if (url.startsWith('blob:')) {
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 重发消息
|
||
const handleRetry = (id) => {
|
||
const msg = messages.value.find((m) => m._id === id)
|
||
if (!msg) return
|
||
|
||
msg._failed = false
|
||
msg._sending = true
|
||
|
||
if (msg.msg_type === 0) {
|
||
send_message({
|
||
app_id: route.query.app_id,
|
||
content: msg.content.content,
|
||
msg_type: 1,
|
||
to_user_id: activeUser.value.sender_id
|
||
}).then(() => {
|
||
msg._sending = false
|
||
}).catch(() => {
|
||
msg._sending = false;
|
||
msg._failed = true;
|
||
ElMessage({ type: 'error', message: '重发失败' })
|
||
})
|
||
} else if (msg.msg_type === 2) {
|
||
const form = new FormData()
|
||
form.append('file', msg.content.file)
|
||
form.append('app_id', route.query.app_id)
|
||
form.append('msg_type', 2)
|
||
form.append('to_user_id', activeUser.value.sender_id)
|
||
send_message(form).then(() => {
|
||
msg._sending = false
|
||
}).catch(() => {
|
||
msg._sending = false;
|
||
msg._failed = true;
|
||
ElMessage({ type: 'error', message: '重发失败' })
|
||
})
|
||
}
|
||
}
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = async () => {
|
||
await nextTick()
|
||
const el = chatBody.value
|
||
if (!el) return
|
||
try {
|
||
el.scrollTop = el.scrollHeight
|
||
} catch (e) {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
|
||
// 表情选择
|
||
const onEmojiSelect = (emoji) => {
|
||
draft.value = (draft.value || '') + emoji
|
||
}
|
||
|
||
// 获取用户列表
|
||
const params = reactive({
|
||
page: 1,
|
||
page_size: 20,
|
||
app_id: route.query.app_id
|
||
})
|
||
|
||
const getUsers = (append = false) => {
|
||
getUserList(params).then((res) => {
|
||
if (append) {
|
||
userList.value = userList.value.concat(res.list || [])
|
||
} else {
|
||
userList.value = res.list || []
|
||
}
|
||
// 首次加载且没有选中用户时,默认选中第一个
|
||
if (!append && userList.value.length && !activeUser.value.sender_id) {
|
||
onSelectUser(userList.value[0].sender_id)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 加载更多用户
|
||
const onLoadUserList = async () => {
|
||
params.page = params.page + 1
|
||
await getUsers(true)
|
||
await nextTick()
|
||
const el = document.querySelector('.left-panel .infinite-list')
|
||
if (el) {
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
}
|
||
|
||
// 定时器相关函数
|
||
const startMessageTimer = () => {
|
||
// 清除现有定时器
|
||
if (messageTimer.value) {
|
||
clearInterval(messageTimer.value)
|
||
}
|
||
|
||
// 每秒获取一次最新消息,不修改 msgQuery.page,使用 pageOverride=1
|
||
messageTimer.value = setInterval(() => {
|
||
if (!msgQuery.user_id) return
|
||
// 获取最新消息(第一页),用于追加新消息
|
||
console.log('定时器轮询消息,用户ID:', msgQuery.user_id)
|
||
getMessages(false, 1)
|
||
}, 1000)
|
||
}
|
||
|
||
// 用户列表轮询定时器
|
||
const userListTimer = ref(null)
|
||
|
||
const startUserListTimer = () => {
|
||
// 清除现有定时器
|
||
if (userListTimer.value) {
|
||
clearInterval(userListTimer.value)
|
||
}
|
||
|
||
// 每秒获取一次用户列表
|
||
userListTimer.value = setInterval(() => {
|
||
getUsers(false) // 不追加,直接替换
|
||
}, 1000)
|
||
}
|
||
|
||
const stopUserListTimer = () => {
|
||
if (userListTimer.value) {
|
||
clearInterval(userListTimer.value)
|
||
userListTimer.value = null
|
||
}
|
||
}
|
||
|
||
// 格式化最新消息内容
|
||
const formatLastMessage = (user) => {
|
||
if (!user.content) return ''
|
||
|
||
try {
|
||
// 尝试解析JSON格式的content
|
||
const contentObj = JSON.parse(user.content)
|
||
|
||
// 优先返回messages字段
|
||
if (contentObj.messages) {
|
||
const content = String(contentObj.messages).slice(0, 30)
|
||
return content.length > 30 ? content + '...' : content
|
||
}
|
||
|
||
// 其次返回text字段
|
||
if (contentObj.text) {
|
||
const content = String(contentObj.text).slice(0, 30)
|
||
return content.length > 30 ? content + '...' : content
|
||
}
|
||
|
||
// 最后返回content字段
|
||
if (contentObj.content) {
|
||
const content = String(contentObj.content).slice(0, 30)
|
||
return content.length > 30 ? content + '...' : content
|
||
}
|
||
|
||
// 如果都没有,返回JSON字符串的前30个字符
|
||
const content = JSON.stringify(contentObj).slice(0, 30)
|
||
return content.length > 30 ? content + '...' : content
|
||
|
||
} catch (error) {
|
||
// JSON解析失败,说明是纯字符串
|
||
let content = String(user.content)
|
||
|
||
// 根据消息类型添加前缀
|
||
if (user.msg_type === 2) {
|
||
content = '[图片] ' + content
|
||
} else if (user.msg_type === 3) {
|
||
content = '[文件] ' + content
|
||
} else if (user.msg_type === 4) {
|
||
content = '[语音] ' + content
|
||
} else if (user.msg_type === 5) {
|
||
content = '[视频] ' + content
|
||
}
|
||
|
||
// 限制长度
|
||
content = content.slice(0, 30)
|
||
return content.length > 30 ? content + '...' : content
|
||
}
|
||
}
|
||
|
||
const stopMessageTimer = () => {
|
||
if (messageTimer.value) {
|
||
clearInterval(messageTimer.value)
|
||
messageTimer.value = null
|
||
}
|
||
}
|
||
|
||
const restartMessageTimer = () => {
|
||
stopMessageTimer()
|
||
if (msgQuery.user_id) {
|
||
startMessageTimer()
|
||
}
|
||
}
|
||
|
||
// 组件挂载时
|
||
onMounted(() => {
|
||
getUsers()
|
||
// 启动用户列表轮询
|
||
startUserListTimer()
|
||
|
||
// 监听窗口关闭,清理定时器
|
||
window.addEventListener('beforeunload', () => {
|
||
stopMessageTimer()
|
||
stopUserListTimer()
|
||
})
|
||
})
|
||
|
||
// 组件卸载时清理定时器
|
||
onUnmounted(() => {
|
||
stopMessageTimer()
|
||
stopUserListTimer()
|
||
})
|
||
|
||
// 监听活跃用户变化,重启定时器
|
||
watch(activeUser, (newVal) => {
|
||
if (newVal.sender_id) {
|
||
restartMessageTimer()
|
||
} else {
|
||
stopMessageTimer()
|
||
}
|
||
})
|
||
|
||
// 当 messages 变化时,尝试可靠地滚动到底部:
|
||
// - 等待 DOM 更新(nextTick),
|
||
// - 立即滚动并在短延迟后再滚一次以应对图片等资源加载导致的高度变化,
|
||
// - 对未加载的图片注册 load 事件,加载完成后再滚动。
|
||
watch(messages, async (newVal, oldVal) => {
|
||
// 只有在自动滚动模式下才触发
|
||
if (!isAutoScroll.value) return
|
||
|
||
await nextTick()
|
||
// 立即尝试滚动到底部
|
||
scrollToBottom()
|
||
|
||
// 在短延迟后再尝试一次,以容忍布局抖动或图片加载
|
||
setTimeout(() => scrollToBottom(), 120)
|
||
|
||
// 如果存在未完成加载的图片,注册 load 事件,加载后滚到底部
|
||
const el = chatBody.value
|
||
if (el) {
|
||
const imgs = el.querySelectorAll('img')
|
||
imgs.forEach(img => {
|
||
if (!img.complete) {
|
||
img.addEventListener('load', () => {
|
||
if (isAutoScroll.value) scrollToBottom()
|
||
}, { once: true })
|
||
}
|
||
})
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.chat-page {
|
||
display: flex;
|
||
height: calc(100vh - 64px);
|
||
}
|
||
|
||
:deep(.el-textarea__inner) {
|
||
background-color: transparent;
|
||
padding-left: 8px !important;
|
||
}
|
||
|
||
.left-panel {
|
||
width: 280px;
|
||
border-right: 1px solid var(--el-border-color);
|
||
background: var(--el-bg-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.left-header {
|
||
height: 60px;
|
||
line-height: 60px;
|
||
padding: 0 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.right-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chat-header {
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid var(--el-border-color);
|
||
}
|
||
|
||
.chat-header .avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.chat-body {
|
||
flex: 1;
|
||
padding: 16px;
|
||
overflow: auto;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
/* Hide scrollbar but keep scrolling functionality */
|
||
.chat-body {
|
||
/* For WebKit-based browsers (Chrome, Safari, Edge Chromium) */
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.chat-body::-webkit-scrollbar {
|
||
width: 0px;
|
||
height: 0px;
|
||
}
|
||
/* For Firefox */
|
||
.chat-body {
|
||
scrollbar-width: none; /* Firefox */
|
||
}
|
||
/* For IE and older Edge */
|
||
.chat-body {
|
||
-ms-overflow-style: none; /* IE 10+ */
|
||
}
|
||
|
||
/* When loading messages during user switch, hide visual content to avoid jump/flicker */
|
||
.chat-body--loading {
|
||
opacity: 0;
|
||
transition: opacity 180ms ease-in-out;
|
||
pointer-events: none; /* prevent interaction during transient loading */
|
||
}
|
||
|
||
// 加载更多样式
|
||
.loading-more {
|
||
text-align: center;
|
||
padding: 10px;
|
||
color: #666;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.chat-footer {
|
||
height: 96px;
|
||
padding: 5px 15px;
|
||
border-top: 1px solid var(--el-border-color);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-top: 10px;
|
||
}
|
||
|
||
.chat-footer .footer-left {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chat-footer .el-button {
|
||
height: 40px;
|
||
align-self: flex-end;
|
||
}
|
||
|
||
.chat-footer .actions {
|
||
display: flex;
|
||
align-items: center
|
||
}
|
||
</style> |