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 element?.scrollIntoView({ behavior: 'smooth' }) 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') if (el) { if (request.cursor == 0) { // el.scrollTop = el.scrollHeight // setTimeout(() => { // el.scrollTop = el.scrollHeight + 1000 // }, 500) 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 console.error('onLoad', params, options) // 新增:支持指定消息定位模式,参数以传入为准合并 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 target = document.getElementById(options.specifiedMsg?.msg_id || '') if (el && target) { // 如果是向上加载更多,保持原有内容位置 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 { // 如果是定位到特定消息,计算并滚动到目标位置 const containerRect = el.getBoundingClientRect() const targetRect = target.getBoundingClientRect() const offset = targetRect.top - containerRect.top loadConfig.isLocatingMessage = true // 居中 const scrollTo = el.scrollTop + offset - el.clientHeight / 2 + target.clientHeight / 2 el.scrollTo({ top: scrollTo, behavior: 'smooth' }) addClass(target, 'border') setTimeout(() => removeClass(target, 'border'), 3000) } } else if (el) { // el.scrollTop = el.scrollHeight scrollToBottom() } }) }) return } loadConfig.specialParams = undefined // 普通模式清空 // 原有逻辑 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 onLoad( { receiver_id: loadConfig.receiver_id, talk_type: loadConfig.talk_type, limit: 30 }, { specifiedMsg: { ...loadConfig.specialParams, direction: 'up', sort_sequence: '', cursor: getMinSequence(), msg_id: records.value.find((item) => item.sequence === getMinSequence() ? item.msg_id : '' )?.msg_id || '' } } ) } else { // 如果不匹配,重置为普通模式 resetLoadConfig() load({ receiver_id: loadConfig.receiver_id, talk_type: loadConfig.talk_type, limit: 30 }) } } else { // 原有逻辑 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 } }