import { reactive, computed, nextTick } from 'vue' import { ServeTalkRecords } from '@/api/chat' import { useDialogueStore } from '@/store' import { ITalkRecord } from '@/types/chat' import { formatTalkRecord } from '@/utils/talk' import { addClass, removeClass, scrollToBottom, isScrollAtBottom } from '@/utils/dom' interface Params { receiver_id: number talk_type: number limit: number } interface SpecialParams extends Params { msg_id?: string cursor?: number direction?: 'up' | 'down' sort_sequence?: string type?: 'loadMore' } interface LoadOptions { specifiedMsg?: SpecialParams middleMsgCreatedAt?: string } export const useTalkRecord = (uid: number) => { const dialogueStore = useDialogueStore() const records = computed((): ITalkRecord[] => dialogueStore.records) const location = reactive({ msgid: '', num: 0 }) const loadConfig = reactive({ receiver_id: 0, talk_type: 0, status: 0, cursor: 0, specialParams: undefined as SpecialParams | undefined, isLocatingMessage: false, isLoadingMore: false, lastLoadMoreTime: 0, targetMessagePosition: 0 }) // 重置 loadConfig const resetLoadConfig = () => { loadConfig.receiver_id = 0 loadConfig.talk_type = 0 loadConfig.status = 0 loadConfig.cursor = 0 loadConfig.specialParams = undefined } const onJumpMessage = (msgid: string) => { const element = document.getElementById(msgid) if (!element) { if (location.msgid == '') { location.msgid = msgid location.num = 3 } else { location.num-- if (location.num === 0) { location.msgid = '' location.num = 0 window['$message'].info('仅支持查看最近300条的记录') return } } const el = document.getElementById('imChatPanel') return el?.scrollTo({ top: 0, behavior: 'smooth' }) } location.msgid = '' location.num = 0 // 使用更精确的滚动定位方式 const el = document.getElementById('imChatPanel') if (el && element) { // 计算目标元素相对于容器的偏移量 const targetOffsetTop = element.offsetTop const containerHeight = el.clientHeight const targetHeight = element.offsetHeight // 计算理想的滚动位置:让目标元素居中显示 let scrollTo = targetOffsetTop - containerHeight / 2 + targetHeight / 2 // 边界检查:确保目标元素完全在可视区域内 const minScrollTop = 0 const maxScrollTop = el.scrollHeight - containerHeight // 如果计算出的位置超出边界,调整到边界内 if (scrollTo < minScrollTop) { scrollTo = minScrollTop } else if (scrollTo > maxScrollTop) { scrollTo = maxScrollTop } // 执行滚动 el.scrollTo({ top: scrollTo, behavior: 'smooth' }) } else { // 降级方案:使用 scrollIntoView element?.scrollIntoView({ behavior: 'smooth', block: 'center' }) } addClass(element, 'border') setTimeout(() => { element && removeClass(element, 'border') }, 3000) } // 加载数据列表 const load = async (params: Params) => { const request = { talk_type: params.talk_type, receiver_id: params.receiver_id, cursor: loadConfig.cursor, limit: 30 } // 如果不是从本地数据库加载的,则设置加载状态为0(加载中) if (loadConfig.status !== 2 && loadConfig.status !== 3) { loadConfig.status = 0 } let scrollHeight = 0 const el = document.getElementById('imChatPanel') if (el) { scrollHeight = el.scrollHeight } const { data, code } = await ServeTalkRecords(request) if (code != 200) { return (loadConfig.status = (loadConfig.status === 2 || loadConfig.status === 3) ? loadConfig.status : 1) // 如果已经从本地加载了数据,保持原状态 } // 防止对话切换过快,数据渲染错误 if ( request.talk_type != loadConfig.talk_type || request.receiver_id != loadConfig.receiver_id ) { return (location.msgid = '') } const items = (data.items || []).map((item: ITalkRecord) => formatTalkRecord(uid, item)) // 同步到本地数据库 try { const { batchAddOrUpdateMessages } = await import('@/utils/db') await batchAddOrUpdateMessages(data.items || [], params.talk_type, params.receiver_id, true, 'sequence') console.log('聊天记录已同步到本地数据库') } catch (error) { console.error('同步聊天记录到本地数据库失败:', error) } // 如果是从本地数据库加载的数据,且服务器返回的数据与本地数据相同,则不需要更新UI if ((loadConfig.status === 2 || loadConfig.status === 3) && request.cursor === 0) { try { // 获取最新的本地数据库消息进行比较 const { getMessages } = await import('@/utils/db') const localMessages = await getMessages( params.talk_type, uid, params.receiver_id, items.length || 30, // 获取与服务器返回数量相同的消息 0 // 从第一页开始 ) // 格式化本地消息,确保与服务器消息结构一致 const formattedLocalMessages = localMessages.map((item: ITalkRecord) => formatTalkRecord(uid, item)) // 改进比较逻辑:检查消息数量和所有消息的ID是否匹配 if (formattedLocalMessages.length === items.length && formattedLocalMessages.length > 0) { // 创建消息ID映射,用于快速查找 const serverMsgMap = new Map() items.forEach(item => serverMsgMap.set(item.msg_id, item)) // 检查每条本地消息是否与服务器消息匹配 const allMatch = formattedLocalMessages.every(localMsg => { const serverMsg = serverMsgMap.get(localMsg.msg_id) // 检查消息是否存在且关键状态是否一致(考虑撤回、已读等状态变化) return serverMsg && serverMsg.is_revoke === localMsg.is_revoke && serverMsg.is_read === localMsg.is_read && (serverMsg.send_status === localMsg.send_status || (!serverMsg.send_status && !localMsg.send_status)) && serverMsg.content === localMsg.content }) if (allMatch) { console.log('本地数据与服务器数据一致,无需更新UI') return } } // 数据不一致,需要更新UI console.log('本地数据与服务器数据不一致,更新UI') } catch (error) { console.error('比较本地数据和服务器数据时出错:', error) // 出错时默认更新UI } } if (request.cursor == 0) { // 判断是否是初次加载 dialogueStore.clearDialogueRecord() } dialogueStore.unshiftDialogueRecord(items.reverse()) loadConfig.status = items.length >= request.limit ? 1 : 2 loadConfig.cursor = data.cursor nextTick(() => { const el = document.getElementById('imChatPanel') if (el) { if (request.cursor == 0) { // el.scrollTop = el.scrollHeight // setTimeout(() => { // el.scrollTop = el.scrollHeight + 1000 // }, 500) console.log('滚动到底部') // 在初次加载完成后恢复上传任务 // 确保在所有聊天记录加载完成后再恢复上传任务 dialogueStore.restoreUploadTasks() scrollToBottom() } else { el.scrollTop = el.scrollHeight - scrollHeight } } if (location.msgid) { onJumpMessage(location.msgid) } }) } // 获取当前消息的最小 sequence const getMinSequence = () => { if (!records.value.length) return 0 return Math.min(...records.value.map((item) => item.sequence)) } // 获取当前消息的最大 sequence const getMaxSequence = () => { if (!records.value.length) return 0 return Math.max(...records.value.map((item) => item.sequence)) } // 从本地数据库加载聊天记录 const loadFromLocalDB = async (params: Params) => { try { // 导入 getMessages 函数 const { getMessages } = await import('@/utils/db') // 从本地数据库获取聊天记录 const localMessages = await getMessages( params.talk_type, uid, params.receiver_id, params.limit || 30, 0 // 从第一页开始 // 不传入 maxSequence 参数,获取最新的消息 ) // 如果有本地数据 if (localMessages && localMessages.length > 0) { // 清空现有记录 dialogueStore.clearDialogueRecord() // 格式化并添加记录 const formattedMessages = localMessages.map((item: ITalkRecord) => formatTalkRecord(uid, item)) dialogueStore.unshiftDialogueRecord(formattedMessages) // 设置加载状态为完成(3表示从本地数据库加载完成) loadConfig.status = 3 // 恢复上传任务 dialogueStore.restoreUploadTasks() // 滚动到底部 nextTick(() => { scrollToBottom() }) return true } return false } catch (error) { console.error('从本地数据库加载聊天记录失败:', error) return false } } /** * 加载数据主入口,支持指定消息定位模式 * @param params 原有参数 * @param options 可选,{ specifiedMsg } 指定消息对象 */ const onLoad = async (params: Params, options?: LoadOptions) => { if ( params.talk_type !== loadConfig.talk_type || params.receiver_id !== loadConfig.receiver_id ) { resetLoadConfig() } loadConfig.cursor = 0 loadConfig.receiver_id = params.receiver_id loadConfig.talk_type = params.talk_type // 新增:支持指定消息定位模式,参数以传入为准合并 if (options?.specifiedMsg?.cursor !== undefined) { loadConfig.specialParams = { ...options.specifiedMsg } // 记录特殊参数,供分页加载用 loadConfig.status = 0 // 复用主流程 loading 状态 // 以 params 为基础,合并 specifiedMsg 的所有字段(只要有就覆盖) const contextParams = { ...params, ...options.specifiedMsg } //msg_id是用来做定位的,不做参数,所以这里清空 contextParams.msg_id = '' ServeTalkRecords(contextParams).then(({ data, code }) => { console.log('data',data) if (code !== 200) { loadConfig.status = 2 return } // 记录当前滚动高度 const el = document.getElementById('imChatPanel') const scrollHeight = el?.scrollHeight || 0 if (contextParams.direction === 'down' && !contextParams.type) { dialogueStore.clearDialogueRecord() } const items = (data.items || []).map((item: ITalkRecord) => formatTalkRecord(uid, item)) if (contextParams.type && contextParams.type === 'loadMore') { dialogueStore.addDialogueRecordForLoadMore(items) } else { dialogueStore.unshiftDialogueRecord( contextParams.direction === 'down' ? items : items.reverse() ) } if ( contextParams.direction === 'up' || (contextParams.direction === 'down' && !contextParams.type) ) { loadConfig.status = items[0].sequence == 1 || data.length === 0 ? 2 : 1 } loadConfig.cursor = data.cursor // 使用 requestAnimationFrame 来确保在下一帧渲染前设置滚动位置 requestAnimationFrame(() => { const el = document.getElementById('imChatPanel') const msgId = options.specifiedMsg?.msg_id const target = msgId ? document.getElementById(msgId) : null if (el) { // 如果是向上加载更多,保持原有内容位置 if (contextParams.direction === 'up') { el.scrollTop = el.scrollHeight - scrollHeight } else if (contextParams.type && contextParams.type === 'loadMore') { // 如果是向下加载更多,保持目标消息在可视区域底部 // 使用可视区域高度来调整,而不是新内容的总高度 nextTick(() => { if (el) { el.scrollTop = scrollHeight - el.clientHeight } }) } else if (target && msgId) { // 只有在有目标元素且有 msg_id 时才执行定位逻辑 // 如果是定位到特定消息,计算并滚动到目标位置 // 使用 nextTick 确保 DOM 完全渲染后再计算位置 nextTick(() => { const el = document.getElementById('imChatPanel') const target = document.getElementById(msgId) if (el && target) { loadConfig.isLocatingMessage = true // 计算目标元素相对于容器的偏移量 const targetOffsetTop = target.offsetTop const containerHeight = el.clientHeight const targetHeight = target.offsetHeight // 计算理想的滚动位置:让目标元素居中显示 let scrollTo = targetOffsetTop - containerHeight / 2 + targetHeight / 2 // 边界检查:确保目标元素完全在可视区域内 const minScrollTop = 0 const maxScrollTop = el.scrollHeight - containerHeight // 如果计算出的位置超出边界,调整到边界内 if (scrollTo < minScrollTop) { scrollTo = minScrollTop } else if (scrollTo > maxScrollTop) { scrollTo = maxScrollTop } // 执行滚动 el.scrollTo({ top: scrollTo, behavior: 'smooth' }) // 添加高亮边框 addClass(target, 'border') setTimeout(() => removeClass(target, 'border'), 3000) // 清除 dialogueStore 中的 specifiedMsg,避免重复使用 if (dialogueStore.specifiedMsg) { dialogueStore.specifiedMsg = '' dialogueStore.noRefreshRecords = true } } }) } else { // 其他情况滚动到底部 // 在特殊参数模式下也需要恢复上传任务 dialogueStore.restoreUploadTasks() scrollToBottom() } } }) }) return } loadConfig.specialParams = undefined // 普通模式清空 // 设置初始加载状态为0(加载中) loadConfig.status = 0 // 先从本地数据库加载数据 const hasLocalData = await loadFromLocalDB(params) // 无论是否有本地数据,都从服务器获取最新数据 // 原有逻辑 console.log('onLoad()执行load') load(params) } // 向上加载更多(兼容特殊参数模式) const onRefreshLoad = () => { console.error('loadConfig.status', loadConfig.status) if (loadConfig.status == 1 || loadConfig.status == 3) { console.log('specialParams', loadConfig.specialParams) // 判断是否是特殊参数模式 if (loadConfig.specialParams && typeof loadConfig.specialParams === 'object') { // 检查特殊参数是否与当前会话匹配 if ( loadConfig.specialParams.talk_type === loadConfig.talk_type && loadConfig.specialParams.receiver_id === loadConfig.receiver_id ) { // 特殊参数模式下,direction: 'up',cursor: 当前最小 sequence // 注意:向上加载更多时,不传递 msg_id,避免触发定位逻辑 onLoad( { receiver_id: loadConfig.receiver_id, talk_type: loadConfig.talk_type, limit: 30 }, { specifiedMsg: { ...loadConfig.specialParams, direction: 'up', sort_sequence: '', cursor: getMinSequence(), // 向上加载更多时不传递 msg_id,保持原有滚动位置 msg_id: undefined } } ) } else { // 如果不匹配,重置为普通模式 resetLoadConfig() console.log('load执行2') load({ receiver_id: loadConfig.receiver_id, talk_type: loadConfig.talk_type, limit: 30 }) } } else { // 原有逻辑 console.log('load执行3') load({ receiver_id: loadConfig.receiver_id, talk_type: loadConfig.talk_type, limit: 30 }) } } } // 向下加载更多(特殊参数模式才生效,普通模式无效,因为普通模式的数据就是从最新开始加载历史的,所以不需要加载更新的数据) const onLoadMoreDown = () => { // 判断是否是特殊参数模式 if (loadConfig.specialParams && typeof loadConfig.specialParams === 'object') { if (loadConfig.isLocatingMessage) { loadConfig.isLocatingMessage = false return } // 添加时间间隔限制,至少间隔 500ms 才能触发下一次加载 const now = Date.now() if (now - loadConfig.lastLoadMoreTime < 500) { return } loadConfig.lastLoadMoreTime = now // 记录当前目标消息的位置 const el = document.getElementById('imChatPanel') if (el) { loadConfig.targetMessagePosition = el.scrollHeight - el.scrollTop } console.log('onLoadMoreDown 特殊模式触底了') // 检查特殊参数是否与当前会话匹配 if ( loadConfig.specialParams.talk_type === loadConfig.talk_type && loadConfig.specialParams.receiver_id === loadConfig.receiver_id ) { onLoad( { receiver_id: loadConfig.receiver_id, talk_type: loadConfig.talk_type, limit: 30 }, { specifiedMsg: { ...loadConfig.specialParams, direction: 'down', sort_sequence: 'asc', cursor: getMaxSequence(), type: 'loadMore' } } ) } } } return { loadConfig, records, onLoad, onRefreshLoad, onLoadMoreDown, onJumpMessage, resetLoadConfig } }