From 664904bd062b697801f8bf38e705d0d03aab4a6c Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 28 May 2026 08:39:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor(im):=20=E6=8B=86=E5=88=86=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B6=88=E6=81=AF=E5=AD=98=E5=82=A8=E5=B9=B6=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E8=8D=89=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 IM IndexedDB DB client,按当前用户初始化本地库 - 将会话与消息拆成 conversations / messages 逐条存储 - 将草稿合并进 Conversation.draft,删除 draftStore - 优化 pull 批量写入,消息、会话摘要和游标同事务落库 - 统一 store action 命名,清理旧 localStorage key 和 TODO - 保留 maxId settings 游标,避免本地消息回收后游标回退 --- src/api/im/message/channel/index.ts | 1 + .../im/home/composables/useMessagePuller.ts | 26 +- .../im/home/composables/useMessageSender.ts | 14 +- src/views/im/home/index.vue | 21 +- .../conversation/ConversationGroupSide.vue | 5 +- .../conversation/ConversationItem.vue | 4 +- .../components/input/MessageInput.vue | 24 +- .../components/message/GroupPinnedMessage.vue | 23 +- .../components/message/MessageHistory.vue | 4 +- .../components/message/MessageItem.vue | 8 +- .../components/message/MessageReadStatus.vue | 2 +- src/views/im/home/store/channelStore.ts | 12 +- src/views/im/home/store/conversationStore.ts | 218 +++++++++++++--- src/views/im/home/store/draftStore.ts | 145 ----------- src/views/im/home/store/messageStore.ts | 243 ++++++++++-------- src/views/im/home/store/websocketStore.ts | 7 +- src/views/im/home/types/index.ts | 14 +- src/views/im/utils/db.ts | 107 ++++---- src/views/im/utils/message.ts | 12 +- src/views/im/utils/storage.ts | 12 - 20 files changed, 454 insertions(+), 448 deletions(-) delete mode 100644 src/views/im/home/store/draftStore.ts diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts index 3207bfb82..0760a0670 100644 --- a/src/api/im/message/channel/index.ts +++ b/src/api/im/message/channel/index.ts @@ -2,6 +2,7 @@ import request from '@/config/axios' export interface ImChannelMessageRespVO { id: number + clientMessageId?: string channelId: number materialId: number type: number diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 1a16e6322..ccae81f89 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -1,6 +1,6 @@ import { watch } from 'vue' import { useConversationStore } from '../store/conversationStore' -import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore' +import { useMessageStore, type PulledMessage } from '../store/messageStore' import { useImWebSocketStore } from '../store/websocketStore' import { useFriendStore } from '../store/friendStore' import { getFriendDisplayName } from '../../utils/user' @@ -106,8 +106,7 @@ export const useMessagePuller = () => { const convertChannelMessage = (message: ImChannelMessageRespVO): Message => { return { id: message.id, - // TODO @AI:是不是都需要使用 message 的 clientMessageId;注意,后端也需要有 clientMessageId; - clientMessageId: generateClientMessageId(), + clientMessageId: message.clientMessageId || generateClientMessageId(), type: message.type, content: message.content, status: message.status ?? ImMessageStatus.UNREAD, @@ -190,14 +189,13 @@ export const useMessagePuller = () => { break } - // TODO @AI:感觉这个 PulledMessageBatchItem 类名,batchItems 变量名,insertPulledBatch 方法名,不够能体现出 message; - const batchItems: PulledMessageBatchItem[] = [] + const pulledMessages: PulledMessage[] = [] // 逐条 dispatch:原消息走批量 insert;RECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。 // 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到 for (const raw of list) { if (isChannel) { const message = raw as ImChannelMessageRespVO - batchItems.push({ + pulledMessages.push({ kind: 'insert', conversationInfo: convertChannelConversation(message), message: convertChannelMessage(message) @@ -208,7 +206,7 @@ export const useMessagePuller = () => { const message = raw as ImPrivateMessageRespVO // 特殊:撤回消息的处理 if (message.type === ImMessageType.RECALL) { - batchItems.push({ + pulledMessages.push({ kind: 'recall', conversationType: ImConversationType.PRIVATE, targetId: getPrivatePeerId(message), @@ -226,7 +224,7 @@ export const useMessagePuller = () => { } } // 其它消息正常入会话消息列表 - batchItems.push({ + pulledMessages.push({ kind: 'insert', conversationInfo: convertPrivateConversation(message), message: convertPrivateMessage(message) @@ -235,7 +233,7 @@ export const useMessagePuller = () => { const message = raw as ImGroupMessageRespVO // 特殊:撤回消息的处理 if (message.type === ImMessageType.RECALL) { - batchItems.push({ + pulledMessages.push({ kind: 'recall', conversationType: ImConversationType.GROUP, targetId: message.groupId, @@ -244,7 +242,7 @@ export const useMessagePuller = () => { continue } // 其它消息正常入会话消息列表 - batchItems.push({ + pulledMessages.push({ kind: 'insert', conversationInfo: convertGroupConversation(message), message: convertGroupMessage(message) @@ -255,11 +253,11 @@ export const useMessagePuller = () => { // 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批 const validIds = list.map((message) => message.id).filter((id): id is number => id != null) if (validIds.length === 0) { - await messageStore.insertPulledBatch(batchItems, conversationType) + await messageStore.applyPulledMessageList(pulledMessages, conversationType) break } const nextMinId = Math.max(...validIds) - await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId) + await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId) // 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻 if (nextMinId <= minId) { break @@ -378,7 +376,7 @@ export const useMessagePuller = () => { } // pull + replay 都完成后再排序,避免回放消息打乱顺序 - conversationStore.sortConversations() + conversationStore.sortConversationList() // 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」 // 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发 @@ -394,7 +392,7 @@ export const useMessagePuller = () => { return } if (maxReadId) { - messageStore.applyReadReceipt({ + messageStore.applyMessageReadReceipt({ conversationType: ImConversationType.PRIVATE, targetId: active.targetId, privateReadMaxId: maxReadId diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 5d4030fbe..88d2155e7 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -111,10 +111,9 @@ export const useMessageSender = () => { clientMessageId = options.existingClientMessageId // 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送, // 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条" - // TODO @AI:尽量不要 m 缩写,全称 const stillExists = messageStore .getMessageList(conversation.type, realTarget) - .some((m) => m.clientMessageId === clientMessageId && !m._ackMerging) + .some((message) => message.clientMessageId === clientMessageId && !message._ackMerging) if (!stillExists) { return false } @@ -227,10 +226,13 @@ export const useMessageSender = () => { // 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应) conversationStore.markConversationAsRead(conversation.type, conversation.targetId) messageStore.markConversationMessagesRead(conversation) - // TODO @AI:message;不要用 m; const maxMessageId = messageStore .getMessages(getClientConversationId(conversation.type, conversation.targetId)) - .reduce((max, m) => (m.id && m.id > max ? m.id : max), 0) + .reduce( + (maxMessageId, message) => + message.id && message.id > maxMessageId ? message.id : maxMessageId, + 0 + ) if (!maxMessageId) { return } @@ -285,8 +287,8 @@ export const useMessageSender = () => { if (!maxReadId) { return } - // applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ - messageStore.applyReadReceipt({ + // applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ + messageStore.applyMessageReadReceipt({ conversationType: ImConversationType.PRIVATE, targetId: peerId, privateReadMaxId: maxReadId diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index af8928137..f5e2aaee2 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -39,7 +39,6 @@ import { useImWebSocketStore } from './store/websocketStore' import { useFriendStore } from './store/friendStore' import { useGroupStore } from './store/groupStore' import { useGroupRequestStore } from './store/groupRequestStore' -import { useDraftStore } from './store/draftStore' import { useFaceStore } from './store/faceStore' import { useChannelStore } from './store/channelStore' import { useMessagePuller } from './composables/useMessagePuller' @@ -65,7 +64,6 @@ const webSocketStore = useImWebSocketStore() const friendStore = useFriendStore() const groupStore = useGroupStore() const groupRequestStore = useGroupRequestStore() -const draftStore = useDraftStore() const faceStore = useFaceStore() const channelStore = useChannelStore() const { pullOnce, cancelPull } = useMessagePuller() @@ -81,18 +79,17 @@ onMounted(async () => { .fetchUnhandledList() .catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e)) - // 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息 + // 1.1 整段 loading=true 阻断会话列表抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息 conversationStore.loading = true try { - // TODO @AI:这里要写个注释???!!! + // 1.2 打开当前用户 IM DB await initDb() - // 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存) - const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([ + // 1.3 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadMessageCursors 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存) + const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([ conversationStore.loadConversations(), - messageStore.loadCursors(), + messageStore.loadMessageCursors(), friendStore.loadFriends(), groupStore.loadGroups(), - draftStore.loadDrafts(), channelStore.loadChannels() ]) @@ -130,7 +127,7 @@ onMounted(async () => { conversationStore.setActiveConversation(firstVisible) } } catch (e) { - // 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断 + // 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续会话列表写入全被早 return 阻断 // 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试 conversationStore.loading = false console.error('[IM] 初始化失败', e) @@ -155,7 +152,7 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un /** 标签关闭前 flush 草稿队列;debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */ function onBeforeUnload() { - draftStore.flushPersist() + conversationStore.flushDraftSave() } window.addEventListener('beforeunload', onBeforeUnload) @@ -163,12 +160,12 @@ window.addEventListener('beforeunload', onBeforeUnload) onUnmounted(() => { cancelPull() webSocketStore.disconnect() - draftStore.flushPersist() + conversationStore.flushDraftSave() faceStore.reset() // 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响 voicePlayer.stop() window.removeEventListener('beforeunload', onBeforeUnload) - // TODO @AI:写个注释?! + // 停止当前 IM session 并清理各 store 内存 void stopRequests() }) diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index 8bcc4928b..5dd3e5859 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -295,7 +295,7 @@
进群需要群主 / 群管理确认 - +
{ if (isActive.value) { return undefined } - return draftStore.getDraft(props.conversation) + return conversationStore.getDraft(props.conversation) }) const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP) diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index d1a14124b..3f509d916 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -46,7 +46,7 @@ :quote="replyTarget" closable class="mx-3 mb-1.5" - @close="clearReply" + @close="clearReplyDraft" />