From 811b93d9f1d81d149493ce41ff2e98d472ee5fbd Mon Sep 17 00:00:00 2001 From: YunaiV Date: Wed, 27 May 2026 23:46:18 +0800 Subject: [PATCH] =?UTF-8?q?refactor(im):=20=E6=8B=86=E5=88=86=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=92=8C=E6=B6=88=E6=81=AF=E6=9C=AC=E5=9C=B0=E5=AD=98?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 IM IndexedDB DB 封装、schema、key helper 和 session guard - 新增 messageStore,支持消息逐条持久化、分页加载、ack 合并、撤回和回执更新 - 调整 conversationStore 只持久化会话摘要,不再内嵌 messages 数组 - 切换发送、拉取、WebSocket、媒体上传和消息组件到 messageStore - 增加离开 IM 时的 store 清理和本地存储序列化保护 --- .../components/user/RecommendCardDialog.vue | 13 +- .../im/home/composables/useMediaUploader.ts | 19 +- .../im/home/composables/useMessagePuller.ts | 88 +- .../im/home/composables/useMessageSender.ts | 39 +- .../im/home/composables/useMuteOverlay.ts | 2 +- src/views/im/home/index.vue | 10 +- src/views/im/home/pages/contact/index.vue | 21 +- .../input/MessageMultiSelectBar.vue | 14 +- .../components/message/GroupPinnedMessage.vue | 40 +- .../components/message/MessageHistory.vue | 41 +- .../components/message/MessageItem.vue | 20 +- .../components/message/MessagePanel.vue | 20 +- .../components/message/MessageReadStatus.vue | 8 +- .../components/message/ReplyPreview.vue | 29 +- .../message/forward/MessageForwardDialog.vue | 1 - src/views/im/home/store/channelStore.ts | 10 +- src/views/im/home/store/conversationStore.ts | 978 +++--------------- src/views/im/home/store/draftStore.ts | 7 + src/views/im/home/store/groupStore.ts | 14 +- src/views/im/home/store/messageStore.ts | 839 +++++++++++++++ src/views/im/home/store/websocketStore.ts | 33 +- src/views/im/home/types/index.ts | 38 +- .../im/manager/face/pack/FacePackItemForm.vue | 3 +- src/views/im/utils/db.ts | 482 +++++++++ src/views/im/utils/message.ts | 9 +- src/views/im/utils/storage.ts | 66 +- 26 files changed, 1815 insertions(+), 1029 deletions(-) create mode 100644 src/views/im/home/store/messageStore.ts create mode 100644 src/views/im/utils/db.ts diff --git a/src/views/im/home/components/user/RecommendCardDialog.vue b/src/views/im/home/components/user/RecommendCardDialog.vue index b2ce4981d..0d49d750f 100644 --- a/src/views/im/home/components/user/RecommendCardDialog.vue +++ b/src/views/im/home/components/user/RecommendCardDialog.vue @@ -83,11 +83,7 @@ - + @@ -119,11 +115,7 @@ import { useConversationStore } from '../../store/conversationStore' import { useFriendStore } from '../../store/friendStore' import { useGroupStore } from '../../store/groupStore' import { useMessageSender } from '../../composables/useMessageSender' -import { - ImConversationType, - ImMessageType, - isGroupConversation -} from '../../../utils/constants' +import { ImConversationType, ImMessageType, isGroupConversation } from '../../../utils/constants' import { getConversationKey } from '../../../utils/conversation' import { buildDefaultGroupName } from '../../../utils/group' import { serializeMessage, type CardTarget } from '../../../utils/message' @@ -287,7 +279,6 @@ async function handleCreateGroupAndSend() { name: group.name || name, avatar: group.avatar || '', unreadCount: 0, - messages: [], lastContent: '', lastSendTime: 0 } diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts index adb3571a7..9cbaddce3 100644 --- a/src/views/im/home/composables/useMediaUploader.ts +++ b/src/views/im/home/composables/useMediaUploader.ts @@ -4,6 +4,7 @@ import { useMessage } from '@/hooks/web/useMessage' import { isOpenableUrl } from '@/utils/url' import { useConversationStore } from '../store/conversationStore' +import { useMessageStore } from '../store/messageStore' import { useMessageSender } from './useMessageSender' import { useMuteOverlay } from './useMuteOverlay' import { ImMessageStatus, ImMessageType } from '../../utils/constants' @@ -67,8 +68,7 @@ export const mediaTypeHandlers: Partial> = { }, [ImMessageType.VOICE]: { kind: '语音', - build: (_file, url, context) => - ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage, + build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage, extractResendContext: (oldContent) => { const old = parseMessage(oldContent) return { voiceDuration: old?.duration ?? 0 } @@ -88,7 +88,8 @@ export const mediaTypeHandlers: Partial> = { extractResendContext: (oldContent) => { const old = parseMessage(oldContent) // 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传 - const reuseCover = old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined + const reuseCover = + old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined return { videoProbe: { duration: old?.duration, width: old?.width, height: old?.height }, videoCoverUrl: reuseCover @@ -155,6 +156,7 @@ export function ensureMediaSizeWithinLimit( export const useMediaUploader = () => { const conversationStore = useConversationStore() + const messageStore = useMessageStore() const userStore = useUserStore() const muteOverlay = useMuteOverlay() const message = useMessage() @@ -177,7 +179,7 @@ export const useMediaUploader = () => { const blobUrl = URL.createObjectURL(opts.file) const clientMessageId = opts.existingClientMessageId || generateClientMessageId() if (opts.existingClientMessageId) { - conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { + messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { content: opts.buildContent(blobUrl), status: ImMessageStatus.SENDING, uploadProgress: 0, @@ -186,7 +188,6 @@ export const useMediaUploader = () => { return { clientMessageId, blobUrl } } const placeholder: Message = { - id: 0, clientMessageId, type: opts.type, content: opts.buildContent(blobUrl), @@ -198,7 +199,7 @@ export const useMediaUploader = () => { uploadProgress: 0, _localFile: opts.file } - conversationStore.insertMessage( + messageStore.insertMessage( { type: conversation.type, targetId: conversation.targetId, @@ -221,7 +222,7 @@ export const useMediaUploader = () => { targetId: number, clientMessageId: string ): void => { - conversationStore.patchMessage(conversationType, targetId, clientMessageId, { + messageStore.patchMessage(conversationType, targetId, clientMessageId, { status: ImMessageStatus.FAILED, uploadProgress: undefined }) @@ -244,7 +245,7 @@ export const useMediaUploader = () => { return } lastPercent = percent - conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { + messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { uploadProgress: percent }) } @@ -303,7 +304,7 @@ export const useMediaUploader = () => { clientMessageId: string realContent: string }): Promise => { - conversationStore.patchMessage( + messageStore.patchMessage( opts.conversation.type, opts.conversation.targetId, opts.clientMessageId, diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index f5c87e0ad..1a16e6322 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -1,5 +1,6 @@ import { watch } from 'vue' import { useConversationStore } from '../store/conversationStore' +import { useMessageStore, type PulledMessageBatchItem } from '../store/messageStore' import { useImWebSocketStore } from '../store/websocketStore' import { useFriendStore } from '../store/friendStore' import { getFriendDisplayName } from '../../utils/user' @@ -30,7 +31,7 @@ import { MESSAGE_PRIVATE_READ_ENABLED } from '../../utils/config' import { buildChannelConversationStub } from '../../utils/channel' -import { getPrivateMessagePeerId } from '../../utils/message' +import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message' import { getCurrentUserId } from '../../utils/storage' import type { Message } from '../types' @@ -47,6 +48,7 @@ import type { Message } from '../types' */ export const useMessagePuller = () => { const conversationStore = useConversationStore() + const messageStore = useMessageStore() const wsStore = useImWebSocketStore() const friendStore = useFriendStore() const groupStore = useGroupStore() @@ -55,7 +57,11 @@ export const useMessagePuller = () => { /** 判断请求是否被主动取消 */ const isAbortError = (e: unknown): boolean => { const error = e as { name?: string; code?: string; message?: string } - return error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.message === 'canceled' + return ( + error?.name === 'CanceledError' || + error?.code === 'ERR_CANCELED' || + error?.message === 'canceled' + ) } /** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话";curry currentUserId 进闭包减少 3 处调用方的样板 */ @@ -66,7 +72,7 @@ export const useMessagePuller = () => { const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => { return { id: message.id, - clientMessageId: message.clientMessageId || '', + clientMessageId: message.clientMessageId || generateClientMessageId(), type: message.type, content: message.content, status: message.status, @@ -81,7 +87,7 @@ export const useMessagePuller = () => { const convertGroupMessage = (message: ImGroupMessageRespVO): Message => { return { id: message.id, - clientMessageId: message.clientMessageId || '', + clientMessageId: message.clientMessageId || generateClientMessageId(), type: message.type, content: message.content, status: message.status, @@ -100,7 +106,8 @@ export const useMessagePuller = () => { const convertChannelMessage = (message: ImChannelMessageRespVO): Message => { return { id: message.id, - clientMessageId: '', + // TODO @AI:是不是都需要使用 message 的 clientMessageId;注意,后端也需要有 clientMessageId; + clientMessageId: generateClientMessageId(), type: message.type, content: message.content, status: message.status ?? ImMessageStatus.UNREAD, @@ -161,7 +168,8 @@ export const useMessagePuller = () => { const isPrivate = conversationType === ImConversationType.PRIVATE const isChannel = conversationType === ImConversationType.CHANNEL const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE - const isStillValid = () => !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId + const isStillValid = () => + !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId while (true) { if (!isStillValid()) { return @@ -182,26 +190,30 @@ export const useMessagePuller = () => { break } - // 逐条 dispatch:原消息走 insertMessage;RECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。 + // TODO @AI:感觉这个 PulledMessageBatchItem 类名,batchItems 变量名,insertPulledBatch 方法名,不够能体现出 message; + const batchItems: PulledMessageBatchItem[] = [] + // 逐条 dispatch:原消息走批量 insert;RECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。 // 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到 for (const raw of list) { if (isChannel) { const message = raw as ImChannelMessageRespVO - conversationStore.insertMessage( - convertChannelConversation(message), - convertChannelMessage(message) - ) + batchItems.push({ + kind: 'insert', + conversationInfo: convertChannelConversation(message), + message: convertChannelMessage(message) + }) continue } if (isPrivate) { const message = raw as ImPrivateMessageRespVO // 特殊:撤回消息的处理 if (message.type === ImMessageType.RECALL) { - conversationStore.recallMessage( - ImConversationType.PRIVATE, - getPrivatePeerId(message), - message.content - ) + batchItems.push({ + kind: 'recall', + conversationType: ImConversationType.PRIVATE, + targetId: getPrivatePeerId(message), + recallSignalContent: message.content + }) continue } // 特殊:离线 pull 期间入库的 FRIEND_* 帧(目前仅 FRIEND_ADD persistent=true)也要走好友数据分发, @@ -214,35 +226,40 @@ export const useMessagePuller = () => { } } // 其它消息正常入会话消息列表 - conversationStore.insertMessage( - convertPrivateConversation(message), - convertPrivateMessage(message) - ) + batchItems.push({ + kind: 'insert', + conversationInfo: convertPrivateConversation(message), + message: convertPrivateMessage(message) + }) } else { const message = raw as ImGroupMessageRespVO // 特殊:撤回消息的处理 if (message.type === ImMessageType.RECALL) { - conversationStore.recallMessage( - ImConversationType.GROUP, - message.groupId, - message.content - ) + batchItems.push({ + kind: 'recall', + conversationType: ImConversationType.GROUP, + targetId: message.groupId, + recallSignalContent: message.content + }) continue } // 其它消息正常入会话消息列表 - conversationStore.insertMessage( - convertGroupConversation(message), - convertGroupMessage(message) - ) + batchItems.push({ + kind: 'insert', + conversationInfo: convertGroupConversation(message), + message: convertGroupMessage(message) + }) } } // 游标推进到本批最大 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) break } const nextMinId = Math.max(...validIds) + await messageStore.insertPulledBatch(batchItems, conversationType, nextMinId) // 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻 if (nextMinId <= minId) { break @@ -311,21 +328,21 @@ export const useMessagePuller = () => { await Promise.all([ pullByType( ImConversationType.PRIVATE, - conversationStore.privateMessageMaxId, + messageStore.privateMessageMaxId, startEpoch, startUserId, abortController.signal ), pullByType( ImConversationType.GROUP, - conversationStore.groupMessageMaxId, + messageStore.groupMessageMaxId, startEpoch, startUserId, abortController.signal ), pullByType( ImConversationType.CHANNEL, - conversationStore.channelMessageMaxId, + messageStore.channelMessageMaxId, startEpoch, startUserId, abortController.signal @@ -369,12 +386,15 @@ export const useMessagePuller = () => { const active = conversationStore.activeConversation if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) { try { - const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId, abortController.signal) + const maxReadId = await apiGetPrivateMaxReadMessageId( + active.targetId, + abortController.signal + ) if (!isCurrentPull()) { return } if (maxReadId) { - conversationStore.applyReadReceipt({ + messageStore.applyReadReceipt({ 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 d3b13cff8..5d4030fbe 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -1,4 +1,5 @@ import { useConversationStore } from '../store/conversationStore' +import { useMessageStore } from '../store/messageStore' import { sendPrivateMessage as apiSendPrivateMessage, readPrivateMessages as apiReadPrivateMessages, @@ -20,6 +21,7 @@ import { } from '../../utils/message' import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants' import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config' +import { getClientConversationId } from '../../utils/db' import type { Conversation, Message } from '../types' import { useUserStore } from '@/store/modules/user' @@ -57,9 +59,10 @@ interface SendExtOptions { */ export const useMessageSender = () => { const conversationStore = useConversationStore() + const messageStore = useMessageStore() const userStore = useUserStore() - /**构造本地乐观消息对象(id=0 表示尚未拿到服务端消息 id) */ + /** 构造本地乐观消息对象 */ const buildLocalMessage = (opts: { clientMessageId: string content: string @@ -68,7 +71,6 @@ export const useMessageSender = () => { atUserIds?: number[] }): Message => { return { - id: 0, clientMessageId: opts.clientMessageId, type: opts.type, content: opts.content, @@ -109,10 +111,10 @@ export const useMessageSender = () => { clientMessageId = options.existingClientMessageId // 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送, // 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条" - const targetConversation = conversationStore.getConversation(conversation.type, realTarget) - const stillExists = targetConversation?.messages.some( - (m) => m.clientMessageId === clientMessageId - ) + // TODO @AI:尽量不要 m 缩写,全称 + const stillExists = messageStore + .getMessageList(conversation.type, realTarget) + .some((m) => m.clientMessageId === clientMessageId && !m._ackMerging) if (!stillExists) { return false } @@ -131,7 +133,7 @@ export const useMessageSender = () => { name: conversation.name || String(realTarget), avatar: conversation.avatar || '' } - conversationStore.insertMessage(conversationInfo, message) + messageStore.insertMessage(conversationInfo, message) } // 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED @@ -143,7 +145,7 @@ export const useMessageSender = () => { type, content }) - conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { + void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, { id: data.id, sendTime: new Date(data.sendTime).getTime(), status: data.status, @@ -158,7 +160,7 @@ export const useMessageSender = () => { atUserIds: options?.atUserIds, receipt: options?.receipt }) - conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { + void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, { id: data.id, sendTime: new Date(data.sendTime).getTime(), status: data.status, @@ -170,7 +172,7 @@ export const useMessageSender = () => { return true } catch (e) { console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e) - conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { + void messageStore.ackMessage(conversation.type, realTarget, clientMessageId, { status: ImMessageStatus.FAILED }) return false @@ -195,7 +197,7 @@ export const useMessageSender = () => { * 2. 此处不做乐观撤回,避免网络失败后状态不可回退 */ const recall = async (message: Message) => { - // 参数校验:本地占位消息(id=0)不能撤回 + // 参数校验:本地占位消息不能撤回 if (!message.id) { return } @@ -215,7 +217,7 @@ export const useMessageSender = () => { /** * 触发当前会话的已读上报(切会话 / 进入页面时调用) * 1. 本端立刻清未读数;服务端回包成功后再做持久化 - * 2. 已读位置取会话内最大真实消息 id(id=0 的本地发送中消息跳过) + * 2. 已读位置取会话内最大真实消息 id(本地发送中消息跳过) */ const readActive = async () => { const conversation = conversationStore.activeConversation @@ -223,11 +225,12 @@ export const useMessageSender = () => { return } // 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应) - conversationStore.markActiveAsRead() - const maxMessageId = conversationStore.getActiveMessages.reduce( - (max, m) => (m.id > max ? m.id : max), - 0 - ) + 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) if (!maxMessageId) { return } @@ -283,7 +286,7 @@ export const useMessageSender = () => { return } // applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ - conversationStore.applyReadReceipt({ + messageStore.applyReadReceipt({ conversationType: ImConversationType.PRIVATE, targetId: peerId, privateReadMaxId: maxReadId diff --git a/src/views/im/home/composables/useMuteOverlay.ts b/src/views/im/home/composables/useMuteOverlay.ts index 39a2c4b16..764feab30 100644 --- a/src/views/im/home/composables/useMuteOverlay.ts +++ b/src/views/im/home/composables/useMuteOverlay.ts @@ -13,7 +13,7 @@ export type MuteOverlayInfo = { text: string; icon: string } * 改成模块级共享后所有订阅者共用一份 setInterval,订阅数清零时也清掉 timer,避免内存与时钟漂移 */ const sharedNow = ref(Date.now()) -let sharedTickTimer: ReturnType | null = null +let sharedTickTimer: number | null = null let subscriberCount = 0 function subscribeNowTick(): void { diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index c38166548..af8928137 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -34,6 +34,7 @@ import { useRoute } from 'vue-router' import { useAppStore } from '@/store/modules/app' import { useConversationStore } from './store/conversationStore' +import { useMessageStore } from './store/messageStore' import { useImWebSocketStore } from './store/websocketStore' import { useFriendStore } from './store/friendStore' import { useGroupStore } from './store/groupStore' @@ -46,6 +47,7 @@ import { useMessageSender } from './composables/useMessageSender' import { useVoicePlayer } from './composables/useVoicePlayer' import { ImConversationType } from '../utils/constants' import { StorageKeys } from '../utils/storage' +import { initDb, stopRequests } from '../utils/db' import type { Conversation } from './types' import ToolBar from './components/ToolBar.vue' import UserInfoCard from './components/user/UserInfoCard.vue' @@ -58,6 +60,7 @@ defineOptions({ name: 'ImIndex' }) const route = useRoute() const appStore = useAppStore() const conversationStore = useConversationStore() +const messageStore = useMessageStore() const webSocketStore = useImWebSocketStore() const friendStore = useFriendStore() const groupStore = useGroupStore() @@ -81,9 +84,12 @@ onMounted(async () => { // 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息 conversationStore.loading = true try { + // TODO @AI:这里要写个注释???!!! + await initDb() // 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存) - const [, hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([ + const [, , hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([ conversationStore.loadConversations(), + messageStore.loadCursors(), friendStore.loadFriends(), groupStore.loadGroups(), draftStore.loadDrafts(), @@ -162,6 +168,8 @@ onUnmounted(() => { // 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响 voicePlayer.stop() window.removeEventListener('beforeunload', onBeforeUnload) + // TODO @AI:写个注释?! + void stopRequests() }) /** diff --git a/src/views/im/home/pages/contact/index.vue b/src/views/im/home/pages/contact/index.vue index 4261129e8..2676ea9ff 100644 --- a/src/views/im/home/pages/contact/index.vue +++ b/src/views/im/home/pages/contact/index.vue @@ -157,13 +157,14 @@ const groups = computed(() => watch( friends, (list) => { - if (selection.value?.type !== 'friend') { + const selected = selection.value + if (selected?.type !== 'friend') { return } - const fresh = list.find((friend) => friend.id === selection.value!.friend.id) + const fresh = list.find((friend) => friend.id === selected.friend.id) if (!fresh) { selection.value = null - } else if (fresh !== selection.value.friend) { + } else if (fresh !== selected.friend) { selection.value = { type: 'friend', friend: fresh } } }, @@ -172,13 +173,14 @@ watch( watch( groups, (list) => { - if (selection.value?.type !== 'group') { + const selected = selection.value + if (selected?.type !== 'group') { return } - const fresh = list.find((group) => group.id === selection.value!.group.id) + const fresh = list.find((group) => group.id === selected.group.id) if (!fresh) { selection.value = null - } else if (fresh !== selection.value.group) { + } else if (fresh !== selected.group) { selection.value = { type: 'group', group: fresh } } }, @@ -187,13 +189,14 @@ watch( watch( friendRequests, (list) => { - if (selection.value?.type !== 'request') { + const selected = selection.value + if (selected?.type !== 'request') { return } - const fresh = list.find((request) => request.id === selection.value!.request.id) + const fresh = list.find((request) => request.id === selected.request.id) if (!fresh) { selection.value = null - } else if (fresh !== selection.value.request) { + } else if (fresh !== selected.request) { selection.value = { type: 'request', request: fresh } } }, diff --git a/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue b/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue index a957b681a..f481f1acc 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue @@ -51,14 +51,17 @@ import Icon from '@/components/Icon/src/Icon.vue' import { useMessage } from '@/hooks/web/useMessage' import { useConversationStore } from '@/views/im/home/store/conversationStore' +import { useMessageStore } from '@/views/im/home/store/messageStore' import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect' import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants' +import { getClientConversationId } from '@/views/im/utils/db' import type { Message } from '@/views/im/home/types' import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys' defineOptions({ name: 'ImMessageMultiSelectBar' }) const conversationStore = useConversationStore() +const messageStore = useMessageStore() const message = useMessage() const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY) const multiSelect = useMessageMultiSelect() @@ -66,16 +69,16 @@ const multiSelect = useMessageMultiSelect() /** 选中条数 */ const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length) -/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort;isNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */ +/** 当前会话内已选消息 */ function getSelectedMessages(): Message[] { const conversation = conversationStore.activeConversation if (!conversation) { return [] } const ids = multiSelect.selectedIdSet.value - return conversation.messages.filter( - (message) => ids.has(message.clientMessageId) && isNormalMessage(message.type) - ) + return messageStore + .getMessages(getClientConversationId(conversation.type, conversation.targetId)) + .filter((message) => ids.has(message.clientMessageId) && isNormalMessage(message.type)) } /** 逐条转发:开 ForwardDialog 单条模式 */ @@ -128,7 +131,7 @@ async function handleDelete() { return } for (const m of messages) { - conversationStore.removeMessage(conversation.type, conversation.targetId, { + messageStore.removeMessage(conversation.type, conversation.targetId, { id: m.id, clientMessageId: m.clientMessageId }) @@ -141,4 +144,3 @@ function handleCancel() { multiSelect.exit() } - diff --git a/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue b/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue index 7850c790b..4a2444198 100644 --- a/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue +++ b/src/views/im/home/pages/conversation/components/message/GroupPinnedMessage.vue @@ -9,8 +9,14 @@ class="flex items-center gap-1.5 w-[360px] px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-bg-color)] shadow-[0_1px_2px_rgba(0,0,0,0.04)] cursor-pointer hover:bg-[var(--el-fill-color-lighter)]" @click="handleTopClick" > - - {{ getSenderName(latest) }}: + + {{ getSenderName(latest) }}: {{ getPreview(latest) }}