feat(chat): 优化聊天界面功能并添加消息管理

refactor: 重构消息处理和用户交互逻辑

fix(env): 更新环境变量配置

style: 调整用户卡片样式和布局

perf: 优化消息发送和接收性能

test: 添加消息排序和去重逻辑测试

chore: 清理无用代码和文件
This commit is contained in:
邹方成 2025-10-29 17:49:12 +08:00
parent 884870d9b7
commit 5f0807c5b7
11 changed files with 8773 additions and 4875 deletions

View File

@ -1,6 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
VITE_SERVE = "https://mini-chat.1024tool.vip/api"
VITE_SERVE = "https://mini-chat.1024tool.vip/api/"
VITE_APP_BASE_API_img = "https://mini-chat.1024tool.vip/"

View File

@ -1,5 +1,5 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'production'
VITE_APP_BASE_API = 'https://dsjhd9s.tbmw.cn/api/'
VITE_SERVE = "https://dsjhd9s.tbmw.cn/api/"
VITE_APP_BASE_API_img = "https://dsjhd9s.tbmw.cn/"
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
VITE_SERVE = "https://mini-chat.1024tool.vip/api/"
VITE_APP_BASE_API_img = "https://mini-chat.1024tool.vip/"

View File

@ -1,4 +1,4 @@
NODE_ENV = 'development'
VITE_APP_BASE_API = 'http://dev-scrm.1024tool.vip/'
VITE_SERVE = "http://dev-scrm.1024tool.vip/"
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
VITE_SERVE = "https://mini-chat.1024tool.vip/api/"

