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 } loadConfig.status = 0 let scrollHeight = 0 console.log('加载数据列表load') const el = document.getElementById('imChatPanel') if (el) { scrollHeight = el.scrollHeight } const { data, code } = await ServeTalkRecords(request) if (code != 200) { return (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)) 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') console.log('request',request) 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 = () => { console.error('records.value', records.value) if (!records.value.length) return 0 console.error(Math.min(...records.value.map((item) => item.sequence))) 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)) } /** * 加载数据主入口,支持指定消息定位模式 * @param params 原有参数 * @param options 可选,{ specifiedMsg } 指定消息对象 */ const onLoad = (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 } // 记录特殊参数,供分页加载用 console.error('options', options) loadConfig.status = 0 // 复用主流程 loading 状态 // 以 params 为基础,合并 specifiedMsg 的所有字段(只要有就覆盖) const contextParams = { ...params, ...options.specifiedMsg } //msg_id是用来做定位的,不做参数,所以这里清空 contextParams.msg_id = '' ServeTalkRecords(contextParams).then(({ data, code }) => { 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 // 普通模式清空 // 原有逻辑 console.log('onLoad()执行load') load(params) } // 向上加载更多(兼容特殊参数模式) const onRefreshLoad = () => { console.error('loadConfig.status', loadConfig.status) if (loadConfig.status == 1) { 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 } }