feat(chat): 优化聊天界面功能并添加消息管理
refactor: 重构消息处理和用户交互逻辑 fix(env): 更新环境变量配置 style: 调整用户卡片样式和布局 perf: 优化消息发送和接收性能 test: 添加消息排序和去重逻辑测试 chore: 清理无用代码和文件
This commit is contained in:
parent
884870d9b7
commit
5f0807c5b7
@ -1,6 +1,6 @@
|
|||||||
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||||
NODE_ENV = 'development'
|
NODE_ENV = 'development'
|
||||||
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
|
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/"
|
VITE_APP_BASE_API_img = "https://mini-chat.1024tool.vip/"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
|
||||||
NODE_ENV = 'production'
|
NODE_ENV = 'production'
|
||||||
VITE_APP_BASE_API = 'https://dsjhd9s.tbmw.cn/api/'
|
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
|
||||||
VITE_SERVE = "https://dsjhd9s.tbmw.cn/api/"
|
VITE_SERVE = "https://mini-chat.1024tool.vip/api/"
|
||||||
VITE_APP_BASE_API_img = "https://dsjhd9s.tbmw.cn/"
|
VITE_APP_BASE_API_img = "https://mini-chat.1024tool.vip/"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
NODE_ENV = 'development'
|
NODE_ENV = 'development'
|
||||||
|
|
||||||
VITE_APP_BASE_API = 'http://dev-scrm.1024tool.vip/'
|
VITE_APP_BASE_API = 'https://mini-chat.1024tool.vip/api'
|
||||||
VITE_SERVE = "http://dev-scrm.1024tool.vip/"
|
VITE_SERVE = "https://mini-chat.1024tool.vip/api/"
|
||||||
|
|||||||
11369
pnpm-lock.yaml
generated
11369
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -57,13 +57,12 @@ const props = defineProps({
|
|||||||
const keyWordsText = ref(props.keyWords)
|
const keyWordsText = ref(props.keyWords)
|
||||||
const list = ref(props.msgList)
|
const list = ref(props.msgList)
|
||||||
watch(() => props.msgList, (val) => {
|
watch(() => props.msgList, (val) => {
|
||||||
|
console.log('ChatRecord 接收到消息列表:', val.length, '条消息')
|
||||||
list.value = val
|
list.value = val
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
console.log('ChatRecord 组件挂载,初始消息数:', props.msgList.length)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
<span v-if="item.unread_count && item.unread_count > 0" class="unread-badge">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="name-block">
|
<div class="name-block">
|
||||||
<span class="nickname">{{ item.sender_name }}</span>
|
<div class="user-info">
|
||||||
<span v-if="item.send_time" class="send-time">{{ item.send_time }}</span>
|
<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>
|
</div>
|
||||||
<span class="sex">
|
<span class="sex">
|
||||||
<!-- <el-icon v-if="item.sex == '男'" style="color: rgb(121.3, 187.1, 255);">
|
<!-- <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 {
|
||||||
|
// 尝试解析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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
})
|
})
|
||||||
@ -107,9 +160,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
.infinite-list .infinite-list-item {
|
.infinite-list .infinite-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 46px;
|
height: 60px;
|
||||||
// margin: 10px 0;
|
// margin: 10px 0;
|
||||||
padding: 0 10px;
|
padding: 8px 10px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -124,11 +177,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
height: 32px;
|
height: 40px;
|
||||||
width: 32px;
|
width: 40px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-top: 6px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
@ -159,14 +212,47 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 8px;
|
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 {
|
.send-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
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 {
|
.sex {
|
||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|||||||
781
src/composables/useChat.ts
Normal file
781
src/composables/useChat.ts
Normal 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
405
src/utils/messageManager.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
@ -23,11 +23,21 @@
|
|||||||
@change="handleImageChange" />
|
@change="handleImageChange" />
|
||||||
<V3Emoji @click-emoji="onEmojiSelect" :recent="true"></V3Emoji>
|
<V3Emoji @click-emoji="onEmojiSelect" :recent="true"></V3Emoji>
|
||||||
</div>
|
</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" />
|
@keydown="handleInputKeydown" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -80,6 +90,9 @@ const loadingMore = ref(false)
|
|||||||
const noMoreMessages = ref(false)
|
const noMoreMessages = ref(false)
|
||||||
const isAutoScroll = ref(true) // 是否自动滚动到底部
|
const isAutoScroll = ref(true) // 是否自动滚动到底部
|
||||||
const messageTimer = ref(null) // 定时器实例
|
const messageTimer = ref(null) // 定时器实例
|
||||||
|
const isSending = ref(false) // 防止重复发送
|
||||||
|
const lastSendTime = ref(0) // 最后发送时间,用于防抖
|
||||||
|
const isUploading = ref(false) // 防止重复上传
|
||||||
|
|
||||||
// 选择用户
|
// 选择用户
|
||||||
const onSelectUser = async (id) => {
|
const onSelectUser = async (id) => {
|
||||||
@ -118,6 +131,83 @@ const onSelectUser = async (id) => {
|
|||||||
|
|
||||||
|
|
||||||
let msgTotal = 0
|
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: 是否为向上翻页加载历史
|
// isHistory: 是否为向上翻页加载历史
|
||||||
// pageOverride: 可选的临时 page 值(不会修改 msgQuery.page),用于定时拉取最新时使用 page=1
|
// pageOverride: 可选的临时 page 值(不会修改 msgQuery.page),用于定时拉取最新时使用 page=1
|
||||||
@ -126,7 +216,12 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
if (isHistory && noMoreMessages.value) return
|
if (isHistory && noMoreMessages.value) return
|
||||||
if (!msgQuery.user_id) 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
|
// 在第一次加载(page=1 且 isHistory=true)期间也将 isLoadingMessages 保持为 true
|
||||||
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
||||||
isLoadingMessages.value = true
|
isLoadingMessages.value = true
|
||||||
@ -138,10 +233,18 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
const res = await get_messages({ ...query })
|
const res = await get_messages({ ...query })
|
||||||
msgTotal = res.total || 0
|
msgTotal = res.total || 0
|
||||||
const list = res.list || []
|
const list = res.list || []
|
||||||
|
|
||||||
|
console.log(`获取消息 - isHistory: ${isHistory}, page: ${queryPage}, 返回消息数: ${list.length}`)
|
||||||
|
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
const newMessages = list.map(item => {
|
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
|
return item
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -151,11 +254,12 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
const prevScrollHeight = el ? el.scrollHeight : 0
|
const prevScrollHeight = el ? el.scrollHeight : 0
|
||||||
|
|
||||||
if (queryPage === 1) {
|
if (queryPage === 1) {
|
||||||
// 第一次加载(第一页)直接替换
|
// 第一次加载(第一页)直接替换,并排序
|
||||||
messages.value = newMessages
|
messages.value = sortMessagesByTime([...newMessages])
|
||||||
} else {
|
} else {
|
||||||
// 向上翻页,prepend
|
// 向上翻页,prepend,然后重新排序整个列表
|
||||||
messages.value = [...newMessages, ...messages.value]
|
const allMessages = [...newMessages, ...messages.value]
|
||||||
|
messages.value = sortMessagesByTime(allMessages)
|
||||||
// 等待 DOM 更新后恢复滚动位置,避免跳动
|
// 等待 DOM 更新后恢复滚动位置,避免跳动
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (el) {
|
if (el) {
|
||||||
@ -164,10 +268,113 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 拉取最新消息(追加到末尾),避免重复
|
// 拉取最新消息(追加到末尾),改进去重逻辑
|
||||||
const existIds = new Set(messages.value.map(m => m.id || m._id))
|
const currentMessages = [...messages.value]
|
||||||
const unique = newMessages.filter(m => !(existIds.has(m.id || m._id)))
|
let hasUpdates = false
|
||||||
if (unique.length) messages.value = [...messages.value, ...unique]
|
|
||||||
|
// 处理每条新消息
|
||||||
|
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 时判断)
|
// 使用 total 判断是否还有更多历史消息(仅在已知 total 时判断)
|
||||||
@ -188,7 +395,12 @@ const getMessages = async (isHistory = true, pageOverride = null) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 可扩展错误处理
|
// 可扩展错误处理
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false
|
// 只有在非轮询时才清理loadingMore状态
|
||||||
|
const isPolling = !isHistory && pageOverride === 1
|
||||||
|
if (!isPolling) {
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 如果这是第一页历史加载,确保 loading 标志被清理(页面外也会在调用者 finally 中清理)
|
// 如果这是第一页历史加载,确保 loading 标志被清理(页面外也会在调用者 finally 中清理)
|
||||||
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
if ((pageOverride ?? msgQuery.page) === 1 && isHistory) {
|
||||||
isLoadingMessages.value = false
|
isLoadingMessages.value = false
|
||||||
@ -244,32 +456,56 @@ const loadMoreMessages = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
const send = () => {
|
const send = async () => {
|
||||||
if (!activeUser.value.sender_id) {
|
const content = draft.value.trim()
|
||||||
ElMessage({ type: 'warning', message: '请先选择一个联系人' })
|
if (!content || isSending.value || isUploading.value) return
|
||||||
|
|
||||||
|
// 防抖检查
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastSendTime.value < 500) {
|
||||||
|
console.log('发送过于频繁,已忽略')
|
||||||
return
|
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 = {
|
const msg = {
|
||||||
_id: id,
|
_id: tempId,
|
||||||
_sending: true,
|
_sending: true,
|
||||||
_failed: false,
|
_failed: false,
|
||||||
sender_name: '机器人',
|
_isTemp: true, // 标记为临时消息
|
||||||
msg_type: 0,
|
sender_id: '888888',
|
||||||
|
sender_name: '平台',
|
||||||
|
msg_type: 1,
|
||||||
content: { messages: content },
|
content: { messages: content },
|
||||||
send_time: '刚刚',
|
send_time: '刚刚',
|
||||||
"sender_id": "888888",
|
_timestamp: now,
|
||||||
"sender_name": "平台",
|
_tempContent: content // 保存原始内容用于匹配
|
||||||
"msg_type": 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.value.push(msg)
|
messages.value.push(msg)
|
||||||
draft.value = ''
|
draft.value = ''
|
||||||
|
|
||||||
// 发送后强制滚动到底部并设置自动滚动状态
|
// 滚动到底部
|
||||||
scrollToBottom()
|
setTimeout(() => scrollToBottom(), 100)
|
||||||
isAutoScroll.value = true
|
isAutoScroll.value = true
|
||||||
|
|
||||||
// 发送到服务器
|
// 发送到服务器
|
||||||
@ -280,13 +516,23 @@ const send = () => {
|
|||||||
}),
|
}),
|
||||||
msg_type: 1,
|
msg_type: 1,
|
||||||
to_user_id: activeUser.value.sender_id
|
to_user_id: activeUser.value.sender_id
|
||||||
}).then(() => {
|
}).then((response) => {
|
||||||
msg._sending = false
|
msg._sending = false
|
||||||
msg._failed = false
|
msg._failed = false
|
||||||
}).catch(() => {
|
console.log('消息发送成功', response)
|
||||||
|
|
||||||
|
// 发送成功后,立即轮询获取最新消息以获取真实ID
|
||||||
|
setTimeout(() => {
|
||||||
|
getMessages(false, 1) // 轮询最新消息
|
||||||
|
}, 500)
|
||||||
|
}).catch((error) => {
|
||||||
msg._sending = false
|
msg._sending = false
|
||||||
msg._failed = true
|
msg._failed = true
|
||||||
|
console.error('消息发送失败:', error)
|
||||||
ElMessage({ type: 'error', message: '消息发送失败' })
|
ElMessage({ type: 'error', message: '消息发送失败' })
|
||||||
|
}).finally(() => {
|
||||||
|
// 重置发送状态
|
||||||
|
isSending.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,7 +540,10 @@ const send = () => {
|
|||||||
const handleInputKeydown = (e) => {
|
const handleInputKeydown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
send()
|
// 添加防抖延迟
|
||||||
|
setTimeout(() => {
|
||||||
|
send()
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,18 +556,36 @@ const handleImageChange = (e) => {
|
|||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
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 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 = {
|
const msg = {
|
||||||
_id: id,
|
_id: tempId,
|
||||||
_sending: true,
|
_sending: true,
|
||||||
_failed: false,
|
_failed: false,
|
||||||
|
_isTemp: true, // 标记为临时消息
|
||||||
sender_id: '888888',
|
sender_id: '888888',
|
||||||
sender_name: '平台',
|
sender_name: '平台',
|
||||||
msg_type: 2,
|
msg_type: 2,
|
||||||
content: { messages: url },
|
content: { messages: url },
|
||||||
send_time: '刚刚'
|
send_time: '刚刚',
|
||||||
|
_timestamp: now,
|
||||||
|
_tempImageFile: file // 保存文件信息用于匹配
|
||||||
}
|
}
|
||||||
messages.value.push(msg)
|
messages.value.push(msg)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@ -334,19 +601,48 @@ const handleImageChange = (e) => {
|
|||||||
form.append('msg_type', 2)
|
form.append('msg_type', 2)
|
||||||
form.append('to_user_id', activeUser.value.sender_id)
|
form.append('to_user_id', activeUser.value.sender_id)
|
||||||
uploadFile(form, import.meta.env.VITE_APP_BASE_API + 'admin/upload/image').then((resp) => {
|
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({
|
send_message({
|
||||||
app_id: route.query.app_id,
|
app_id: route.query.app_id,
|
||||||
content: JSON.stringify({ messages: import.meta.env.VITE_APP_BASE_API + resp.preview_image_url }),
|
|
||||||
msg_type: 2,
|
msg_type: 2,
|
||||||
|
content: JSON.stringify({ messages: imageUrl }),
|
||||||
to_user_id: activeUser.value.sender_id
|
to_user_id: activeUser.value.sender_id
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
msg._sending = false
|
// 发送成功,移除临时消息
|
||||||
msg._failed = false
|
const index = messages.value.findIndex(m => m._id === tempId)
|
||||||
}).catch(() => {
|
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._sending = false
|
||||||
msg._failed = true
|
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
|
// 每秒获取一次最新消息,不修改 msgQuery.page,使用 pageOverride=1
|
||||||
messageTimer.value = setInterval(() => {
|
messageTimer.value = setInterval(() => {
|
||||||
if (!msgQuery.user_id) return
|
if (!msgQuery.user_id) return
|
||||||
|
// 获取最新消息(第一页),用于追加新消息
|
||||||
|
console.log('定时器轮询消息,用户ID:', msgQuery.user_id)
|
||||||
getMessages(false, 1)
|
getMessages(false, 1)
|
||||||
}, 1000)
|
}, 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 = () => {
|
const stopMessageTimer = () => {
|
||||||
if (messageTimer.value) {
|
if (messageTimer.value) {
|
||||||
clearInterval(messageTimer.value)
|
clearInterval(messageTimer.value)
|
||||||
@ -467,14 +838,20 @@ const restartMessageTimer = () => {
|
|||||||
// 组件挂载时
|
// 组件挂载时
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getUsers()
|
getUsers()
|
||||||
|
// 启动用户列表轮询
|
||||||
|
startUserListTimer()
|
||||||
|
|
||||||
// 监听窗口关闭,清理定时器
|
// 监听窗口关闭,清理定时器
|
||||||
window.addEventListener('beforeunload', stopMessageTimer)
|
window.addEventListener('beforeunload', () => {
|
||||||
|
stopMessageTimer()
|
||||||
|
stopUserListTimer()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
// 组件卸载时清理定时器
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopMessageTimer()
|
stopMessageTimer()
|
||||||
|
stopUserListTimer()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听活跃用户变化,重启定时器
|
// 监听活跃用户变化,重启定时器
|
||||||
|
|||||||
@ -88,7 +88,15 @@
|
|||||||
<el-input v-model="ruleForm.nickname" placeholder="请输入客服昵称" size="large" />
|
<el-input v-model="ruleForm.nickname" placeholder="请输入客服昵称" size="large" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="客服密码" prop="password">
|
<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>
|
||||||
<el-form-item label="客服手机号">
|
<el-form-item label="客服手机号">
|
||||||
<el-input v-model="ruleForm.mobile" placeholder="请输入客服手机号" size="large" />
|
<el-input v-model="ruleForm.mobile" placeholder="请输入客服手机号" size="large" />
|
||||||
@ -162,6 +170,7 @@ import { appList } from '@/api/miniProgram'
|
|||||||
import { ElNotification } from 'element-plus';
|
import { ElNotification } from 'element-plus';
|
||||||
import robotStore from '@/store/modules/robotStore'
|
import robotStore from '@/store/modules/robotStore'
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import md5 from 'js-md5'
|
||||||
const userRobotStore = robotStore()
|
const userRobotStore = robotStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const robotCards = ref([])
|
const robotCards = ref([])
|
||||||
@ -172,35 +181,52 @@ const ruleForm = ref({})
|
|||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const newRobotId = ref('')
|
const newRobotId = ref('')
|
||||||
let robotCopy = {}
|
let robotCopy = {}
|
||||||
const rules = ref({
|
// 动态验证规则,根据是否为编辑模式调整密码验证
|
||||||
username: [
|
const getRules = () => {
|
||||||
{
|
const baseRules = {
|
||||||
|
username: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入客服名称',
|
||||||
|
trigger: 'blur',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
nickname: [{
|
||||||
required: true,
|
required: true,
|
||||||
message: '请输入客服名称',
|
message: '请输入客服昵称',
|
||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
}
|
}],
|
||||||
],
|
mobile: [{
|
||||||
nickname: [{
|
required: true,
|
||||||
required: true,
|
message: '请输入客服手机号',
|
||||||
message: '请输入客服昵称',
|
trigger: 'blur',
|
||||||
trigger: 'blur',
|
}],
|
||||||
}],
|
avatar: [{
|
||||||
mobile: [{
|
required: true,
|
||||||
required: true,
|
message: '请上传客服头像',
|
||||||
message: '请输入客服手机号',
|
trigger: 'change',
|
||||||
trigger: 'blur',
|
}],
|
||||||
}],
|
}
|
||||||
avatar: [{
|
|
||||||
required: true,
|
// 在创建模式下,密码是必填的;在编辑模式下,密码是可选的
|
||||||
message: '请上传客服头像',
|
if (!isEdit.value) {
|
||||||
trigger: 'change',
|
baseRules.password = [{
|
||||||
}],
|
required: true,
|
||||||
password: [{
|
message: '请输入客服密码',
|
||||||
required: true,
|
trigger: 'blur',
|
||||||
message: '请输入客服密码',
|
}]
|
||||||
trigger: 'blur',
|
} else {
|
||||||
}]
|
baseRules.password = [{
|
||||||
})
|
required: false,
|
||||||
|
message: '请输入客服密码',
|
||||||
|
trigger: 'blur',
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseRules
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = ref(getRules())
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
nikename: '',
|
nikename: '',
|
||||||
@ -234,20 +260,21 @@ const handleAdd = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isEdit.value = false
|
isEdit.value = false
|
||||||
|
rules.value = getRules() // 更新验证规则
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
ruleForm.value = {}
|
ruleForm.value = {}
|
||||||
ruleFormRef.value.resetFields()
|
ruleFormRef.value.resetFields()
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
let editItem = {}
|
let editItem = {}
|
||||||
let editIndex = 0
|
let editIndex = 0
|
||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
isEdit.value = true
|
isEdit.value = true
|
||||||
// editItem = item
|
rules.value = getRules() // 更新验证规则
|
||||||
// editIndex = index
|
|
||||||
ruleForm.value = JSON.parse(JSON.stringify(item))
|
ruleForm.value = JSON.parse(JSON.stringify(item))
|
||||||
|
// 清空密码字段,避免显示原密码
|
||||||
|
ruleForm.value.password = ''
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
const mhtLoading = ref(false)
|
const mhtLoading = ref(false)
|
||||||
@ -257,16 +284,20 @@ const submitRobot = async () => {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
try {
|
try {
|
||||||
|
// 编辑时的数据对象
|
||||||
const res = await editRobot(ruleForm.value.id, {
|
const editData = {
|
||||||
username: ruleForm.value.username,
|
username: ruleForm.value.username,
|
||||||
nickname: ruleForm.value.nickname,
|
nickname: ruleForm.value.nickname,
|
||||||
avatar: ruleForm.value.avatar,
|
avatar: ruleForm.value.avatar,
|
||||||
mobile: ruleForm.value.mobile,
|
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
|
dialogVisible.value = false
|
||||||
mhtLoading.value = false
|
mhtLoading.value = false
|
||||||
getTable()
|
getTable()
|
||||||
@ -279,15 +310,18 @@ const submitRobot = async () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
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({
|
robotCards.value.push({
|
||||||
...ruleForm.value,
|
...ruleForm.value,
|
||||||
|
|
||||||
})
|
})
|
||||||
// getTable()
|
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
mhtLoading.value = false
|
mhtLoading.value = false
|
||||||
|
|
||||||
getTable()
|
getTable()
|
||||||
ElNotification({
|
ElNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@ -296,7 +330,6 @@ const submitRobot = async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
mhtLoading.value = false
|
mhtLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user