11369
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -57,13 +57,12 @@ const props = defineProps({
const keyWordsText = ref(props.keyWords)
const list = ref(props.msgList)
watch(() => props.msgList, (val) => {
console.log('ChatRecord 接收到消息列表:', val.length, '条消息')
list.value = val
})
onMounted(() => {
console.log('ChatRecord 组件挂载,初始消息数:', props.msgList.length)
})
</script>

View File

@ -9,8 +9,11 @@
<span v-if="item.unread_count && item.unread_count > 0" class="unread-badge">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</span>
</div>
<div class="name-block">
<span class="nickname">{{ item.sender_name }}</span>
<span v-if="item.send_time" class="send-time">{{ item.send_time }}</span>
<div class="user-info">
<span class="nickname">{{ item.sender_name }}</span>
<span v-if="item.send_time" class="send-time">{{ item.send_time }}</span>
</div>
<div v-if="item.content" class="last-message">{{ formatLastMessage(item) }}</div>
</div>
<span class="sex">
<!-- <el-icon v-if="item.sex == '男'" style="color: rgb(121.3, 187.1, 255);">
@ -95,6 +98,56 @@ const handleItem = (row) => {
}
//
const formatLastMessage = (user) => {
if (!user.content) return ''
try {
// JSONcontent
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
}
// JSON30
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
}
}
onMounted(() => {
})
@ -107,9 +160,9 @@ onMounted(() => {
.infinite-list .infinite-list-item {
display: flex;
height: 46px;
height: 60px;
// margin: 10px 0;
padding: 0 10px;
padding: 8px 10px;
line-height: 22px;
cursor: pointer;
position: relative;
@ -124,11 +177,11 @@ onMounted(() => {
}
.user-avatar {
height: 32px;
width: 32px;
border-radius: 4px;
height: 40px;
width: 40px;
border-radius: 6px;
vertical-align: middle;
margin-top: 6px;
margin-top: 2px;
}
.unread-badge {
@ -159,14 +212,47 @@ onMounted(() => {
flex-direction: column;
justify-content: center;
margin-left: 8px;
flex: 1;
min-width: 0; //
}
.user-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2px;
}
.nickname {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.send-time {
font-size: 12px;
color: #999;
margin-top: 2px;
white-space: nowrap;
margin-left: 8px;
}
.last-message {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
.sex {
.el-icon {
font-size: 18px;

781
src/composables/useChat.ts Normal file
View File

@ -0,0 +1,781 @@
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { get_messages, send_message } from '@/api/chat'
import { ElMessage } from 'element-plus'
import { MessageManager, type MessageItem } from '@/utils/messageManager'
export interface ChatMessage extends MessageItem {
// 继承MessageItem的所有属性
}
export interface ChatUser {
sender_id: string
sender_name: string
sender_avatar?: string
active?: boolean
}
export interface ChatState {
messages: ChatMessage[]
activeUser: ChatUser | null
isLoading: boolean
isConnected: boolean
lastMessageTime: string | null
unreadCount: number
}
export interface PollingConfig {
baseInterval: number // 基础轮询间隔(毫秒)
maxInterval: number // 最大轮询间隔
minInterval: number // 最小轮询间隔
backoffMultiplier: number // 退避倍数
maxRetries: number // 最大重试次数
}
export function useChat(appId: string, config?: Partial<PollingConfig>) {
// 默认配置
const defaultConfig: PollingConfig = {
baseInterval: 1000, // 1秒
maxInterval: 30000, // 30秒
minInterval: 500, // 0.5秒
backoffMultiplier: 1.5,
maxRetries: 3
}
const pollingConfig = { ...defaultConfig, ...config }
// 自适应轮询配置
const adaptiveConfig = {
activeInterval: 1000, // 用户活跃时的轮询间隔
inactiveInterval: 5000, // 用户不活跃时的轮询间隔
idleInterval: 15000, // 用户空闲时的轮询间隔
inactiveThreshold: 30000, // 不活跃阈值30秒
idleThreshold: 300000, // 空闲阈值5分钟
messageFrequencyThreshold: 5, // 消息频率阈值(每分钟)
adaptiveEnabled: true // 是否启用自适应轮询
}
// 性能优化配置
const performanceConfig = {
maxMessages: 1000, // 最大消息数量
cleanupThreshold: 1200, // 清理阈值
cleanupBatchSize: 200, // 每次清理的消息数量
debounceDelay: 100, // 防抖延迟(毫秒)
throttleDelay: 200, // 节流延迟(毫秒)
enableVirtualScroll: true, // 启用虚拟滚动
enableLazyLoading: true // 启用图片懒加载
}
// 消息管理器
const messageManager = new MessageManager(1000)
// 状态管理
const state = reactive<ChatState>({
messages: [],
activeUser: null,
isLoading: false,
isConnected: true,
lastMessageTime: null,
unreadCount: 0
})
// 轮询相关状态
const pollingState = reactive({
timer: null as number | null,
currentInterval: pollingConfig.baseInterval,
retryCount: 0,
isPolling: false,
lastPollTime: 0,
consecutiveEmptyPolls: 0
})
// 用户活动状态跟踪
const activityState = reactive({
lastActivity: Date.now(),
lastMessageTime: Date.now(),
messageCount: 0,
messageFrequency: 0, // 每分钟消息数
userStatus: 'active' as 'active' | 'inactive' | 'idle'
})
// 滚动和UI状态
const uiState = reactive({
isAutoScroll: true,
isNearBottom: true,
loadingMore: false,
noMoreMessages: false
})
// 虚拟滚动状态
const virtualScrollState = reactive({
containerHeight: 400,
itemHeight: 80,
scrollTop: 0,
visibleStart: 0,
visibleEnd: 0
})
// 分页查询参数
const queryParams = reactive({
page: 1,
page_size: 50,
app_id: appId,
user_id: ''
})
// 计算属性
const hasActiveUser = computed(() => !!state.activeUser?.sender_id)
const shouldPoll = computed(() => hasActiveUser.value) // 只要有活跃用户就应该轮询
const hasMessages = computed(() => state.messages.length > 0)
const isLoading = computed(() => uiState.loadingMore || pollingState.isPolling)
const connectionStatus = computed(() => {
if (!state.isConnected) return 'disconnected'
if (pollingState.isPolling) return 'polling'
return 'connected'
})
// 虚拟滚动计算属性
const virtualScrollHeight = computed(() => {
return state.messages.length * virtualScrollState.itemHeight
})
const virtualScrollOffset = computed(() => {
return virtualScrollState.visibleStart * virtualScrollState.itemHeight
})
const visibleMessages = computed(() => {
if (!performanceConfig.enableVirtualScroll || state.messages.length <= 50) {
return state.messages
}
const start = Math.max(0, virtualScrollState.visibleStart)
const end = Math.min(state.messages.length, virtualScrollState.visibleEnd)
return state.messages.slice(start, end)
})
/**
*
*/
function getMessageId(message: ChatMessage): string {
return messageManager.getMessageId(message)
}
/**
*
*/
function calculateAdaptiveInterval(): number {
if (!adaptiveConfig.adaptiveEnabled) {
return pollingConfig.baseInterval
}
const now = Date.now()
const timeSinceLastActivity = now - activityState.lastActivity
const timeSinceLastMessage = now - activityState.lastMessageTime
// 更新用户状态
if (timeSinceLastActivity < adaptiveConfig.inactiveThreshold) {
activityState.userStatus = 'active'
} else if (timeSinceLastActivity < adaptiveConfig.idleThreshold) {
activityState.userStatus = 'inactive'
} else {
activityState.userStatus = 'idle'
}
// 计算消息频率(每分钟消息数)
const messageTimeWindow = 60000 // 1分钟
if (timeSinceLastMessage < messageTimeWindow) {
activityState.messageFrequency = activityState.messageCount / (timeSinceLastMessage / messageTimeWindow)
} else {
activityState.messageFrequency = 0
}
// 根据用户状态和消息频率确定轮询间隔
let interval = pollingConfig.baseInterval
switch (activityState.userStatus) {
case 'active':
interval = adaptiveConfig.activeInterval
// 如果消息频率高,减少轮询间隔
if (activityState.messageFrequency > adaptiveConfig.messageFrequencyThreshold) {
interval = Math.max(500, interval * 0.5)
}
break
case 'inactive':
interval = adaptiveConfig.inactiveInterval
break
case 'idle':
interval = adaptiveConfig.idleInterval
break
}
return Math.min(interval, pollingConfig.maxInterval)
}
// 防抖函数
const debounce = (func: Function, delay: number) => {
let timeoutId: number | null = null
return (...args: any[]) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = window.setTimeout(() => func.apply(null, args), delay)
}
}
// 节流函数
const throttle = (func: Function, delay: number) => {
let lastCall = 0
return (...args: any[]) => {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
func.apply(null, args)
}
}
}
/**
*
*/
const updateUserActivity = debounce(() => {
activityState.lastActivity = Date.now()
activityState.userStatus = 'active'
// 固定轮询模式下不需要重启轮询
}, performanceConfig.debounceDelay)
// 检查并清理旧消息
const checkAndCleanupMessages = () => {
const currentCount = messageManager.getStats().totalMessages
if (currentCount > performanceConfig.cleanupThreshold) {
const messagesToRemove = currentCount - performanceConfig.maxMessages
messageManager.cleanup(messagesToRemove)
state.messages = messageManager.getAllMessages()
console.log(`清理了 ${messagesToRemove} 条旧消息,当前消息数: ${messageManager.getStats().totalMessages}`)
}
}
// 内存监控
const memoryMonitor = {
lastCheck: Date.now(),
checkInterval: 30000, // 30秒检查一次
check() {
const now = Date.now()
if (now - this.lastCheck < this.checkInterval) return
this.lastCheck = now
const stats = messageManager.getStats()
console.log('内存监控:', {
消息数量: stats.totalMessages,
缓存大小: stats.cacheSize,
: `${(stats.memoryUsage / 1024 / 1024).toFixed(2)}MB`,
虚拟滚动: performanceConfig.enableVirtualScroll ? '启用' : '禁用',
懒加载: performanceConfig.enableLazyLoading ? '启用' : '禁用'
})
// 如果内存使用过高,强制清理
if (stats.memoryUsage > 50 * 1024 * 1024) { // 50MB
console.warn('内存使用过高,执行强制清理')
checkAndCleanupMessages()
}
}
}
/**
*
*/
function calculateNextInterval(hasNewMessages: boolean, errorOccurred: boolean): number {
if (errorOccurred) {
// 发生错误时使用指数退避
pollingState.currentInterval = Math.min(
pollingState.currentInterval * pollingConfig.backoffMultiplier,
pollingConfig.maxInterval
)
} else if (hasNewMessages) {
// 有新消息时加快轮询
pollingState.currentInterval = pollingConfig.minInterval
pollingState.consecutiveEmptyPolls = 0
} else {
// 没有新消息时逐渐减慢轮询
pollingState.consecutiveEmptyPolls++
if (pollingState.consecutiveEmptyPolls > 3) {
pollingState.currentInterval = Math.min(
pollingState.currentInterval * 1.2,
pollingConfig.maxInterval
)
}
}
return pollingState.currentInterval
}
/**
*
*/
async function fetchLatestMessages(force: boolean = false): Promise<boolean> {
if (!force && (pollingState.isPolling || !hasActiveUser.value)) {
return false
}
if (!hasActiveUser.value) {
return false
}
pollingState.isPolling = true
pollingState.lastPollTime = Date.now()
try {
const query = {
...queryParams,
page: 1, // 始终获取第一页的最新消息
timestamp: state.lastMessageTime // 可选:传递时间戳进行增量更新
}
console.log('轮询获取消息:', query)
const response = await get_messages(query)
const newMessages = (response as any).list || []
if (newMessages.length > 0) {
// 处理消息内容
const processedMessages = newMessages.map((msg: any) => {
try {
if (typeof msg.content === 'string') {
try {
msg.content = JSON.parse(msg.content)
} catch (parseErr) {
msg.content = { messages: msg.content }
}
}
} catch (e) {
// 保持原始内容(已在上方处理字符串情况)
}
return msg
})
// 使用MessageManager进行去重和管理
const result = messageManager.addMessages(processedMessages)
if (result.newMessages.length > 0) {
// 更新状态中的消息列表
const oldMessagesCount = state.messages.length
state.messages = messageManager.getAllMessages()
const newMessagesCount = state.messages.length
console.log(`消息状态更新: 旧消息数量=${oldMessagesCount}, 新消息数量=${newMessagesCount}`)
console.log('新增消息:', result.newMessages.map(m => ({ id: messageManager.getMessageId(m), content: m.content })))
// 检查是否需要清理旧消息
checkAndCleanupMessages()
// 内存监控检查
memoryMonitor.check()
// 更新最后消息时间
const latestMessage = result.newMessages[result.newMessages.length - 1]
state.lastMessageTime = latestMessage.send_time
// 如果用户不在底部,增加未读计数
if (!uiState.isNearBottom) {
state.unreadCount += result.newMessages.length
}
console.log(`添加了 ${result.newMessages.length} 条新消息,更新了 ${result.updatedMessages.length} 条消息`)
console.log('消息管理器统计:', messageManager.getStats())
console.log('当前消息列表长度:', state.messages.length)
pollingState.retryCount = 0
state.isConnected = true
return true
}
}
pollingState.retryCount = 0
state.isConnected = true
return false
} catch (error) {
console.error('获取消息失败:', error)
pollingState.retryCount++
if (pollingState.retryCount >= pollingConfig.maxRetries) {
state.isConnected = false
ElMessage.error('连接失败,请检查网络')
}
return false
} finally {
pollingState.isPolling = false
}
}
/**
*
*/
function startPolling() {
stopPolling()
if (!shouldPoll.value) {
return
}
console.log('启动固定1秒轮询消息')
const poll = async () => {
console.log(`开始轮询 - 用户: ${state.activeUser?.sender_name}, shouldPoll: ${shouldPoll.value}`)
const hasNewMessages = await fetchLatestMessages()
if (hasNewMessages) {
activityState.messageCount++
activityState.lastMessageTime = Date.now()
console.log('轮询发现新消息,已更新状态')
} else {
console.log('轮询未发现新消息')
}
// 使用固定1秒轮询间隔
const nextInterval = pollingConfig.baseInterval // 固定1秒
console.log(`轮询状态: 固定间隔, 间隔: ${nextInterval}ms, 下次轮询: ${shouldPoll.value}`)
if (shouldPoll.value) {
pollingState.timer = setTimeout(poll, nextInterval)
} else {
console.log('停止轮询 - shouldPoll为false')
}
}
// 立即执行一次,然后开始定时轮询
poll()
}
/**
*
*/
function stopPolling() {
if (pollingState.timer) {
clearTimeout(pollingState.timer)
pollingState.timer = null
console.log('停止消息轮询')
}
}
/**
*
*/
function resetPolling() {
pollingState.currentInterval = pollingConfig.baseInterval
pollingState.retryCount = 0
pollingState.consecutiveEmptyPolls = 0
}
/**
*
*/
async function loadHistoryMessages() {
if (uiState.loadingMore || uiState.noMoreMessages || !hasActiveUser.value) {
return
}
uiState.loadingMore = true
try {
queryParams.page++
const response = await get_messages({ ...queryParams })
const historyMessages = (response as any).list || []
if (historyMessages.length > 0) {
// 处理消息内容
const processedMessages = historyMessages.map((msg: any) => {
try {
if (typeof msg.content === 'string') {
try {
msg.content = JSON.parse(msg.content)
} catch (parseErr) {
msg.content = { messages: msg.content }
}
}
} catch (e) {
// 保持原始内容(已在上方处理字符串情况)
}
return msg
})
// 使用MessageManager添加历史消息
messageManager.addMessages(processedMessages)
state.messages = messageManager.getAllMessages()
// 检查是否还有更多消息
if (historyMessages.length < queryParams.page_size) {
uiState.noMoreMessages = true
}
} else {
uiState.noMoreMessages = true
}
} catch (error) {
console.error('加载历史消息失败:', error)
queryParams.page-- // 回滚页码
} finally {
uiState.loadingMore = false
}
}
/**
*
*/
async function sendMessage(content: string, msgType: number = 1) {
if (!hasActiveUser.value || !content.trim()) {
return
}
// 更新用户活动
updateUserActivity()
const tempId = `temp_${Date.now()}_${Math.random()}`
const tempMessage: ChatMessage = {
_id: tempId,
sender_id: 'current_user',
sender_name: '我',
content: { messages: content },
msg_type: msgType,
send_time: new Date().toISOString(),
_sending: true,
_failed: false,
_local: true
}
// 使用MessageManager添加临时消息
messageManager.addMessage(tempMessage)
state.messages = messageManager.getAllMessages()
try {
const response = await send_message({
app_id: appId,
content,
msg_type: msgType,
to_user_id: state.activeUser!.sender_id
})
// 更新消息状态
messageManager.markMessageSent(tempId, response as any)
state.messages = messageManager.getAllMessages()
// 立即强制轮询一次获取最新消息
await fetchLatestMessages(true)
} catch (error) {
console.error('发送消息失败:', error)
// 标记消息发送失败
messageManager.markMessageFailed(tempId)
state.messages = messageManager.getAllMessages()
ElMessage.error('消息发送失败')
}
}
/**
*
*/
async function retryMessage(messageId: string) {
const message = state.messages.find(msg => getMessageId(msg) === messageId)
if (!message || !message._failed) {
return
}
message._sending = true
message._failed = false
try {
const content = typeof message.content === 'object'
? message.content.messages || message.content.content || ''
: message.content
await sendMessage(content, message.msg_type)
} catch (error) {
message._sending = false
message._failed = true
}
}
/**
*
*/
function switchUser(user: ChatUser) {
// 停止当前轮询
stopPolling()
// 重置状态
state.activeUser = user
state.messages = []
state.lastMessageTime = null
state.unreadCount = 0
queryParams.user_id = user.sender_id
queryParams.page = 1
uiState.noMoreMessages = false
resetPolling()
// 加载初始消息
loadInitialMessages()
}
/**
*
*/
async function loadInitialMessages() {
if (!hasActiveUser.value) return
state.isLoading = true
try {
const response = await get_messages({ ...queryParams })
const messages = (response as any).list || []
if (messages.length > 0) {
const processedMessages = messages.map((msg: any) => {
try {
if (typeof msg.content === 'string') {
try {
msg.content = JSON.parse(msg.content)
} catch (parseErr) {
msg.content = { messages: msg.content }
}
}
} catch (e) {
// 保持原始内容(已在上方处理字符串情况)
}
return msg
})
// 使用MessageManager初始化消息
messageManager.addMessages(processedMessages)
state.messages = messageManager.getAllMessages()
// 设置最后消息时间
if (processedMessages.length > 0) {
state.lastMessageTime = processedMessages[processedMessages.length - 1].send_time
}
// 检查是否还有更多消息
if (messages.length < queryParams.page_size) {
uiState.noMoreMessages = true
}
}
// 开始轮询
startPolling()
} catch (error) {
console.error('加载初始消息失败:', error)
ElMessage.error('加载消息失败')
} finally {
state.isLoading = false
}
}
/**
*
*/
const handleScroll = throttle((scrollTop: number, scrollHeight: number, clientHeight: number) => {
// 更新用户活动
updateUserActivity()
// 更新虚拟滚动状态
if (performanceConfig.enableVirtualScroll && state.messages.length > 50) {
virtualScrollState.scrollTop = scrollTop
virtualScrollState.containerHeight = clientHeight
const visibleCount = Math.ceil(clientHeight / virtualScrollState.itemHeight) + 2
const startIndex = Math.floor(scrollTop / virtualScrollState.itemHeight)
virtualScrollState.visibleStart = Math.max(0, startIndex - 1)
virtualScrollState.visibleEnd = Math.min(
state.messages.length,
virtualScrollState.visibleStart + visibleCount
)
}
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50
const wasNearBottom = uiState.isNearBottom
uiState.isNearBottom = isNearBottom
uiState.isAutoScroll = isNearBottom
// 清除未读计数
if (isNearBottom && state.unreadCount > 0) {
state.unreadCount = 0
}
// 控制轮询
// 保持轮询持续进行,与滚动位置无关
if (shouldPoll.value && !pollingState.timer) {
startPolling()
}
// 加载更多历史消息
if (scrollTop <= 0) {
loadHistoryMessages()
}
}, performanceConfig.throttleDelay)
// 生命周期管理
onMounted(() => {
// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPolling()
} else if (shouldPoll.value) {
startPolling()
}
})
})
onUnmounted(() => {
stopPolling()
})
// 重启轮询(用于自适应调整)
const restartPolling = () => {
if (pollingState.isPolling) {
stopPolling()
startPolling()
}
}
return {
// 状态
state,
pollingState,
uiState,
activityState,
virtualScrollState,
// 计算属性
hasActiveUser,
shouldPoll,
hasMessages,
isLoading,
connectionStatus,
virtualScrollHeight,
virtualScrollOffset,
visibleMessages,
// 方法
switchUser,
sendMessage,
retryMessage,
loadHistoryMessages,
handleScroll,
startPolling,
stopPolling,
restartPolling,
updateUserActivity,
// 工具方法
getMessageId,
// 性能配置
performanceConfig
}
}

405
src/utils/messageManager.ts Normal file
View File

@ -0,0 +1,405 @@
/**
*
*
*/
export interface MessageItem {
id?: string | number
_id?: string | number
message_id?: string | number
sender_id: string
sender_name: string
sender_avatar?: string
content: any
msg_type: number
send_time: string
_sending?: boolean
_failed?: boolean
_local?: boolean // 本地临时消息标记
}
export interface MessageCache {
messages: Map<string, MessageItem>
messageOrder: string[] // 保持消息顺序
lastUpdateTime: number
maxCacheSize: number
}
export class MessageManager {
private cache: MessageCache
private readonly MAX_CACHE_SIZE = 1000 // 最大缓存消息数量
private readonly CACHE_CLEANUP_THRESHOLD = 1200 // 触发清理的阈值
constructor(maxCacheSize: number = 1000) {
this.cache = {
messages: new Map(),
messageOrder: [],
lastUpdateTime: Date.now(),
maxCacheSize
}
}
/**
*
*/
getMessageId(message: MessageItem): string {
// 优先级message_id > id > _id > 组合键
if (message.message_id) {
return String(message.message_id)
}
if (message.id) {
return String(message.id)
}
if (message._id) {
return String(message._id)
}
// 使用组合键作为后备方案
const timeKey = new Date(message.send_time).getTime()
const contentKey = this.getContentHash(message.content)
return `${message.sender_id}-${timeKey}-${contentKey}`
}
/**
*
*/
private getContentHash(content: any): string {
const str = typeof content === 'string' ? content : JSON.stringify(content)
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // 转换为32位整数
}
return Math.abs(hash).toString(36)
}
/**
*
*/
addMessage(message: MessageItem): boolean {
const messageId = this.getMessageId(message)
// 检查是否已存在
if (this.cache.messages.has(messageId)) {
// 更新现有消息(可能是状态变化)
const existingMessage = this.cache.messages.get(messageId)!
const updatedMessage = { ...existingMessage, ...message }
this.cache.messages.set(messageId, updatedMessage)
return false // 表示不是新消息
}
// 添加新消息
this.cache.messages.set(messageId, { ...message })
this.cache.messageOrder.push(messageId)
this.cache.lastUpdateTime = Date.now()
// 检查是否需要清理缓存
if (this.cache.messageOrder.length > this.CACHE_CLEANUP_THRESHOLD) {
this.cleanupCache()
}
return true // 表示是新消息
}
/**
*
*/
addMessages(messages: MessageItem[]): { newMessages: MessageItem[], updatedMessages: MessageItem[] } {
const newMessages: MessageItem[] = []
const updatedMessages: MessageItem[] = []
for (const message of messages) {
const isNew = this.addMessage(message)
if (isNew) {
newMessages.push(message)
} else {
updatedMessages.push(message)
}
}
return { newMessages, updatedMessages }
}
/**
*
*/
getAllMessages(): MessageItem[] {
return this.cache.messageOrder
.map(id => this.cache.messages.get(id))
.filter(Boolean) as MessageItem[]
}
/**
*
*/
getMessages(start: number = 0, count: number = 50): MessageItem[] {
const allMessages = this.getAllMessages()
return allMessages.slice(start, start + count)
}
/**
* N条消息
*/
getLatestMessages(count: number = 50): MessageItem[] {
const allMessages = this.getAllMessages()
return allMessages.slice(-count)
}
/**
*
*/
updateMessageStatus(messageId: string, updates: Partial<MessageItem>): boolean {
const message = this.cache.messages.get(messageId)
if (!message) {
return false
}
const updatedMessage = { ...message, ...updates }
this.cache.messages.set(messageId, updatedMessage)
this.cache.lastUpdateTime = Date.now()
return true
}
/**
*
*/
markMessageSending(messageId: string): boolean {
return this.updateMessageStatus(messageId, { _sending: true, _failed: false })
}
/**
*
*/
markMessageSent(messageId: string, serverMessage?: Partial<MessageItem>): boolean {
const updates: Partial<MessageItem> = {
_sending: false,
_failed: false,
_local: false,
...serverMessage
}
return this.updateMessageStatus(messageId, updates)
}
/**
*
*/
markMessageFailed(messageId: string): boolean {
return this.updateMessageStatus(messageId, { _sending: false, _failed: true })
}
/**
*
*/
removeMessage(messageId: string): boolean {
if (!this.cache.messages.has(messageId)) {
return false
}
this.cache.messages.delete(messageId)
const index = this.cache.messageOrder.indexOf(messageId)
if (index > -1) {
this.cache.messageOrder.splice(index, 1)
}
this.cache.lastUpdateTime = Date.now()
return true
}
/**
*
*/
private cleanupCache(): void {
const excessCount = this.cache.messageOrder.length - this.cache.maxCacheSize
if (excessCount <= 0) {
return
}
// 移除最旧的消息
const messagesToRemove = this.cache.messageOrder.splice(0, excessCount)
for (const messageId of messagesToRemove) {
this.cache.messages.delete(messageId)
}
console.log(`清理了 ${excessCount} 条旧消息,当前缓存大小: ${this.cache.messageOrder.length}`)
}
/**
*
*/
clear(): void {
this.cache.messages.clear()
this.cache.messageOrder = []
this.cache.lastUpdateTime = Date.now()
}
/**
*
*/
cleanup(removeCount: number) {
if (removeCount <= 0 || removeCount >= this.cache.messageOrder.length) return
// 移除最旧的消息
const messagesToRemove = this.cache.messageOrder.splice(0, removeCount)
// 从缓存中移除对应的消息
messagesToRemove.forEach(messageId => {
this.cache.messages.delete(messageId)
})
this.cache.lastUpdateTime = Date.now()
console.log(`MessageManager: 清理了 ${removeCount} 条旧消息`)
}
/**
*
*/
getStats() {
return {
totalMessages: this.cache.messageOrder.length,
cacheSize: this.cache.messages.size,
lastUpdateTime: this.cache.lastUpdateTime,
maxCacheSize: this.cache.maxCacheSize,
memoryUsage: this.estimateMemoryUsage()
}
}
/**
* 使
*/
private estimateMemoryUsage(): number {
let totalSize = 0
for (const message of this.cache.messages.values()) {
// 粗略估算每个消息对象的大小
totalSize += JSON.stringify(message).length * 2 // UTF-16编码每字符2字节
}
// 加上Map和Array的开销
totalSize += this.cache.messageOrder.length * 50 // 每个字符串引用约50字节
return totalSize
}
/**
*
*/
export(): { messages: MessageItem[], order: string[], timestamp: number } {
return {
messages: Array.from(this.cache.messages.values()),
order: [...this.cache.messageOrder],
timestamp: this.cache.lastUpdateTime
}
}
/**
*
*/
import(data: { messages: MessageItem[], order: string[], timestamp: number }): void {
this.clear()
// 重建缓存
for (const message of data.messages) {
const messageId = this.getMessageId(message)
this.cache.messages.set(messageId, message)
}
this.cache.messageOrder = [...data.order]
this.cache.lastUpdateTime = data.timestamp
// 验证数据完整性
this.validateCache()
}
/**
*
*/
private validateCache(): void {
// 移除order中不存在的消息ID
this.cache.messageOrder = this.cache.messageOrder.filter(id =>
this.cache.messages.has(id)
)
// 移除messages中不在order中的消息
for (const messageId of this.cache.messages.keys()) {
if (!this.cache.messageOrder.includes(messageId)) {
this.cache.messages.delete(messageId)
}
}
}
/**
*
*/
findMessage(predicate: (message: MessageItem) => boolean): MessageItem | undefined {
for (const message of this.cache.messages.values()) {
if (predicate(message)) {
return message
}
}
return undefined
}
/**
*
*/
filterMessages(predicate: (message: MessageItem) => boolean): MessageItem[] {
return this.getAllMessages().filter(predicate)
}
/**
*
*/
getFailedMessages(): MessageItem[] {
return this.filterMessages(msg => msg._failed === true)
}
/**
*
*/
getSendingMessages(): MessageItem[] {
return this.filterMessages(msg => msg._sending === true)
}
/**
*
*/
getLocalMessages(): MessageItem[] {
return this.filterMessages(msg => msg._local === true)
}
}
/**
*
*/
export function createMessageManager(maxCacheSize?: number): MessageManager {
return new MessageManager(maxCacheSize)
}
/**
*
*/
export function deduplicateMessages(messages: MessageItem[]): MessageItem[] {
const manager = createMessageManager()
manager.addMessages(messages)
return manager.getAllMessages()
}
/**
*
*/
export function mergeMessages(existingMessages: MessageItem[], newMessages: MessageItem[]): MessageItem[] {
const manager = createMessageManager()
// 先添加现有消息
manager.addMessages(existingMessages)
// 再添加新消息
const result = manager.addMessages(newMessages)
return {
allMessages: manager.getAllMessages(),
newCount: result.newMessages.length,
updatedCount: result.updatedMessages.length
} as any
}

View File

@ -1,398 +0,0 @@
<template>
<div class="chat-page">
<div class="left-panel">
<UserCard @load="onLoadUserList" :cardlist="userList" height="calc(100vh - 120px)" @change="onSelectUser" />
</div>
<div class="right-panel">
<div class="chat-body" ref="chatBody" @scroll="handleScroll">
<ChatRecord :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;">
<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>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, watch } from 'vue'
import ChatRecord from '@/components/ChatRecord/index.vue'
import UserCard 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'
const route = useRoute()
// mock data for now integrate with store/api as needed
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 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 onSelectUser = (id) => {
// id may be user_id or array when multiple; handle only single selection here
const u = userList.value.find((it) => it.user_id == id)
if (u) {
// mark selected and clear others
userList.value.forEach((it) => (it.active = it.user_id === id))
activeUser.value = u
sendeInfo.userInfo = { user_id: u.user_id, user_name: u.user_name, user_avatar: u.user_avatar }
// set robot identity used by ChatRecord to determine right-side messages
sendeInfo.robotInfo = { wx_id: 'robot', head_url: mihoutai }
// load messages for user (placeholder)
messages.value = []
//
msgQuery.page = 1
msgQuery.user_id = u.user_id
noMoreMessages.value = false
// scroll after messages rendered
scrollToBottom()
getMessages()
}
}
//
const getMessages = () => {
if (loadingMore.value || noMoreMessages.value) return
loadingMore.value = true
get_messages({ ...msgQuery }).then(res => {
if (res.list && res.list.length > 0) {
//
const newMessages = res.list.map(item => {
item.content = JSON.parse(item.content)
return item
})
if (msgQuery.page === 1) {
//
messages.value = newMessages
} else {
//
messages.value = [...newMessages, ...messages.value]
}
//
if (res.list.length < msgQuery.page_size) {
noMoreMessages.value = true
}
} else {
//
noMoreMessages.value = true
}
loadingMore.value = false
}).catch(() => {
loadingMore.value = false
})
}
//
const handleScroll = (e) => {
const { scrollTop } = e.target
//
if (scrollTop <= 0 && !loadingMore.value && !noMoreMessages.value) {
loadMoreMessages()
}
}
//
const loadMoreMessages = () => {
if (msgQuery.user_id) {
msgQuery.page++
getMessages()
}
}
const send = () => {
if (!activeUser.value.user_id) {
ElMessage({ type: 'warning', message: '请先选择一个联系人' })
return
}
if (!draft.value) return
const content = draft.value
const id = Date.now() + Math.random()
const msg = {
_id: id,
_sending: true,
_failed: false,
sender_name: '机器人',
msg_type: 0,
content: { messages: content },
send_time: '刚刚',
"sender_id": "888888",
"sender_name": "平台",
"msg_type": 1
}
messages.value.push(msg)
draft.value = ''
// ensure scroll
scrollToBottom()
// send to server
send_message({
app_id: route.query.app_id,
content: JSON.stringify({
messages: content
}),
msg_type: 1,
to_user_id: activeUser.value.user_id
}).then(() => {
msg._sending = false
msg._failed = false
}).catch(() => {
msg._sending = false
msg._failed = true
ElMessage({ type: 'error', message: '消息发送失败' })
})
}
const handleInputKeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const triggerImageInput = () => {
imageInput.value && imageInput.value.click()
}
const handleImageChange = (e) => {
const file = e.target.files[0]
if (!file) return
// URL
const url = URL.createObjectURL(file)
const id = Date.now() + Math.random()
const msg = {
_id: id,
_sending: true,
_failed: false,
sender_id: '888888',
sender_name: '平台',
msg_type: 2,
content: { messages: url },
send_time: '刚刚'
}
messages.value.push(msg)
// input
e.target.value = ''
//
setTimeout(() => scrollToBottom(), 100)
// file to server using FormData
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.user_id)
uploadFile(form, import.meta.env.VITE_APP_BASE_API + 'admin/upload/image').then((resp) => {
send_message({
app_id: route.query.app_id,
content: JSON.stringify({ messages: import.meta.env.VITE_APP_BASE_API + resp.preview_image_url }),
msg_type: 2,
to_user_id: activeUser.value.user_id
}).then(() => {
msg._sending = false
msg._failed = false
}).catch(() => {
msg._sending = false
msg._failed = true
ElMessage({ type: 'error', message: '图片发送失败' })
})
})
}
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.user_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.user_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) {
// ignore
}
}
// messages
// watcher
const onEmojiSelect = (emoji) => {
if (draft.value) {
draft.value += emoji
} else {
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) {
// append new items
userList.value = userList.value.concat(res.list || [])
} else {
userList.value = res.list || []
}
// if first load and no active user, select first
if (!append && userList.value.length && !activeUser.value.user_id) {
onSelectUser(userList.value[0].user_id)
}
})
}
// UserCard load
const onLoadUserList = async () => {
params.page = params.page + 1
await getUsers(true)
// DOM
await nextTick()
const el = document.querySelector('.left-panel .infinite-list')
if (el) {
//
el.scrollTop = el.scrollHeight
}
}
const sendMessage = async (message) => {
send_message(message).then((res) => {
}).catch((err) => {
ElMessage({ type: 'error', message: '消息发送失败' })
})
}
onMounted(() => {
getUsers()
})
</script>
<style scoped lang="scss">
.chat-page {
display: flex;
height: calc(100vh - 60px);
}
: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
}
.chat-footer {
height: 96px;
padding: 5px 5px;
border-top: 1px solid var(--el-border-color);
}
.chat-footer .actions {
display: flex;
align-items: center
}
</style>

