chat-pc/src/hooks/useTalkRecord.ts
Phoenix 4863b4c77c feat(chat): 实现聊天记录本地存储功能
添加Dexie.js作为本地数据库,实现聊天记录和会话的本地存储与同步
修改消息和会话相关store方法,支持本地数据库操作
优化消息加载逻辑,优先从本地加载再同步服务器数据
添加数据库工具函数,包括消息增删改查和会话管理功能
2025-06-30 16:00:06 +08:00

563 lines
19 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
}
// 如果不是从本地数据库加载的则设置加载状态为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
}
}