chat-pc/src/hooks/useTalkRecord.ts

446 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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')
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
// 新增:支持指定消息定位模式,参数以传入为准合并
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 {
// 其他情况滚动到底部
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
// 注意:向上加载更多时,不传递 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()
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
}
}