View File

@ -23,11 +23,21 @@
@change="handleImageChange" />
<V3Emoji @click-emoji="onEmojiSelect" :recent="true"></V3Emoji>
</div>
<el-input type="textarea" v-model="draft" placeholder="输入消息..." rows="2" style="flex:1"
<el-input type="textarea" v-model="draft" placeholder="输入消息..." :rows="2" style="flex:1"
@keydown="handleInputKeydown" />
</div>
<!-- 添加发送按钮 -->
<!-- <el-button type="primary" @click="send">发送</el-button> -->
<!-- 发送按钮 -->
<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>
@ -80,6 +90,9 @@ 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) => {
@ -118,6 +131,83 @@ const onSelectUser = async (id) => {
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
}
}
// IDID
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)
// 20202030
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
@ -126,7 +216,12 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
if (isHistory && noMoreMessages.value) return
if (!msgQuery.user_id) return
loadingMore.value = true
// 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
@ -138,10 +233,18 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
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 => {
try { item.content = JSON.parse(item.content) } catch (e) { /* keep original */ }
// ChatRecord
try {
item.content = JSON.parse(item.content)
} catch (e) {
//
item.content = { messages: item.content }
}
return item
})
@ -151,11 +254,12 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
const prevScrollHeight = el ? el.scrollHeight : 0
if (queryPage === 1) {
//
messages.value = newMessages
//
messages.value = sortMessagesByTime([...newMessages])
} else {
// prepend
messages.value = [...newMessages, ...messages.value]
// prepend
const allMessages = [...newMessages, ...messages.value]
messages.value = sortMessagesByTime(allMessages)
// DOM
await nextTick()
if (el) {
@ -164,10 +268,113 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
}
}
} else {
//
const existIds = new Set(messages.value.map(m => m.id || m._id))
const unique = newMessages.filter(m => !(existIds.has(m.id || m._id)))
if (unique.length) messages.value = [...messages.value, ...unique]
//
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
@ -188,7 +395,12 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
} catch (err) {
//
} finally {
loadingMore.value = false
// loadingMore
const isPolling = !isHistory && pageOverride === 1
if (!isPolling) {
loadingMore.value = false
}
// loading finally
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
isLoadingMessages.value = false
@ -244,32 +456,56 @@ const loadMoreMessages = () => {
}
//
const send = () => {
if (!activeUser.value.sender_id) {
ElMessage({ type: 'warning', message: '请先选择一个联系人' })
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
}
if (!draft.value) return
const content = draft.value
const id = Date.now() + Math.random()
//
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: id,
_id: tempId,
_sending: true,
_failed: false,
sender_name: '机器人',
msg_type: 0,
_isTemp: true, //
sender_id: '888888',
sender_name: '平台',
msg_type: 1,
content: { messages: content },
send_time: '刚刚',
"sender_id": "888888",
"sender_name": "平台",
"msg_type": 1
_timestamp: now,
_tempContent: content //
}
messages.value.push(msg)
draft.value = ''
//
scrollToBottom()
//
setTimeout(() => scrollToBottom(), 100)
isAutoScroll.value = true
//
@ -280,13 +516,23 @@ const send = () => {
}),
msg_type: 1,
to_user_id: activeUser.value.sender_id
}).then(() => {
}).then((response) => {
msg._sending = false
msg._failed = false
}).catch(() => {
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
})
}
@ -294,7 +540,10 @@ const send = () => {
const handleInputKeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
//
setTimeout(() => {
send()
}, 50)
}
}
@ -307,18 +556,36 @@ 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 id = Date.now() + Math.random()
const tempId = `temp_img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const now = Date.now()
const msg = {
_id: id,
_id: tempId,
_sending: true,
_failed: false,
_isTemp: true, //
sender_id: '888888',
sender_name: '平台',
msg_type: 2,
content: { messages: url },
send_time: '刚刚'
send_time: '刚刚',
_timestamp: now,
_tempImageFile: file //
}
messages.value.push(msg)
e.target.value = ''
@ -334,19 +601,48 @@ const handleImageChange = (e) => {
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,
content: JSON.stringify({ messages: import.meta.env.VITE_APP_BASE_API + resp.preview_image_url }),
msg_type: 2,
content: JSON.stringify({ messages: imageUrl }),
to_user_id: activeUser.value.sender_id
}).then(() => {
msg._sending = false
msg._failed = false
}).catch(() => {
//
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
ElMessage({ type: 'error', message: '图片发送失败' })
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)
}
})
}
@ -446,10 +742,85 @@ const startMessageTimer = () => {
// 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 {
// JSONcontent
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
}
// JSON30
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)
@ -467,14 +838,20 @@ const restartMessageTimer = () => {
//
onMounted(() => {
getUsers()
//
startUserListTimer()
//
window.addEventListener('beforeunload', stopMessageTimer)
window.addEventListener('beforeunload', () => {
stopMessageTimer()
stopUserListTimer()
})
})
//
onUnmounted(() => {
stopMessageTimer()
stopUserListTimer()
})
//

View File

@ -88,7 +88,15 @@
<el-input v-model="ruleForm.nickname" placeholder="请输入客服昵称" size="large" />
</el-form-item>
<el-form-item label="客服密码" prop="password">
<el-input v-model="ruleForm.password" placeholder="请输入客服密码" size="large" />
<el-input
v-model="ruleForm.password"
type="password"
:placeholder="isEdit ? '留空则不修改密码' : '请输入客服密码'"
size="large"
/>
<div v-if="isEdit" style="font-size: 12px; color: #999; margin-top: 4px;">
留空则不修改密码
</div>
</el-form-item>
<el-form-item label="客服手机号">
<el-input v-model="ruleForm.mobile" placeholder="请输入客服手机号" size="large" />
@ -162,6 +170,7 @@ import { appList } from '@/api/miniProgram'
import { ElNotification } from 'element-plus';
import robotStore from '@/store/modules/robotStore'
import { useRouter } from 'vue-router';
import md5 from 'js-md5'
const userRobotStore = robotStore()
const router = useRouter()
const robotCards = ref([])
@ -172,35 +181,52 @@ const ruleForm = ref({})
const isEdit = ref(false)
const newRobotId = ref('')
let robotCopy = {}
const rules = ref({
username: [
{
//
const getRules = () => {
const baseRules = {
username: [
{
required: true,
message: '请输入客服名称',
trigger: 'blur',
}
],
nickname: [{
required: true,
message: '请输入客服名称',
message: '请输入客服称',
trigger: 'blur',
}
],
nickname: [{
required: true,
message: '请输入客服昵称',
trigger: 'blur',
}],
mobile: [{
required: true,
message: '请输入客服手机号',
trigger: 'blur',
}],
avatar: [{
required: true,
message: '请上传客服头像',
trigger: 'change',
}],
password: [{
required: true,
message: '请输入客服密码',
trigger: 'blur',
}]
})
}],
mobile: [{
required: true,
message: '请输入客服手机号',
trigger: 'blur',
}],
avatar: [{
required: true,
message: '请上传客服头像',
trigger: 'change',
}],
}
//
if (!isEdit.value) {
baseRules.password = [{
required: true,
message: '请输入客服密码',
trigger: 'blur',
}]
} else {
baseRules.password = [{
required: false,
message: '请输入客服密码',
trigger: 'blur',
}]
}
return baseRules
}
const rules = ref(getRules())
const query = reactive({
username: '',
nikename: '',
@ -234,20 +260,21 @@ const handleAdd = () => {
return
}
isEdit.value = false
rules.value = getRules() //
dialogVisible.value = true
nextTick(() => {
ruleForm.value = {}
ruleFormRef.value.resetFields()
})
}
let editItem = {}
let editIndex = 0
const handleEdit = (item) => {
isEdit.value = true
// editItem = item
// editIndex = index
rules.value = getRules() //
ruleForm.value = JSON.parse(JSON.stringify(item))
//
ruleForm.value.password = ''
dialogVisible.value = true
}
const mhtLoading = ref(false)
@ -257,16 +284,20 @@ const submitRobot = async () => {
if (valid) {
if (isEdit.value) {
try {
const res = await editRobot(ruleForm.value.id, {
//
const editData = {
username: ruleForm.value.username,
nickname: ruleForm.value.nickname,
avatar: ruleForm.value.avatar,
mobile: ruleForm.value.mobile,
})
// editItem = ruleForm.value
// robotCards.value[editIndex] = editItem
// getTable()
}
//
if (ruleForm.value.password && ruleForm.value.password.trim() !== '') {
editData.password = md5(ruleForm.value.password)
}
const res = await editRobot(ruleForm.value.id, editData)
dialogVisible.value = false
mhtLoading.value = false
getTable()
@ -279,15 +310,18 @@ const submitRobot = async () => {
}
} else {
try {
const res = await addRobot(ruleForm.value)
// MD5
const createData = {
...ruleForm.value,
password: md5(ruleForm.value.password)
}
const res = await addRobot(createData)
robotCards.value.push({
...ruleForm.value,
})
// getTable()
dialogVisible.value = false
mhtLoading.value = false
getTable()
ElNotification({
type: 'success',
@ -296,7 +330,6 @@ const submitRobot = async () => {
} catch {
mhtLoading.value = false
}
}
} else {