@@ -119,7 +121,9 @@
class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--el-text-color-primary)] bg-[var(--el-color-warning-light-9)] transition-colors hover:bg-[var(--el-color-warning-light-8)]"
@click="handleNotFriendClick"
>
-
+
对方还不是你的朋友
@@ -141,7 +145,7 @@
暂无消息
+ 找到这层 wrapper,scrollIntoView + 加高亮 class;本地占位消息跳过 -->
conversationStore.getActiveMessages)
+const messages = computed(() => {
+ const conversation = conversationStore.activeConversation
+ return conversation
+ ? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId))
+ : []
+})
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
diff --git a/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue b/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue
index 282ffa972..df87255ad 100644
--- a/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue
+++ b/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue
@@ -59,7 +59,7 @@ import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/grou
import { CommonStatusEnum } from '@/utils/constants'
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
import type { Message } from '../../../../types'
-import { useConversationStore } from '../../../../store/conversationStore'
+import { useMessageStore } from '../../../../store/messageStore'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import PagedScroller from '../../../../components/PagedScroller.vue'
@@ -73,7 +73,7 @@ const props = defineProps<{
groupId: number
}>()
-const conversationStore = useConversationStore()
+const messageStore = useMessageStore()
// popover 开关:show 时拉已读名单,关闭后保留 readUserIds 缓存(重开同一条消息不再请求)
const popVisible = ref(false)
@@ -132,7 +132,7 @@ const unreadMembers = computed(() =>
* 跳过:本地占位消息(id = 0,还没拿到服务端 id),后端没法按 messageId 查
* 失败:仅控制台告警,readUserIds 保持空数组 → label 走 readCount 兜底,不阻塞 UI
*
- * 拉到名单后顺手把 readCount / receiptStatus 回写到 conversationStore,让 popover 外面的
+ * 拉到名单后顺手把 readCount / receiptStatus 回写到 messageStore,让 popover 外面的
* label 也跟着走最新数:离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧,弹层里看到"已读 5"
* 但外面仍是"未读"或旧人数;这里以服务端返回为准矫正回去
*/
@@ -150,7 +150,7 @@ async function loadReadUsers() {
// 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支;
// 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING)
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
- conversationStore.applyReadReceipt({
+ messageStore.applyReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: props.groupId,
groupMessageId: props.message.id,
diff --git a/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue
index 7eb04cfab..a0ef26b2c 100644
--- a/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue
+++ b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue
@@ -14,7 +14,9 @@
-
+
{{ parsedPayload.name }}
@@ -58,7 +55,11 @@
-
+
@@ -103,8 +104,10 @@ import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useConversationStore } from '../../../../store/conversationStore'
+import { useMessageStore } from '../../../../store/messageStore'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { ImMessageType } from '@/views/im/utils/constants'
+import { getClientConversationId } from '@/views/im/utils/db'
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
import {
parseMessage,
@@ -148,6 +151,7 @@ const emit = defineEmits<{
}>()
const conversationStore = useConversationStore()
+const messageStore = useMessageStore()
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
const liveMessage = computed(() => {
@@ -155,7 +159,9 @@ const liveMessage = computed(() => {
if (!conversation || !props.quote.messageId) {
return undefined
}
- return conversation.messages.find((message) => message.id === props.quote.messageId)
+ return messageStore
+ .getMessages(getClientConversationId(conversation.type, conversation.targetId))
+ .find((message) => message.id === props.quote.messageId)
})
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
@@ -193,9 +199,7 @@ const isMaterial = computed(() => props.quote.type === ImMessageType.MATERIAL)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => {
const text = parsedPayload.value?.content ?? ''
- return text.length <= MAX_TEXT_PREVIEW_LEN
- ? text
- : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
+ return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
})
/** 文件 icon:按扩展名挑色,跟主气泡渲染同源 */
@@ -227,4 +231,3 @@ function onClick() {
emit('locate', props.quote.messageId)
}
-
diff --git a/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue b/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue
index e10288e83..d340d2112 100644
--- a/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue
+++ b/src/views/im/home/pages/conversation/components/message/forward/MessageForwardDialog.vue
@@ -402,7 +402,6 @@ async function handleCreateGroupAndSend() {
name: group.name || name,
avatar: group.avatar || '',
unreadCount: 0,
- messages: [],
lastContent: '',
lastSendTime: 0
}
diff --git a/src/views/im/home/store/channelStore.ts b/src/views/im/home/store/channelStore.ts
index b591020c9..a9ee15ea4 100644
--- a/src/views/im/home/store/channelStore.ts
+++ b/src/views/im/home/store/channelStore.ts
@@ -90,6 +90,12 @@ export const useChannelStore = defineStore('imChannelStore', {
conversation.avatar = channel.avatar
}
})
+ },
+
+ /** 清空频道内存 */
+ clear() {
+ this.channels = []
+ this.loaded = false
}
}
})
@@ -98,4 +104,6 @@ if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
}
-export const useImChannelStore = () => useChannelStore(store)
+export const useChannelStoreWithOut = () => useChannelStore(store)
+// TODO @AI:这里,重名名,是不是没必要???(问问。)
+export const useImChannelStore = useChannelStoreWithOut
diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts
index a3b47c61f..065f9f79d 100644
--- a/src/views/im/home/store/conversationStore.ts
+++ b/src/views/im/home/store/conversationStore.ts
@@ -1,388 +1,183 @@
-import { defineStore, acceptHMRUpdate } from 'pinia'
-import { toRaw } from 'vue'
+import { acceptHMRUpdate, defineStore } from 'pinia'
import { store } from '@/store'
-import {
- ImConversationType,
- ImMessageType,
- ImMessageStatus,
- IM_AT_ALL_USER_ID,
- isGroupNotification,
- isMediaMessageType,
- isNormalMessage
-} from '../../utils/constants'
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
-import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage'
-import { parseRecallMessageId, revokeBlobUrlsInContent } from '../../utils/message'
-import { getConversationKey, resolveConversationLastContent } from '../../utils/conversation'
-import { tryGetSenderDisplayName } from '../../utils/user'
-import { useGroupStore } from './groupStore'
+import { ImConversationType } from '../../utils/constants'
+import { getClientConversationId, getDb, type DbTx } from '../../utils/db'
+import { getCurrentUserId } from '../../utils/storage'
import { useDraftStore } from './draftStore'
-import type { Conversation, ConversationStoreMeta, Message } from '../types'
+import { useMessageStore } from './messageStore'
+import type { Conversation, ConversationDO } from '../types'
-// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
-// TODO @芋艿:首次拉取消息时,如果消息过多,可能导致渲染卡顿。(1% 场景)
-
-/**
- * 算出新一条 lastSenderDisplayName 快照——caller 拿这个值去赋 conversation.lastSenderDisplayName
- *
- * 1. 能算出真名 → 用真名
- * 2. 算不出 + 同发送人 → 沿用旧快照(冷拉期间常见)
- * 3. 算不出 + 换发送人 → undefined(旧快照不再适用)
- *
- * 群聊算不出真名时顺手触发兜底拉成员(store 内部已并发去重),让后续渲染能命中真名
- */
-function deriveLastSenderDisplayName(
- conversation: Conversation,
- senderId: number
-): string | undefined {
- // 1. 严格版算名字:能拿到 displayUserName / 备注 / 真实昵称就直接用,对应规则 1
- const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
- if (liveSenderName) {
- return liveSenderName
- }
-
- // 2. 群聊兜底拉成员:分两种情况
- // a. members 完全没加载(!membersLoaded)→ 拉整群(pullOnce 期间多个 senderId 都缺时,单飞表会 dedup 成一次请求)
- // b. members 已加载但缺这一个(新加入的成员,本端未收到 GROUP_MEMBER_SETTING_UPDATE)→ 补齐这一个
- if (conversation.type === ImConversationType.GROUP) {
- const groupStore = useGroupStore()
- const group = groupStore.getGroup(conversation.targetId)
- const fetchPromise = group?.membersLoaded
- ? groupStore.fetchGroupMember(conversation.targetId, senderId)
- : groupStore.fetchGroupMembers(conversation.targetId)
- fetchPromise.catch((e) =>
- console.warn(
- '[IM conversationStore] 兜底拉群成员失败',
- { groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
- e
- )
- )
- }
-
- // 3. 算不出真名:同发送人沿用旧快照(规则 2),换人则清掉避免显示成上一个人(规则 3)
- const isSameSender = conversation.lastSenderId === senderId
- return isSameSender ? conversation.lastSenderDisplayName : undefined
-}
-
-/**
- * 按 conversation.messages 末尾重算 last* 系列摘要 / 事实索引
- *
- * 用于:删除最后一条消息 / loadConversations drop 媒体占位后;剩余消息为空则字段一并清空(含 lastSendTime=0),让空会话排到列表末尾。
- * 末条消息存在时,lastSendTime 取该消息的 sendTime;缺失时沿用 conversation 现值
- */
-function recomputeConversationLast(conversation: Conversation): void {
- const last = conversation.messages[conversation.messages.length - 1]
- if (last) {
- const senderDisplayName = deriveLastSenderDisplayName(conversation, last.senderId)
- conversation.lastContent = resolveConversationLastContent(
- last,
- conversation.type,
- conversation.targetId,
- senderDisplayName
- )
- conversation.lastSendTime = last.sendTime || conversation.lastSendTime
- conversation.lastSenderId = last.senderId
- conversation.lastMessageType = last.type
- conversation.lastSelfSend = last.selfSend
- conversation.lastSenderDisplayName = senderDisplayName
- } else {
- conversation.lastContent = ''
- conversation.lastSenderId = undefined
- conversation.lastMessageType = undefined
- conversation.lastSelfSend = undefined
- conversation.lastSenderDisplayName = undefined
- // 排序时间也要清,否则空会话仍按旧 lastSendTime 排在前面(刷新后媒体占位被 drop 时容易踩到)
- conversation.lastSendTime = 0
+/** 会话转 IndexedDB 记录 */
+function toConversationDO(conversation: Conversation): ConversationDO {
+ return {
+ ...conversation,
+ clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
}
}
-/**
- * 群聊未读消息把 @ 标记同步到会话;非群聊 / 自己发的 / 已读 / 没 @ 全跳过
- *
- * 同时被新消息插入路径和合并末尾消息路径调用,让 pull 拿到不完整 atUserIds 后 WS 补齐的场景,
- * 也能正确点亮 conversation.atMe / atAll 红字徽标
- */
-function syncConversationAtFlags(conversation: Conversation, message: Message): void {
- if (
- message.selfSend ||
- conversation.type !== ImConversationType.GROUP ||
- !message.atUserIds ||
- message.atUserIds.length === 0 ||
- message.status === ImMessageStatus.READ
- ) {
- return
- }
- const currentUserId = getCurrentUserId()
- if (currentUserId && message.atUserIds.includes(currentUserId)) {
- conversation.atMe = true
- }
- if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
- conversation.atAll = true
- }
-}
-
-/**
- * 把服务端字段(REST ack / WS 推送 / pull 回填)合并到本地消息
- *
- * - content 被替换时 revoke 旧 blob URL(媒体占位转真实 url 释放 File 内存)
- * - 状态离开 SENDING 后清 uploadProgress(让 isUploading 不再命中、进度遮罩消失)
- * - 状态非 FAILED 终态再清 _localFile(FAILED 留着供重试 uploadAndSendMedia)
- *
- * 同时被 ackMessage / insertMessage(existingIndex 覆盖路径) 使用,确保 REST / WS 两路都做相同清理
- */
-function applyServerMessageUpdate(message: Message, updates: Partial): void {
- if (updates.content && updates.content !== message.content) {
- revokeBlobUrlsInContent(message.content)
- }
- Object.assign(message, updates)
- if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
- message.uploadProgress = undefined
- if (updates.status !== ImMessageStatus.FAILED) {
- message._localFile = undefined
- }
- }
+/** IndexedDB 记录转会话 */
+function fromConversationDO(conversation: ConversationDO): Conversation {
+ const { clientConversationId: _clientConversationId, ...rest } = conversation
+ return rest
}
export const useConversationStore = defineStore('imConversationStore', {
state: () => ({
- conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
+ conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道)
activeConversation: null as Conversation | null, // 当前激活的会话
- loadedUserId: null as number | null, // 当前内存里这份 conversations 属于哪个用户;切账号时同 key 的 active 不再被误恢复
- privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标
- groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标
- channelMessageMaxId: 0, // 频道最大消息 id,作为 pull 的游标
- loading: false, // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
- recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表(按推送顺序倒序,最大 CONVERSATION_RECENT_FORWARD_MAX 个)
+ loading: false, // 是否正在批量加载
+ recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表
}),
getters: {
- /**
- * 会话列表排序规则:
- * 1. 置顶优先(top=true 的在前)
- * 2. 同级别按 lastSendTime 降序
- */
+ /** 排序后的会话列表 */
getSortedConversations(state): Conversation[] {
return [...state.conversations]
- .filter((c) => !c.deleted)
+ .filter((conversation) => !conversation.deleted)
.sort((a, b) => {
const aTop = a.top ? 1 : 0
const bTop = b.top ? 1 : 0
if (aTop !== bTop) {
return bTop - aTop
}
- return b.lastSendTime - a.lastSendTime
+ return (b.lastSendTime || 0) - (a.lastSendTime || 0)
})
},
- /** 当前会话的消息列表 */
- getActiveMessages(state): Message[] {
- return state.activeConversation?.messages || []
- },
- /** 未读总数(免打扰会话不计入)—— 用于 ToolBar 红点 */
+
+ /** 未读总数 */
getTotalUnread(state): number {
return state.conversations
- .filter((c) => !c.deleted && !c.silent)
- .reduce((sum, c) => sum + (c.unreadCount || 0), 0)
+ .filter((conversation) => !conversation.deleted && !conversation.silent)
+ .reduce((sum, conversation) => sum + (conversation.unreadCount || 0), 0)
},
- /** 查找会话:按 (type, targetId) 组合主键 */
+
+ /** 查找会话 */
getConversation:
(state) =>
- (type: number, targetId: number): Conversation | undefined => {
- return state.conversations.find((c) => c.type === type && c.targetId === targetId)
- }
+ (type: number, targetId: number): Conversation | undefined =>
+ state.conversations.find(
+ (conversation) => conversation.type === type && conversation.targetId === targetId
+ )
},
actions: {
- // ==================== 本地存储 ====================
-
- /**
- * 从 IndexedDB 恢复会话数据
- *
- * 1. 读 meta(游标 + 会话索引),无 meta 直接返回
- * 2. 并发读取每个会话的消息 key,组装回 Conversation
- * 3. 修正重启前遗留的"发送中"状态为失败
- */
+ /** 加载会话 */
+ // TODO @AI:方法里的代码段注释,写一下。
async loadConversations() {
const userId = getCurrentUserId()
if (!userId) {
- // 未登录场景也清一下,避免上次登录残留的 conversations / 游标继续在内存中生效
this.clear()
return
}
- // 加载前快照旧账号身份 + 旧 active 主键:仅同账号才恢复 active,跨账号即便新列表有同 key 也不误激活
- const previousLoadedUserId = this.loadedUserId
const previousActiveKey = this.activeConversation
- ? getConversationKey(this.activeConversation)
+ ? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
: null
- // 立刻清空内存:无 meta / 空会话列表 / 异常分支都会沿用清空后的干净状态,避免旧账号 conversations + 游标继续生效
this.clear()
- try {
- // 顺手把最近转发列表也恢复出来;和 meta 并发读
- const [meta, recent] = await Promise.all([
- imStorage.getItem(StorageKeys.conversationMeta(userId)),
- imStorage.getItem(StorageKeys.recentForwardConversationKeys(userId))
- ])
- // recent 失效保持开头 reset 给的 [];有值才覆盖
- if (Array.isArray(recent)) {
- this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
- }
- // 标记本次加载到内存里的是哪个用户的数据;放在所有早返回路径之前,让 "无 meta / 空会话" 也能在同账号 re-enter 时识别
- this.loadedUserId = userId
- if (!meta) {
- return
- }
- this.privateMessageMaxId = Number(meta.privateMessageMaxId) || 0
- this.groupMessageMaxId = Number(meta.groupMessageMaxId) || 0
- this.channelMessageMaxId = Number((meta as any).channelMessageMaxId) || 0
- if (!meta.conversations || meta.conversations.length === 0) {
- return
- }
-
- // 并发拉取每个会话的消息,组装回完整 Conversation;
- // 单会话失败时退化为空消息列表 + 打印日志,避免拖垮整体加载
- const tasks = meta.conversations.map(async (conversation): Promise => {
- try {
- const rawMessages =
- (await imStorage.getItem(
- StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId)
- )) || []
- // 【媒体消息】(IMAGE / FILE / VOICE / VIDEO)SENDING 或 FAILED 都直接 drop:
- // _localFile 持久化时已被剥掉,刷新后重传必拿不到 file,content 里又可能是失效的 blob URL,留着只会让用户点重试时把 blob URL 当真实 url 发到服务端
- // 【文本类】SENDING 转 FAILED 仍可重发(content 里就是 plain text)
- const messages = rawMessages.filter((message) => {
- const isMedia = isMediaMessageType(message.type)
- if (message.status === ImMessageStatus.SENDING) {
- if (isMedia) {
- return false
- }
- message.status = ImMessageStatus.FAILED
- return true
- }
- return !(message.status === ImMessageStatus.FAILED && isMedia)
- })
- const restored: Conversation = { ...conversation, messages }
- // 媒体占位被 drop 时,conversation 旧 lastContent / lastSendTime 等仍指向已不存在的占位,
- // 按剩余消息末尾重算,避免会话列表显示一条摘要、消息面板里却没对应消息
- if (messages.length !== rawMessages.length) {
- recomputeConversationLast(restored)
- }
- return restored
- } catch (e) {
- console.warn(
- '[IM] 单会话消息加载失败',
- { type: conversation.type, targetId: conversation.targetId },
- e
- )
- return { ...conversation, messages: [] }
- }
- })
- this.conversations = await Promise.all(tasks)
- // 重绑 active:同账号 + 旧 key 仍存在才恢复;跨账号即便有同 key 也不误激活。显式 ?? null 避免依赖 clear() 的隐式链
- if (previousLoadedUserId === userId && previousActiveKey) {
- this.activeConversation =
- this.conversations.find(
- (c) => !c.deleted && getConversationKey(c) === previousActiveKey
- ) ?? null
- }
- } catch (e) {
- console.error('[IM] 本地消息缓存读取失败', e)
- // 加载中途异常:避免半成品(maxId 已写但 conversations 未填)让 pullOnce 用错游标漏拉
- this.clear()
+ const db = getDb()
+ const [conversations, recent] = await Promise.all([
+ db.getAll('conversations'),
+ db.getSetting('recentForwardConversationKeys')
+ ])
+ this.conversations = conversations.map(fromConversationDO)
+ if (Array.isArray(recent)) {
+ this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
+ }
+ if (previousActiveKey) {
+ this.activeConversation =
+ this.conversations.find(
+ (conversation) =>
+ !conversation.deleted &&
+ getClientConversationId(conversation.type, conversation.targetId) ===
+ previousActiveKey
+ ) ?? null
}
},
- /** loadConversations 开头 + 中途失败统一兜底;切账号 / 异常时让旧会话 + 游标不残留。命名与 friendStore.clear / groupStore.clear 对齐 */
+ /** 清空会话内存 */
clear() {
this.conversations = []
this.activeConversation = null
- this.loadedUserId = null
- this.privateMessageMaxId = 0
- this.groupMessageMaxId = 0
- this.channelMessageMaxId = 0
this.recentForwardConversationKeys = []
},
- /**
- * 持久化到 IndexedDB(fire-and-forget;调用方无需 await)
- *
- * - 不传 target:仅写 meta(适用于 top / silent / unread 等元数据变更)
- * - 传单个 conversation:写 meta + 该会话的消息(单条消息变更走这里)
- * - 传数组:写 meta + 数组里所有未删除会话的消息(loading 完成后兜底 flush 用)
- *
- * 按会话分桶后,单条消息变更只重写当前会话的消息 key,避免老方案的全量序列化;写入失败已在内部 catch 兜底(仅打印日志)不影响 UI 流程,所以接口签名设为 void
- */
- saveConversations(target?: Conversation | Conversation[] | null): void {
- // loading 期间跳过,避免离线消息批量到达时的密集写入
- if (this.loading) {
+ /** 执行会话持久化 */
+ // TODO @AI:想了下,DbTx 改成 DbTransaction 吧,变量可以叫 tx;
+ // TODO @AI:方法里的代码段注释,写一下。
+ async persistConversations(
+ target?: Conversation | Conversation[] | null,
+ tx?: DbTx
+ ): Promise {
+ const db = getDb()
+ const conversations = (
+ Array.isArray(target) ? target : target ? [target] : this.conversations
+ ).map(toConversationDO)
+ if (tx) {
+ for (const conversation of conversations) {
+ await db.put('conversations', conversation, tx)
+ }
return
}
- const userId = getCurrentUserId()
- if (!userId) {
- return
- }
- // 1. meta:游标 + 会话索引(剔除 messages,过滤软删除)
- const meta: ConversationStoreMeta = {
- privateMessageMaxId: this.privateMessageMaxId,
- groupMessageMaxId: this.groupMessageMaxId,
- channelMessageMaxId: this.channelMessageMaxId,
- conversations: this.conversations
- .filter((c) => !c.deleted)
- .map(({ messages, ...rest }) => rest)
- } as ConversationStoreMeta
- const tasks: Promise[] = [
- imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
- ]
- // 2. 归一化 target 为待 flush 的会话列表,过滤掉已软删除的
- const conversationsToFlush: Conversation[] = (
- Array.isArray(target) ? target : target ? [target] : []
- ).filter((c) => !c.deleted)
- for (const conversation of conversationsToFlush) {
- // ① toRaw 拆掉 Vue reactive Proxy:IDB 的 structuredClone 不接受 Proxy,不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢)
- // ② 剥 _localFile:IDB 能 structuredClone File 对象,但视频几百 MB 落盘没意义;
- // fast path:先扫一眼整条链路有无 _localFile,没有就直接 toRaw —— 绝大部分会话不会同时有上传中的媒体,每次 ack 都全量 map+spread 浪费
- const rawMessages = toRaw(conversation.messages)
- const hasLocalFile = rawMessages.some((message) => message._localFile != null)
- const messagesForFlush = hasLocalFile
- ? rawMessages.map((message) => {
- if (message._localFile == null) {
- return message
- }
- const { _localFile: _omitted, ...rest } = message
- return rest
- })
- : rawMessages
- tasks.push(
- imStorage.setItem(
- StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
- messagesForFlush
- )
- )
- }
- // 3. fire-and-forget:失败仅打日志,不影响 UI
- void Promise.all(tasks).catch((e) => console.error('[IM] 本地消息缓存存储失败', e))
+ await db.transaction(['conversations'], 'readwrite', async (innerTx) => {
+ for (const conversation of conversations) {
+ await db.put('conversations', conversation, innerTx)
+ }
+ })
},
- /** 物理删除某个会话的消息 key(软删除会话时释放空间;fire-and-forget) */
- removeConversationMessages(type: number, targetId: number): void {
- const userId = getCurrentUserId()
- if (!userId) {
+ /** 持久化会话 */
+ saveConversations(target?: Conversation | Conversation[] | null, tx?: DbTx): void {
+ if (this.loading && !tx) {
return
}
- removeQuietly(
- StorageKeys.conversationMessages(userId, type, targetId),
- '[IM] 本地消息缓存删除失败'
+ void this.persistConversations(target, tx).catch((e) =>
+ console.warn('[IM conversationStore] 会话写入失败', e)
)
},
- // ==================== 会话查找 / 打开 ====================
+ /** 确保会话存在 */
+ // TODO @AI:方法里的代码段注释,写一下。
+ ensureConversation(info: {
+ type: number
+ targetId: number
+ name: string
+ avatar: string
+ silent?: boolean
+ }): Conversation {
+ let conversation = this.getConversation(info.type, info.targetId)
+ if (!conversation) {
+ conversation = this.createEmptyConversation(
+ info.type,
+ info.targetId,
+ info.name,
+ info.avatar,
+ info.silent
+ )
+ this.conversations.unshift(conversation)
+ } else if (conversation.deleted) {
+ conversation.deleted = false
+ conversation.name = info.name || conversation.name
+ conversation.avatar = info.avatar || conversation.avatar
+ if (info.silent !== undefined) {
+ conversation.silent = info.silent
+ }
+ } else {
+ if (info.name) {
+ conversation.name = info.name
+ }
+ if (info.avatar) {
+ conversation.avatar = info.avatar
+ }
+ if (info.silent !== undefined) {
+ conversation.silent = info.silent
+ }
+ }
+ return conversation
+ },
- /**
- * 打开或创建一个会话,并设为激活
- *
- * 调用方应该把从 friendStore / groupStore 拿到的最新元数据(silent 等)
- * 通过 options 传进来,避免新建/复用的会话显示陈旧状态。
- * 此处不在 conversationStore 里反向 import friendStore/groupStore,是为了避免循环依赖。
- */
+ /** 打开或创建会话 */
+ // TODO @AI:方法里的代码段注释,写一下。
openConversation(
targetId: number,
type: number,
@@ -390,49 +185,39 @@ export const useConversationStore = defineStore('imConversationStore', {
avatar: string,
options?: { silent?: boolean }
): Conversation {
- // 按 (type, targetId) 查找已有会话,不存在则新建并插到列表头部
- let conversation = this.getConversation(type, targetId)
- if (!conversation) {
- conversation = this.createEmptyConversation(type, targetId, name, avatar)
- if (options?.silent !== undefined) {
- conversation.silent = options.silent
- }
- this.conversations.unshift(conversation)
- } else {
- // 已存在会话:用最新元数据刷新 name / avatar / silent
- if (name) {
- conversation.name = name
- }
- if (avatar) {
- conversation.avatar = avatar
- }
- if (options?.silent !== undefined) {
- conversation.silent = options.silent
- }
- }
+ const conversation = this.ensureConversation({
+ type,
+ targetId,
+ name,
+ avatar,
+ silent: options?.silent
+ })
this.setActiveConversation(conversation)
+ this.saveConversations(conversation)
return conversation
},
- /** 设置当前会话,同时清零未读数 + 清除 @ 标记 */
+ /** 设置当前会话 */
+ // TODO @AI:方法里的代码段注释,写一下。
setActiveConversation(conversation: Conversation | null) {
this.activeConversation = conversation
- if (conversation) {
- conversation.unreadCount = 0
- conversation.atMe = false
- conversation.atAll = false
- // 仅元数据变更(unreadCount / atMe / atAll),不动 messages
- this.saveConversations()
+ if (!conversation) {
+ return
}
+ conversation.unreadCount = 0
+ conversation.atMe = false
+ conversation.atAll = false
+ void useMessageStore().ensureLoaded(conversation)
+ this.saveConversations(conversation)
},
- /** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用);调用方传 silent 时按 friend / group store 的值落地,未传保持默认 false */
+ /** 创建空会话 */
createEmptyConversation(
type: number,
targetId: number,
name: string,
avatar: string,
- silent: boolean = false
+ silent = false
): Conversation {
return {
targetId,
@@ -442,7 +227,6 @@ export const useConversationStore = defineStore('imConversationStore', {
lastContent: '',
lastSendTime: 0,
unreadCount: 0,
- messages: [],
deleted: false,
top: false,
silent,
@@ -451,29 +235,29 @@ export const useConversationStore = defineStore('imConversationStore', {
}
},
- // ==================== 置顶 / 免打扰 / 删除会话 ====================
-
- /** 将某个会话置顶态切换 */
+ /** 设置置顶 */
setTop(type: number, targetId: number, top: boolean) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
conversation.top = top
- this.saveConversations()
+ this.saveConversations(conversation)
},
- /** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /silent API 负责) */
+ /** 设置免打扰 */
setSilent(type: number, targetId: number, silent: boolean) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
conversation.silent = silent
- this.saveConversations()
+ // TODO @AI:saveConversations 拆成 saveConversationList、saveConversation 两个方法;
+ this.saveConversations(conversation)
},
- /** 删除会话(软删:标记 deleted=true,持久化时过滤;同步物理删除消息 key 释放空间)*/
+ /** 删除会话 */
+ // TODO @AI:方法里的代码段注释,写一下。
removeConversation(type: number, targetId: number) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
@@ -482,391 +266,23 @@ export const useConversationStore = defineStore('imConversationStore', {
if (this.activeConversation === conversation) {
this.activeConversation = null
}
- // 释放媒体占位的 blob URL + _localFile:未持久化资源,软删后没人渲染,留着只占内存(视频几百 MB),
- // 同步清空 messages 让 GC 早回收(软删的会话被 getSortedConversations 过滤,messages 留着无意义)
- conversation.messages.forEach((message) => {
- revokeBlobUrlsInContent(message.content)
- message._localFile = undefined
- })
- conversation.messages = []
conversation.deleted = true
- // 软删后会话的消息文件不再有用,物理删除该 key
- this.removeConversationMessages(type, targetId)
- // 同步清掉该会话的草稿,避免重建同 key 会话时残留 [草稿]
+ useMessageStore().deleteConversationMessages(type, targetId)
useDraftStore().clearDraft({ type, targetId })
- this.saveConversations()
+ this.saveConversations(conversation)
},
- /** 删私聊会话的语义糖:friendStore 删好友时调,避免外面手写 ImConversationType.PRIVATE */
+ /** 删除私聊会话 */
removePrivateConversation(friendId: number) {
this.removeConversation(ImConversationType.PRIVATE, friendId)
},
- /** 删群聊会话的语义糖:groupStore 群解散时调,避免外面手写 ImConversationType.GROUP */
+ /** 删除群聊会话 */
removeGroupConversation(groupId: number) {
this.removeConversation(ImConversationType.GROUP, groupId)
},
- // ==================== 消息插入 / 更新 ====================
-
- /**
- * 插入消息到会话
- *
- * 主要行为(子步骤见函数内 // x.y 注释):
- * 1. 会话定位:查找或创建 + 去重合并
- * 2. 更新会话元数据:摘要、@ 标记、未读数
- * 3. 按 id 有序插入消息
- * 4. 收尾:更新游标 + 持久化
- */
- insertMessage(
- conversationInfo: {
- type: number
- targetId: number
- name: string
- avatar: string
- silent?: boolean // 调用方按 friend / group store 当前 silent 传入;未传不动会话已有值
- },
- messageInfo: Message
- ) {
- // 0. 群广播事件旁路:按 type 局部更新 groupStore 的 role / ownerUserId / 成员列表等状态
- if (
- conversationInfo.type === ImConversationType.GROUP &&
- isGroupNotification(messageInfo.type)
- ) {
- useGroupStore().applyGroupNotification(
- conversationInfo.targetId,
- messageInfo.type,
- messageInfo.content
- )
- }
-
- // 1.1 查找或自动创建会话;命中软删会话需要复活(场景:A 退群后被重新拉入、用户主动删了对话又收到新消息)
- // silent 跟随调用方:新建写入;复活 / 已有会话仅在调用方明确传值时覆盖,避免本地 silent 与 friend / group store 漂移
- let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId)
- if (!conversation) {
- conversation = this.createEmptyConversation(
- conversationInfo.type,
- conversationInfo.targetId,
- conversationInfo.name,
- conversationInfo.avatar,
- conversationInfo.silent
- )
- this.conversations.unshift(conversation)
- } else if (conversation.deleted) {
- conversation.deleted = false
- conversation.name = conversationInfo.name || conversation.name
- conversation.avatar = conversationInfo.avatar || conversation.avatar
- if (conversationInfo.silent !== undefined) {
- conversation.silent = conversationInfo.silent
- }
- } else if (conversationInfo.silent !== undefined) {
- conversation.silent = conversationInfo.silent
- }
-
- // 1.2 去重合并:优先按 id,其次按 clientMessageId。命中则覆盖更新并返回
- const existingIndex = conversation.messages.findIndex((message) => {
- if (messageInfo.id && message.id && message.id === messageInfo.id) {
- return true
- }
- return !!(
- messageInfo.clientMessageId &&
- message.clientMessageId &&
- message.clientMessageId === messageInfo.clientMessageId
- )
- })
- if (existingIndex >= 0) {
- // 覆盖更新:与 ackMessage 走同一份 applyServerMessageUpdate;
- // WebSocket / pull 比 REST ack 先到的场景下,blob revoke 和 uploadProgress / _localFile 清理在这里完成
- applyServerMessageUpdate(conversation.messages[existingIndex], messageInfo)
- // 仅合并到末尾那条时刷会话摘要 + 群 @ 标记;中间位置合并不动会话级 last*,避免后到的早消息把排序时间倒着拉回去
- if (existingIndex === conversation.messages.length - 1) {
- recomputeConversationLast(conversation)
- syncConversationAtFlags(conversation, messageInfo)
- }
- this.updateMaxId(conversationInfo.type, messageInfo.id)
- this.saveConversations(conversation)
- return
- }
-
- // 2.1 更新会话摘要 + 最后一条消息事实索引(含发送人名快照)。
- // deriveLastSenderDisplayName 必须在更新 lastSenderId 之前调用,靠旧值判断"同发送人"
- const senderDisplayName = deriveLastSenderDisplayName(conversation, messageInfo.senderId)
- conversation.lastContent = resolveConversationLastContent(
- messageInfo,
- conversation.type,
- conversation.targetId,
- senderDisplayName
- )
- conversation.lastSendTime = messageInfo.sendTime || Date.now()
- conversation.lastSenderId = messageInfo.senderId
- conversation.lastMessageType = messageInfo.type
- conversation.lastSelfSend = messageInfo.selfSend
- conversation.lastSenderDisplayName = senderDisplayName
-
- // 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
- syncConversationAtFlags(conversation, messageInfo)
-
- // 2.3 未读数:非当前会话 + 非自己发送 + 普通消息 + 非已读 => +1
- const isActive =
- this.activeConversation?.type === conversationInfo.type &&
- this.activeConversation?.targetId === conversationInfo.targetId
- if (
- !messageInfo.selfSend &&
- !isActive &&
- isNormalMessage(messageInfo.type) &&
- messageInfo.status !== ImMessageStatus.READ &&
- messageInfo.status !== ImMessageStatus.RECALL
- ) {
- conversation.unreadCount++
- }
-
- // 3. 按真实 id 升序插入;id=0 的本地占位(SENDING)固定停在末尾,
- // 并发场景下:占位仍在末尾时收到的真实消息会追加在占位之后,列表不严格按 id 升序
- let insertIndex = conversation.messages.length
- if (messageInfo.id) {
- for (let index = 0; index < conversation.messages.length; index++) {
- const existing = conversation.messages[index]
- if (existing.id && messageInfo.id < existing.id) {
- insertIndex = index
- break
- }
- }
- }
- conversation.messages.splice(insertIndex, 0, messageInfo)
-
- // 4.1 更新游标
- this.updateMaxId(conversationInfo.type, messageInfo.id)
-
- // 4.2 持久化:消息 + meta
- this.saveConversations(conversation)
- },
-
- /**
- * 根据 clientMessageId 更新消息状态
- *
- * 乐观更新回填:本地先以 SENDING 状态插入临时消息(id=0 + clientMessageId),
- * 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。
- */
- ackMessage(
- conversationType: number,
- targetId: number,
- clientMessageId: string,
- updates: Partial
- ) {
- const conversation = this.getConversation(conversationType, targetId)
- if (!conversation) {
- return
- }
- const messageIndex = conversation.messages.findIndex(
- (item) => item.clientMessageId === clientMessageId
- )
- if (messageIndex < 0) {
- return
- }
- applyServerMessageUpdate(conversation.messages[messageIndex], updates)
- // ack 命中末尾消息时按服务端 sendTime / content 重算会话摘要,让会话列表跟着权威值排序
- if (messageIndex === conversation.messages.length - 1) {
- recomputeConversationLast(conversation)
- }
- if (updates.id) {
- this.updateMaxId(conversationType, updates.id)
- }
- this.saveConversations(conversation)
- },
-
- /**
- * 局部更新一条本地消息(不持久化、不更新游标)
- *
- * 媒体上传链路高频调用:onUploadProgress 每次回调都 patch uploadProgress;上传完成 patch content(替换 blob → 真实 url)。
- * 不落 IDB 是性能取舍 ── progress 高频写盘会卡 UI;后续 sendRaw 的 ackMessage 会自然把最终态持久化
- */
- patchMessage(
- conversationType: number,
- targetId: number,
- clientMessageId: string,
- patch: Partial
- ) {
- const conversation = this.getConversation(conversationType, targetId)
- if (!conversation) {
- return
- }
- const message = conversation.messages.find((item) => item.clientMessageId === clientMessageId)
- if (!message) {
- return
- }
- // 值未变就早返回:onUploadProgress 高频回调里同 percent 重复 patch 时直接跳过响应式更新链
- // (createUploadProgressHandler 在源头已去重;这里是最后兜底,对 patch.uploadProgress / status 等字段都生效)
- let changed = false
- for (const key in patch) {
- if (
- Object.prototype.hasOwnProperty.call(patch, key) &&
- (patch as Record)[key] !==
- (message as unknown as Record)[key]
- ) {
- changed = true
- break
- }
- }
- if (!changed) {
- return
- }
- applyServerMessageUpdate(message, patch)
- },
-
- /**
- * 撤回消息:解析撤回信号 content(`{"messageId": xxx}`),找到原消息更新为 RECALL 态 + 刷新会话摘要
- * 撤回提示文案不固化,由 ConversationItem / MessageItem 渲染时调 buildRecallTip 实时算
- */
- recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
- const messageId = parseRecallMessageId(recallSignalContent)
- if (messageId <= 0) {
- return
- }
- const conversation = this.getConversation(conversationType, targetId)
- if (!conversation) {
- return
- }
- const message = conversation.messages.find((item) => item.id === messageId)
- if (!message) {
- return
- }
- message.type = ImMessageType.RECALL
- message.status = ImMessageStatus.RECALL
- // 清空 content:撤回文案由渲染层 buildRecallTip 实时算,老 content 留着会被误认为有效消息文本
- message.content = ''
- // 最后一条消息是刚撤回的,才更新会话摘要 + lastMessageType(senderId 不变,沿用旧快照)
- if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
- conversation.lastContent = resolveConversationLastContent(
- message,
- conversation.type,
- conversation.targetId,
- conversation.lastSenderDisplayName
- )
- conversation.lastMessageType = ImMessageType.RECALL
- }
- this.saveConversations(conversation)
- },
-
- /** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */
- applyReadReceipt(options: {
- conversationType: number
- targetId: number
- // 私聊:把和该好友的「自己发送的、id <= privateReadMaxId 的」消息标为已读
- // 必须卡 maxId 边界:回执在路上时新发的消息不能被误标为已读
- privateReadMaxId?: number
- // 群聊:针对单条消息的回执刷新
- groupMessageId?: number
- readCount?: number
- receiptStatus?: number
- }) {
- const conversation = this.getConversation(options.conversationType, options.targetId)
- if (!conversation) {
- return
- }
- if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
- const maxReadId = options.privateReadMaxId
- conversation.messages.forEach((message) => {
- if (
- message.selfSend &&
- message.id &&
- message.id <= maxReadId &&
- message.status !== ImMessageStatus.RECALL
- ) {
- message.status = ImMessageStatus.READ
- }
- })
- } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
- const message = conversation.messages.find((item) => item.id === options.groupMessageId)
- if (message) {
- if (options.readCount !== undefined) {
- message.readCount = options.readCount
- }
- if (options.receiptStatus !== undefined) {
- message.receiptStatus = options.receiptStatus
- }
- }
- }
- this.saveConversations(conversation)
- },
-
- /**
- * 把"更早的历史消息"批量插到会话消息列表的最前面(合并 + 去重)
- *
- * MessageHistory 弹窗的"加载更早"按钮调用:调用方先调 /im/message/{private,group}/list 拉一页 +
- * 用 useMessagePuller 的 convert 函数转好,再传进来。
- *
- * 不更新 lastContent / lastSendTime / unreadCount:这些字段反映"最新一条",加载老消息时不应改动;
- * 也不触发 conversation 排序,避免会话列表抖动
- */
- prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
- if (earlierMessages.length === 0) {
- return
- }
- const conversation = this.getConversation(conversationType, targetId)
- if (!conversation) {
- return
- }
- // 1. 去重:拿当前会话已有消息的 id 集合,把入参里 id 撞上的过滤掉
- // (后端返回的"更早消息"可能跟本地缓存有重叠,比如增量 pull 拉到过同一段)
- // id=0 是本地占位消息,不参与去重判定(也不会被 prepend,下面 filter 一并卡掉)
- const existingIds = new Set(
- conversation.messages.map((message) => message.id).filter((id) => id > 0)
- )
- // 2. 过滤后按 id 升序:list 接口虽然按 id desc 返回,前端要展示成"早 → 晚",
- // 所以 prepend 之前先 sort asc,让 fresh 数组本身的相对顺序符合时间线
- const fresh = earlierMessages
- .filter((message) => message.id > 0 && !existingIds.has(message.id))
- .sort((a, b) => a.id - b.id)
- if (fresh.length === 0) {
- return
- }
- // 3. 拼接 + 落盘:fresh 在前、原 messages 在后;持久化让下次冷启动不用再调接口
- conversation.messages = [...fresh, ...conversation.messages]
- this.saveConversations(conversation)
- },
-
- /**
- * 从本地消息列表移除一条消息(右键"删除";不同步后端)
- * 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配
- */
- removeMessage(
- conversationType: number,
- targetId: number,
- key: { id?: number; clientMessageId?: string }
- ) {
- const conversation = this.getConversation(conversationType, targetId)
- if (!conversation) {
- return
- }
- const index = conversation.messages.findIndex((message) => {
- if (key.id && message.id && message.id === key.id) {
- return true
- }
- return !!(
- key.clientMessageId &&
- message.clientMessageId &&
- message.clientMessageId === key.clientMessageId
- )
- })
- if (index < 0) {
- return
- }
- // 媒体消息占位 / FAILED 删除时释放 content 里的 blob URL,避免 File 对象内存泄漏
- revokeBlobUrlsInContent(conversation.messages[index].content)
- conversation.messages.splice(index, 1)
- // 删的是最后一条时按剩余末尾重算摘要 + 事实索引
- if (index === conversation.messages.length) {
- recomputeConversationLast(conversation)
- }
- this.saveConversations(conversation)
- },
-
- /**
- * 跨端 READ 推送收到时把指定会话清成"全已读":unread + atMe + atAll 一起清;避免群里跨端读完但本端 @ 红字残留
- *
- * 与 markActiveAsRead 的区别:本方法不更新 messages 单条 status(跨端推送只携带 conversation 级游标),
- * 仅刷会话级未读 / @ 状态;如果未读和 @ 都已经是 false,直接 noop 避免无效 saveConversations
- */
+ /** 标记会话已读 */
markConversationAsRead(type: number, targetId: number) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
@@ -881,32 +297,9 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations(conversation)
},
- /**
- * 当前会话全部标记为已读(切换会话 / 手动触发)
- * 只处理「对方发来的、尚未读」的消息
- */
- markActiveAsRead() {
- if (!this.activeConversation) {
- return
- }
- this.activeConversation.unreadCount = 0
- this.activeConversation.atMe = false
- this.activeConversation.atAll = false
- this.activeConversation.messages.forEach((message) => {
- if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
- message.status = ImMessageStatus.READ
- }
- })
- this.saveConversations(this.activeConversation)
- },
+ // TODO @AI:把最近转发 ==== 拆分下???
- // ==================== 最近转发 ====================
-
- /**
- * 推送一批会话 key 到最近转发列表:去重 + 推到队首 + 截断 CONVERSATION_RECENT_FORWARD_MAX
- *
- * 调用点:RecommendCardDialog / MessageForwardDialog 提交后(含部分成功)把目标 keys 推进来
- */
+ /** 推送最近转发会话 */
pushRecentForwardConversationKeys(keys: string[]) {
if (!keys || keys.length === 0) {
return
@@ -919,11 +312,7 @@ export const useConversationStore = defineStore('imConversationStore', {
this.persistRecentForwardConversationKeys()
},
- /**
- * 从最近转发列表移除单条会话 key
- *
- * 调用点:ConversationPickerPanel「最近转发」段进入移除模式时点击 × 触发
- */
+ /** 移除最近转发会话 */
removeRecentForwardConversationKey(key: string) {
const index = this.recentForwardConversationKeys.indexOf(key)
if (index < 0) {
@@ -933,60 +322,23 @@ export const useConversationStore = defineStore('imConversationStore', {
this.persistRecentForwardConversationKeys()
},
- /** 把当前最近转发会话 key 列表落到 IDB;fire-and-forget,按 userId 分桶 */
+ /** 持久化最近转发会话 */
persistRecentForwardConversationKeys() {
- const userId = getCurrentUserId()
- if (!userId) {
- return
- }
- void imStorage
- .setItem(
- StorageKeys.recentForwardConversationKeys(userId),
- toRaw(this.recentForwardConversationKeys)
+ void getDb()
+ .setSetting(
+ 'recentForwardConversationKeys',
+ this.recentForwardConversationKeys.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
)
- .catch((e) => console.warn('[IM] 最近转发列表持久化失败', e))
+ .catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
},
- // ==================== 其它 ====================
-
- /** 更新 privateMessageMaxId / groupMessageMaxId 游标 */
- updateMaxId(conversationType: number, messageId?: number) {
- if (!messageId) {
- return
- }
- if (conversationType === ImConversationType.PRIVATE) {
- if (messageId > this.privateMessageMaxId) {
- this.privateMessageMaxId = messageId
- }
- } else if (conversationType === ImConversationType.GROUP) {
- if (messageId > this.groupMessageMaxId) {
- this.groupMessageMaxId = messageId
- }
- } else if (conversationType === ImConversationType.CHANNEL) {
- if (messageId > this.channelMessageMaxId) {
- this.channelMessageMaxId = messageId
- }
- }
- },
-
- /**
- * 离线消息加载完后重排:按 lastSendTime 倒序,并把 loading 期间累积的内存变更全量 flush
- *
- * loading 期间 saveConversations 都会被早 return 跳过,这里把所有会话作为数组传入兜底,
- * 否则离线拉取的消息只在内存里、未落盘,重启会丢。
- */
+ /** 重排会话 */
sortConversations() {
- this.conversations.sort((a, b) => b.lastSendTime - a.lastSendTime)
+ this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
this.saveConversations(this.conversations)
},
- /**
- * 同步会话的展示元数据(name / avatar / silent)
- *
- * 调用方负责把好友 / 群的信息整理成 Conversation 视角的字段:
- * - 私聊:name = friend.nickname;avatar = friend.avatar
- * - 群聊:name = group.name(或叠加 groupRemark);avatar = group.avatar
- */
+ /** 同步会话展示元数据 */
updateConversation(
type: number,
targetId: number,
@@ -1010,18 +362,14 @@ export const useConversationStore = defineStore('imConversationStore', {
changed = true
}
if (changed) {
- this.saveConversations()
+ this.saveConversations(conversation)
}
}
}
})
-export const useConversationStoreWithOut = () => {
- return useConversationStore(store)
-}
+export const useConversationStoreWithOut = () => useConversationStore(store)
-// dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷
-// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
}
diff --git a/src/views/im/home/store/draftStore.ts b/src/views/im/home/store/draftStore.ts
index e269b7567..d36fc639b 100644
--- a/src/views/im/home/store/draftStore.ts
+++ b/src/views/im/home/store/draftStore.ts
@@ -124,6 +124,13 @@ export const useDraftStore = defineStore('imDraft', {
/** 立即落盘待写的草稿;beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
flushPersist(): void {
persistBucket.flush()
+ },
+
+ /** 清空草稿内存 */
+ // TODO @AI:写草稿,是不是融合到 conversationStore 里。
+ clear(): void {
+ persistBucket.cancel()
+ this.drafts = {}
}
}
})
diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts
index 76fcfe744..97ead3313 100644
--- a/src/views/im/home/store/groupStore.ts
+++ b/src/views/im/home/store/groupStore.ts
@@ -514,7 +514,7 @@ export const useGroupStore = defineStore('imGroupStore', {
/**
* 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action
*
- * WebSocket 实时收 + useMessagePuller 离线 pull 都走 conversationStore.insertMessage 旁路调用
+ * WebSocket 实时收 + useMessagePuller 离线 pull 都走 messageStore.insertMessage 旁路调用
* store 里没缓存的群静默忽略,等 fetchGroups 兜底
*/
applyGroupNotification(groupId: number, type: number, content?: string) {
@@ -567,7 +567,9 @@ export const useGroupStore = defineStore('imGroupStore', {
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
if (isSelfInPayloadMembers(payload)) {
- useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
+ useGroupRequestStore()
+ .fetchUnhandledList()
+ .catch(() => undefined)
}
break
case ImMessageType.GROUP_ADMIN_REMOVE:
@@ -701,7 +703,9 @@ export const useGroupStore = defineStore('imGroupStore', {
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
const selfUserId = getCurrentUserId()
if (selfUserId && payload.newOwnerUserId === selfUserId) {
- useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
+ useGroupRequestStore()
+ .fetchUnhandledList()
+ .catch(() => undefined)
}
},
@@ -802,7 +806,9 @@ function convertGroup(group: ImGroupRespVO): Group {
}
/** 后端 ImGroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
-function convertGroupMessageVO(message: NonNullable[number]): Message {
+function convertGroupMessageVO(
+ message: NonNullable[number]
+): Message {
const currentUserId = getCurrentUserId()
return {
id: message.id,
diff --git a/src/views/im/home/store/messageStore.ts b/src/views/im/home/store/messageStore.ts
new file mode 100644
index 000000000..78ff18b8d
--- /dev/null
+++ b/src/views/im/home/store/messageStore.ts
@@ -0,0 +1,839 @@
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { store } from '@/store'
+
+import {
+ IM_AT_ALL_USER_ID,
+ ImConversationType,
+ ImMessageStatus,
+ ImMessageType,
+ isGroupNotification,
+ isNormalMessage
+} from '../../utils/constants'
+import {
+ getClientConversationId,
+ getClientMessageKey,
+ getDb,
+ getServerMessageKey,
+ parseClientConversationId,
+ setMessageMaxId,
+ type DbTx
+} from '../../utils/db'
+import {
+ generateClientMessageId,
+ parseRecallMessageId,
+ revokeBlobUrlsInContent
+} from '../../utils/message'
+import { resolveConversationLastContent } from '../../utils/conversation'
+import { getCurrentUserId } from '../../utils/storage'
+import { tryGetSenderDisplayName } from '../../utils/user'
+import { useGroupStore } from './groupStore'
+import { useConversationStore } from './conversationStore'
+import type { Conversation, Message, MessageDO } from '../types'
+
+const MESSAGE_CACHE_CONVERSATION_LIMIT = 5
+const ackMergingPromises = new Map>()
+
+interface MessageConversationInfo {
+ type: number
+ targetId: number
+ name: string
+ avatar: string
+ silent?: boolean
+}
+
+// TODO @AI:叫这个 type 有点奇怪,可能需要再考虑下。
+export type PulledMessageBatchItem =
+ | {
+ kind: 'insert'
+ conversationInfo: MessageConversationInfo
+ message: Message
+ }
+ | {
+ kind: 'recall'
+ conversationType: number
+ targetId: number
+ recallSignalContent: string
+ }
+
+/** 获取会话的消息缓存 key */
+function getMessageCacheKey(type: number, targetId: number): string {
+ return getClientConversationId(type, targetId)
+}
+
+/** 生成消息本地主键 */
+function getMessageKey(
+ message: Pick,
+ conversationType: number
+): string {
+ return message.id
+ ? getServerMessageKey(conversationType, message.id)
+ : getClientMessageKey(message.clientMessageId)
+}
+
+/** 补齐客户端消息编号 */
+function ensureClientMessageId(message: Message): Message {
+ if (!message.clientMessageId) {
+ message.clientMessageId = generateClientMessageId()
+ }
+ if (!message.id) {
+ message.id = undefined
+ }
+ return message
+}
+
+/** 转换为 IndexedDB 消息记录 */
+// TODO @AI:buildXXX 更合理。
+function toMessageDO(message: Message, conversationType: number): MessageDO {
+ const {
+ uploadProgress: _uploadProgress,
+ _localFile: _localFile,
+ _ackMerging: _ackMerging,
+ ...rest
+ } = message
+ return {
+ ...rest,
+ messageKey: getMessageKey(message, conversationType),
+ conversationType,
+ clientConversationId: getClientConversationId(conversationType, message.targetId)
+ }
+}
+
+/** IndexedDB 消息记录转前端消息 */
+// TODO @AI:buildXXX 更合理。
+function fromMessageDO(message: MessageDO): Message {
+ const {
+ messageKey: _messageKey,
+ conversationType: _conversationType,
+ clientConversationId: _clientConversationId,
+ ...rest
+ } = message
+ return rest
+}
+
+/** 算出末条消息的发送人快照 */
+// TODO @AI:里面的代码注释;最好写下;
+function deriveLastSenderDisplayName(
+ conversation: Conversation,
+ senderId: number
+): string | undefined {
+ const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
+ if (liveSenderName) {
+ return liveSenderName
+ }
+ if (conversation.type === ImConversationType.GROUP) {
+ const groupStore = useGroupStore()
+ const group = groupStore.getGroup(conversation.targetId)
+ const fetchPromise = group?.membersLoaded
+ ? groupStore.fetchGroupMember(conversation.targetId, senderId)
+ : groupStore.fetchGroupMembers(conversation.targetId)
+ fetchPromise.catch((e) =>
+ console.warn(
+ '[IM messageStore] 兜底拉群成员失败',
+ { groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
+ e
+ )
+ )
+ }
+ return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
+}
+
+/** 按消息更新会话摘要 */
+function applyConversationSummary(conversation: Conversation, message: Message): void {
+ const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId)
+ conversation.lastContent = resolveConversationLastContent(
+ message,
+ conversation.type,
+ conversation.targetId,
+ senderDisplayName
+ )
+ conversation.lastSendTime = message.sendTime || Date.now()
+ conversation.lastSenderId = message.senderId
+ conversation.lastMessageType = message.type
+ conversation.lastMessageId = message.id
+ conversation.lastClientMessageId = message.clientMessageId
+ conversation.lastMessageStatus = message.status
+ conversation.lastReceiptStatus = message.receiptStatus
+ conversation.lastSelfSend = message.selfSend
+ conversation.lastSenderDisplayName = senderDisplayName
+}
+
+/** 按末条消息重算会话摘要 */
+function recomputeConversationLast(conversation: Conversation, messages: Message[]): void {
+ const last = messages[messages.length - 1]
+ if (last) {
+ applyConversationSummary(conversation, last)
+ return
+ }
+ conversation.lastContent = ''
+ conversation.lastSendTime = 0
+ conversation.lastSenderId = undefined
+ conversation.lastMessageType = undefined
+ conversation.lastMessageId = undefined
+ conversation.lastClientMessageId = undefined
+ conversation.lastMessageStatus = undefined
+ conversation.lastReceiptStatus = undefined
+ conversation.lastSelfSend = undefined
+ conversation.lastSenderDisplayName = undefined
+}
+
+/** 同步群 @ 状态 */
+function syncConversationAtFlags(conversation: Conversation, message: Message): void {
+ if (
+ message.selfSend ||
+ conversation.type !== ImConversationType.GROUP ||
+ !message.atUserIds ||
+ message.atUserIds.length === 0 ||
+ message.status === ImMessageStatus.READ
+ ) {
+ return
+ }
+ const currentUserId = getCurrentUserId()
+ if (currentUserId && message.atUserIds.includes(currentUserId)) {
+ conversation.atMe = true
+ }
+ if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
+ conversation.atAll = true
+ }
+}
+
+/** 应用服务端消息更新 */
+function applyServerMessageUpdate(message: Message, updates: Partial): void {
+ if (updates.content && updates.content !== message.content) {
+ revokeBlobUrlsInContent(message.content)
+ }
+ Object.assign(message, updates)
+ if (updates.id === 0) {
+ message.id = undefined
+ }
+ if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
+ message.uploadProgress = undefined
+ if (updates.status !== ImMessageStatus.FAILED) {
+ message._localFile = undefined
+ }
+ }
+}
+
+/** 判断是否为同一条消息 */
+function isSameMessage(left: Message, right: Message): boolean {
+ if (left.id && right.id && left.id === right.id) {
+ return true
+ }
+ return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
+}
+
+export const useMessageStore = defineStore('imMessageStore', {
+ state: () => ({
+ messagesByConversation: {} as Record,
+ loadedConversationKeys: [] as string[],
+ privateMessageMaxId: 0,
+ groupMessageMaxId: 0,
+ channelMessageMaxId: 0
+ }),
+
+ getters: {
+ /** 获取会话已加载消息 */
+ getMessages:
+ (state) =>
+ (clientConversationId: string): Message[] =>
+ state.messagesByConversation[clientConversationId] || []
+ },
+
+ actions: {
+ /** 清空消息内存 */
+ clear() {
+ Object.values(this.messagesByConversation).forEach((messages) => {
+ messages.forEach((message) => {
+ revokeBlobUrlsInContent(message.content)
+ message._localFile = undefined
+ })
+ })
+ this.messagesByConversation = {}
+ this.loadedConversationKeys = []
+ this.privateMessageMaxId = 0
+ this.groupMessageMaxId = 0
+ this.channelMessageMaxId = 0
+ ackMergingPromises.clear()
+ },
+
+ /** 从 settings 加载消息游标 */
+ async loadCursors() {
+ const db = getDb()
+ // TODO @AI:可以通过 message 表去算么?不通过这个。
+ const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
+ db.getSetting('privateMessageMaxId'),
+ db.getSetting('groupMessageMaxId'),
+ db.getSetting('channelMessageMaxId')
+ ])
+ this.privateMessageMaxId = privateMaxId || 0
+ this.groupMessageMaxId = groupMaxId || 0
+ this.channelMessageMaxId = channelMaxId || 0
+ },
+
+ /** 更新内存游标 */
+ updateMaxId(conversationType: number, messageId?: number) {
+ if (!messageId) {
+ return
+ }
+ if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) {
+ this.privateMessageMaxId = messageId
+ } else if (
+ conversationType === ImConversationType.GROUP &&
+ messageId > this.groupMessageMaxId
+ ) {
+ this.groupMessageMaxId = messageId
+ } else if (
+ conversationType === ImConversationType.CHANNEL &&
+ messageId > this.channelMessageMaxId
+ ) {
+ this.channelMessageMaxId = messageId
+ }
+ },
+
+ /** 标记会话近期使用 */
+ touchConversation(clientConversationId: string) {
+ this.loadedConversationKeys = [
+ clientConversationId,
+ ...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
+ ]
+ // 保留当前活跃会话 + 最近打开过的 5 个会话。
+ const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
+ const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_CONVERSATION_LIMIT + 1)
+ this.loadedConversationKeys = retained
+ removed.forEach((key) => {
+ delete this.messagesByConversation[key]
+ })
+ },
+
+ /** 加载当前会话最近消息 */
+ async loadMore(
+ clientConversationId: string,
+ beforeSendTime?: number,
+ limit = 50
+ ): Promise {
+ // TODO @AI:代码段的注释;
+ const list = await getDb().getMessagesByConversation(clientConversationId, {
+ beforeSendTime,
+ limit
+ })
+ const parsed = parseClientConversationId(clientConversationId)
+ if (!parsed) {
+ return []
+ }
+ const messages = list.map(fromMessageDO)
+ const existing = this.messagesByConversation[clientConversationId] || []
+ const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
+ const fresh = messages.filter(
+ (message) => !existingKeys.has(getMessageKey(message, parsed.type))
+ )
+ // TODO @AI:messageA、messageB;
+ this.messagesByConversation[clientConversationId] = [...fresh, ...existing].sort(
+ (a, b) => (a.sendTime || 0) - (b.sendTime || 0)
+ )
+ this.touchConversation(clientConversationId)
+ return fresh
+ },
+
+ /** 确保会话消息已加载 */
+ async ensureLoaded(conversation: Conversation) {
+ // TODO @AI:代码段的注释;
+ const key = getMessageCacheKey(conversation.type, conversation.targetId)
+ if (this.messagesByConversation[key]) {
+ this.touchConversation(key)
+ return
+ }
+ await this.loadMore(key)
+ },
+
+ /** 获取内存消息数组 */
+ getMessageList(conversationType: number, targetId: number): Message[] {
+ // TODO @AI:代码段的注释;
+ const key = getMessageCacheKey(conversationType, targetId)
+ if (!this.messagesByConversation[key]) {
+ this.messagesByConversation[key] = []
+ }
+ this.touchConversation(key)
+ return this.messagesByConversation[key]
+ },
+
+ /** 持久化单条消息 */
+ async persistMessage(message: Message, conversationType: number, tx?: DbTx) {
+ // TODO @AI:代码段的注释;
+ const db = getDb()
+ const next = toMessageDO(message, conversationType)
+ if (message.id && message.clientMessageId) {
+ const existing = await db.getByIndex(
+ 'messages',
+ 'clientMessageId',
+ message.clientMessageId,
+ tx
+ )
+ if (existing && existing.messageKey !== next.messageKey) {
+ await db.delete('messages', existing.messageKey, tx)
+ }
+ }
+ await db.put('messages', next, tx)
+ },
+
+ /** 持久化消息游标 */
+ async persistMaxId(conversationType: number, messageId?: number, tx?: DbTx) {
+ this.updateMaxId(conversationType, messageId)
+ await setMessageMaxId(conversationType, messageId, tx)
+ },
+
+ /** 应用撤回到内存 */
+ applyRecallInMemory(conversationType: number, targetId: number, recallSignalContent: string) {
+ // TODO @AI:代码段的注释;
+ const messageId = parseRecallMessageId(recallSignalContent)
+ if (!messageId) {
+ return null
+ }
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
+ if (!conversation) {
+ return null
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const message = messages.find((item) => item.id === messageId)
+ if (!message) {
+ return null
+ }
+ message.type = ImMessageType.RECALL
+ message.status = ImMessageStatus.RECALL
+ message.content = ''
+ if (messages[messages.length - 1]?.id === messageId) {
+ recomputeConversationLast(conversation, messages)
+ }
+ return { conversation, message }
+ },
+
+ /** 批量写入拉取消息 */
+ async insertPulledBatch(
+ items: PulledMessageBatchItem[],
+ conversationType: number,
+ maxMessageId?: number
+ ) {
+ // TODO @AI:代码段的注释;
+ if (items.length === 0) {
+ await this.persistMaxId(conversationType, maxMessageId)
+ return
+ }
+ const conversationStore = useConversationStore()
+ const persistedMessages = new Map()
+ const changedConversations = new Map()
+
+ const addChanged = (conversation: Conversation, message: Message) => {
+ const clientConversationId = getClientConversationId(
+ conversation.type,
+ conversation.targetId
+ )
+ changedConversations.set(clientConversationId, conversation)
+ persistedMessages.set(getMessageKey(message, conversation.type), {
+ message,
+ conversationType: conversation.type
+ })
+ }
+
+ // TODO @AI:是不是最好 mesages?
+ for (const item of items) {
+ if (item.kind === 'recall') {
+ const changed = this.applyRecallInMemory(
+ item.conversationType,
+ item.targetId,
+ item.recallSignalContent
+ )
+ if (changed) {
+ addChanged(changed.conversation, changed.message)
+ }
+ continue
+ }
+
+ const { conversationInfo } = item
+ const message = ensureClientMessageId(item.message)
+ if (
+ conversationInfo.type === ImConversationType.GROUP &&
+ isGroupNotification(message.type)
+ ) {
+ useGroupStore().applyGroupNotification(
+ conversationInfo.targetId,
+ message.type,
+ message.content
+ )
+ }
+
+ const conversation = conversationStore.ensureConversation(conversationInfo)
+ const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
+ const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
+ if (existingIndex >= 0) {
+ applyServerMessageUpdate(messages[existingIndex], message)
+ if (existingIndex === messages.length - 1) {
+ recomputeConversationLast(conversation, messages)
+ syncConversationAtFlags(conversation, message)
+ }
+ this.updateMaxId(conversationInfo.type, message.id)
+ addChanged(conversation, messages[existingIndex])
+ continue
+ }
+
+ // TODO @AI:applyConversationSummary 要 await 么?不然会有报错;
+ applyConversationSummary(conversation, message)
+ syncConversationAtFlags(conversation, message)
+ const isActive =
+ conversationStore.activeConversation?.type === conversationInfo.type &&
+ conversationStore.activeConversation?.targetId === conversationInfo.targetId
+ if (
+ !message.selfSend &&
+ !isActive &&
+ isNormalMessage(message.type) &&
+ message.status !== ImMessageStatus.READ &&
+ message.status !== ImMessageStatus.RECALL
+ ) {
+ conversation.unreadCount++
+ }
+
+ let insertIndex = messages.length
+ if (message.id) {
+ for (let index = 0; index < messages.length; index++) {
+ const existing = messages[index]
+ if (existing.id && message.id < existing.id) {
+ insertIndex = index
+ break
+ }
+ }
+ }
+ messages.splice(insertIndex, 0, message)
+ this.updateMaxId(conversationInfo.type, message.id)
+ addChanged(conversation, message)
+ }
+
+ this.updateMaxId(conversationType, maxMessageId)
+ await getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ for (const item of persistedMessages.values()) {
+ await this.persistMessage(item.message, item.conversationType, tx)
+ }
+ await conversationStore.persistConversations([...changedConversations.values()], tx)
+ await setMessageMaxId(conversationType, maxMessageId, tx)
+ })
+ .catch((e) => console.error('[IM messageStore] 批量消息写入失败', e))
+ },
+
+ /** 插入消息 */
+ insertMessage(
+ conversationInfo: MessageConversationInfo,
+ messageInfo: Message,
+ options?: { persistMaxId?: boolean }
+ ) {
+ // TODO @AI:代码段的注释;类似上面的问题;;;
+ const conversationStore = useConversationStore()
+ const message = ensureClientMessageId(messageInfo)
+ if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
+ useGroupStore().applyGroupNotification(
+ conversationInfo.targetId,
+ message.type,
+ message.content
+ )
+ }
+
+ const conversation = conversationStore.ensureConversation(conversationInfo)
+ const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
+ const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
+ if (existingIndex >= 0) {
+ applyServerMessageUpdate(messages[existingIndex], message)
+ if (existingIndex === messages.length - 1) {
+ recomputeConversationLast(conversation, messages)
+ syncConversationAtFlags(conversation, message)
+ }
+ this.updateMaxId(conversationInfo.type, message.id)
+ void getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ await this.persistMessage(messages[existingIndex], conversationInfo.type, tx)
+ await conversationStore.persistConversations(conversation, tx)
+ if (options?.persistMaxId !== false) {
+ await setMessageMaxId(conversationInfo.type, message.id, tx)
+ }
+ })
+ .catch((e) => console.error('[IM messageStore] 消息写入失败', e))
+ return
+ }
+
+ applyConversationSummary(conversation, message)
+ syncConversationAtFlags(conversation, message)
+
+ const isActive =
+ conversationStore.activeConversation?.type === conversationInfo.type &&
+ conversationStore.activeConversation?.targetId === conversationInfo.targetId
+ if (
+ !message.selfSend &&
+ !isActive &&
+ isNormalMessage(message.type) &&
+ message.status !== ImMessageStatus.READ &&
+ message.status !== ImMessageStatus.RECALL
+ ) {
+ conversation.unreadCount++
+ }
+
+ let insertIndex = messages.length
+ if (message.id) {
+ for (let index = 0; index < messages.length; index++) {
+ const existing = messages[index]
+ if (existing.id && message.id < existing.id) {
+ insertIndex = index
+ break
+ }
+ }
+ }
+ messages.splice(insertIndex, 0, message)
+ this.updateMaxId(conversationInfo.type, message.id)
+ void getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ await this.persistMessage(message, conversationInfo.type, tx)
+ await conversationStore.persistConversations(conversation, tx)
+ if (options?.persistMaxId !== false) {
+ await setMessageMaxId(conversationInfo.type, message.id, tx)
+ }
+ })
+ .catch((e) => console.error('[IM messageStore] 消息写入失败', e))
+ },
+
+ /** ack 合并 */
+ ackMessage(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ updates: Partial
+ ) {
+ const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`
+ const existingPromise = ackMergingPromises.get(mergeKey)
+ if (existingPromise) {
+ return existingPromise
+ }
+ const promise = this.doAckMessage(
+ conversationType,
+ targetId,
+ clientMessageId,
+ updates
+ ).finally(() => {
+ ackMergingPromises.delete(mergeKey)
+ })
+ ackMergingPromises.set(mergeKey, promise)
+ return promise
+ },
+
+ /** 执行 ack 合并 */
+ async doAckMessage(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ updates: Partial
+ ) {
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
+ if (!conversation) {
+ return
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const message = messages.find((item) => item.clientMessageId === clientMessageId)
+ if (!message) {
+ return
+ }
+ message._ackMerging = true
+ try {
+ applyServerMessageUpdate(message, updates)
+ if (messages[messages.length - 1] === message) {
+ recomputeConversationLast(conversation, messages)
+ }
+ this.updateMaxId(conversationType, message.id)
+ await getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ await this.persistMessage(message, conversationType, tx)
+ await conversationStore.persistConversations(conversation, tx)
+ await setMessageMaxId(conversationType, message.id, tx)
+ })
+ .catch((e) => console.error('[IM messageStore] ack 写入失败', e))
+ } finally {
+ message._ackMerging = false
+ }
+ },
+
+ /** 局部更新消息 */
+ patchMessage(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ patch: Partial
+ ) {
+ // TODO @AI:代码段的注释;
+ const message = this.getMessageList(conversationType, targetId).find(
+ (item) => item.clientMessageId === clientMessageId
+ )
+ if (!message) {
+ return
+ }
+ let changed = false
+ for (const key in patch) {
+ if (
+ Object.prototype.hasOwnProperty.call(patch, key) &&
+ (patch as Record)[key] !==
+ (message as unknown as Record)[key]
+ ) {
+ changed = true
+ break
+ }
+ }
+ if (changed) {
+ applyServerMessageUpdate(message, patch)
+ }
+ },
+
+ /** 撤回消息 */
+ recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
+ const conversationStore = useConversationStore()
+ const changed = this.applyRecallInMemory(conversationType, targetId, recallSignalContent)
+ if (!changed) {
+ return
+ }
+ this.persistMessage(changed.message, conversationType).catch((e) =>
+ console.error('[IM messageStore] 撤回消息写入失败', e)
+ )
+ conversationStore.saveConversations(changed.conversation)
+ },
+
+ /** 应用已读回执 */
+ applyReadReceipt(options: {
+ conversationType: number
+ targetId: number
+ privateReadMaxId?: number
+ groupMessageId?: number
+ readCount?: number
+ receiptStatus?: number
+ }) {
+ // TODO @AI:代码段的注释;
+ const messages = this.getMessageList(options.conversationType, options.targetId)
+ const changed: Message[] = []
+ if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
+ messages.forEach((message) => {
+ if (
+ message.selfSend &&
+ message.id &&
+ message.id <= options.privateReadMaxId! &&
+ message.status !== ImMessageStatus.RECALL
+ ) {
+ message.status = ImMessageStatus.READ
+ changed.push(message)
+ }
+ })
+ } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
+ const message = messages.find((item) => item.id === options.groupMessageId)
+ if (message) {
+ if (options.readCount !== undefined) {
+ message.readCount = options.readCount
+ }
+ if (options.receiptStatus !== undefined) {
+ message.receiptStatus = options.receiptStatus
+ }
+ changed.push(message)
+ }
+ }
+ changed.forEach((message) => {
+ this.persistMessage(message, options.conversationType).catch((e) =>
+ console.warn('[IM messageStore] 回执写入失败', e)
+ )
+ })
+ },
+
+ /** 前置历史消息 */
+ prependMessages(conversationType: number, targetId: number, earlierMessages: Message[]) {
+ // TODO @AI:代码段的注释;
+ if (earlierMessages.length === 0) {
+ return
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const existingIds = new Set(messages.map((message) => message.id).filter(Boolean))
+ const fresh = earlierMessages
+ .map(ensureClientMessageId)
+ .filter((message) => message.id && !existingIds.has(message.id))
+ .sort((a, b) => (a.id || 0) - (b.id || 0))
+ if (fresh.length === 0) {
+ return
+ }
+ const key = getMessageCacheKey(conversationType, targetId)
+ this.messagesByConversation[key] = [...fresh, ...messages]
+ fresh.forEach((message) => {
+ this.persistMessage(message, conversationType).catch((e) =>
+ console.warn('[IM messageStore] 历史消息写入失败', e)
+ )
+ })
+ },
+
+ /** 删除单条消息 */
+ removeMessage(
+ conversationType: number,
+ targetId: number,
+ key: { id?: number; clientMessageId?: string }
+ ) {
+ // TODO @AI:代码段的注释;
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
+ if (!conversation) {
+ return
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const index = messages.findIndex((message) => {
+ if (key.id && message.id && message.id === key.id) {
+ return true
+ }
+ return !!key.clientMessageId && message.clientMessageId === key.clientMessageId
+ })
+ if (index < 0) {
+ return
+ }
+ const [removed] = messages.splice(index, 1)
+ revokeBlobUrlsInContent(removed.content)
+ if (index === messages.length) {
+ recomputeConversationLast(conversation, messages)
+ }
+ getDb()
+ .delete('messages', getMessageKey(removed, conversationType))
+ .catch((e) => console.warn('[IM messageStore] 消息删除失败', e))
+ conversationStore.saveConversations()
+ },
+
+ /** 当前会话标记已读 */
+ markConversationMessagesRead(conversation: Conversation) {
+ // TODO @AI:代码段的注释;
+ const messages = this.getMessageList(conversation.type, conversation.targetId)
+ messages.forEach((message) => {
+ if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
+ message.status = ImMessageStatus.READ
+ this.persistMessage(message, conversation.type).catch((e) =>
+ console.warn('[IM messageStore] 已读状态写入失败', e)
+ )
+ }
+ })
+ },
+
+ /** 删除会话全部消息 */
+ deleteConversationMessages(conversationType: number, targetId: number) {
+ // TODO @AI:代码段的注释;
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ const messages = this.messagesByConversation[clientConversationId] || []
+ messages.forEach((message) => {
+ revokeBlobUrlsInContent(message.content)
+ message._localFile = undefined
+ })
+ delete this.messagesByConversation[clientConversationId]
+ this.loadedConversationKeys = this.loadedConversationKeys.filter(
+ (key) => key !== clientConversationId
+ )
+ getDb()
+ .deleteByIndex('messages', 'clientConversationId', clientConversationId)
+ .catch((e) => console.warn('[IM messageStore] 会话消息删除失败', e))
+ }
+ }
+})
+
+export const useMessageStoreWithOut = () => useMessageStore(store)
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts
index 5b19898d9..7840a77fd 100644
--- a/src/views/im/home/store/websocketStore.ts
+++ b/src/views/im/home/store/websocketStore.ts
@@ -28,6 +28,7 @@ import {
WS_RECONNECT_JITTER_MS
} from '../../utils/config'
import { useConversationStore } from './conversationStore'
+import { useMessageStore } from './messageStore'
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
@@ -317,10 +318,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/**
* 频道消息实时入会话;频道消息单向 + 无状态机,直接 insertMessage 即可
- * pull 与 WS 拿到同一条 id 时,conversationStore.insertMessage 内部按 id 去重,不会重复
+ * pull 与 WS 拿到同一条 id 时,messageStore.insertMessage 内部按 id 去重,不会重复
*/
handleChannelMessage(websocketMessage: ImChannelMessageRespVO) {
const conversationStore = useConversationStore()
+ const messageStore = useMessageStore()
// 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
if (conversationStore.loading) {
this.messageBuffer.push({
@@ -333,7 +335,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
typeof websocketMessage.sendTime === 'number'
? websocketMessage.sendTime
: new Date(websocketMessage.sendTime).getTime()
- conversationStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
+ messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), {
id: websocketMessage.id,
clientMessageId: '',
type: websocketMessage.type,
@@ -513,7 +515,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态),不让它作为新消息进列表
if (websocketMessage.type === ImMessageType.RECALL) {
- conversationStore.recallMessage(
+ useMessageStore().recallMessage(
ImConversationType.PRIVATE,
peerId,
websocketMessage.content
@@ -523,7 +525,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
const message = convertPrivateMessage(websocketMessage, currentUserId)
- conversationStore.insertMessage(
+ useMessageStore().insertMessage(
{
type: ImConversationType.PRIVATE,
targetId: peerId,
@@ -543,7 +545,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
- conversationStore.markActiveAsRead()
+ conversationStore.markConversationAsRead(ImConversationType.PRIVATE, peerId)
+ if (conversation) {
+ useMessageStore().markConversationMessagesRead(conversation)
+ }
if (MESSAGE_PRIVATE_READ_ENABLED) {
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@@ -580,8 +585,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!websocketMessage.id) {
return
}
- const conversationStore = useConversationStore()
- conversationStore.applyReadReceipt({
+ useMessageStore().applyReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: websocketMessage.senderId,
privateReadMaxId: websocketMessage.id
@@ -637,7 +641,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
// 这里拦截下来改走 recallMessage(把原消息更新为 RECALL 态)
if (websocketMessage.type === ImMessageType.RECALL) {
- conversationStore.recallMessage(
+ useMessageStore().recallMessage(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.content
@@ -647,7 +651,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
const message = convertGroupMessage(websocketMessage, currentUserId)
- conversationStore.insertMessage(
+ useMessageStore().insertMessage(
{
type: ImConversationType.GROUP,
targetId: websocketMessage.groupId,
@@ -669,7 +673,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
- conversationStore.markActiveAsRead()
+ conversationStore.markConversationAsRead(
+ ImConversationType.GROUP,
+ websocketMessage.groupId
+ )
+ if (conversation) {
+ useMessageStore().markConversationMessagesRead(conversation)
+ }
if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@@ -698,8 +708,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!MESSAGE_GROUP_READ_ENABLED) {
return
}
- const conversationStore = useConversationStore()
- conversationStore.applyReadReceipt({
+ useMessageStore().applyReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: websocketMessage.groupId,
groupMessageId: websocketMessage.id,
diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts
index 01e0eba8f..0cd8fcc54 100644
--- a/src/views/im/home/types/index.ts
+++ b/src/views/im/home/types/index.ts
@@ -47,13 +47,16 @@ export interface Conversation {
name: string // 展示名称(私聊=好友昵称;群聊=群名)
avatar: string // 头像
unreadCount: number // 未读数
- messages: Message[] // 消息列表
// ========== 最后一条消息事实索引 ==========
lastContent: string // 会话列表展示的最后一条消息摘要
lastSendTime: number // 最后一条消息时间,用于排序
lastSenderId?: number // 发送人编号
lastMessageType?: number // 消息类型,对齐 ImMessageType
+ lastMessageId?: number // 最后一条服务端消息编号
+ lastClientMessageId?: string // 最后一条客户端消息编号
+ lastMessageStatus?: number // 最后一条消息状态
+ lastReceiptStatus?: number // 最后一条群回执状态
lastSelfSend?: boolean // 是否自己发的
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
@@ -68,7 +71,8 @@ export interface Conversation {
// 消息数据结构
export interface Message {
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ==========
- id: number // 服务端消息编号,发送中为 0
+ // TODO @AI:全局的 id 占位 0,是不是枚举下!!!
+ id?: number // 服务端消息编号,发送中为空
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
type: number // 消息类型,对齐 ImMessageType
content: string // 消息内容,JSON 字符串
@@ -90,23 +94,25 @@ export interface Message {
// 媒体消息内存中保留的原始 File;下划线前缀表示不进 JSON / 不持久化(IDB 恢复后必为 undefined)
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
_localFile?: File
+ _ackMerging?: boolean // ack 合并中标记,不持久化
}
-/**
- * 会话索引项:基于 Conversation 派生,但剥离 messages 字段(消息按会话独立存到 messages key)
- *
- * Omit 是 TS 内置工具类型:从类型 T 中剔除 K 指定的字段,得到剩余字段组成的新类型。
- * 这里 `Omit` 等价于"Conversation 去掉 messages 字段后的版本",
- * 与"Conversation 派生但少一个 messages 字段"的语义一致,不需要再手写一份重复结构。
- */
-export type ConversationMeta = Omit
+// ==================== IndexedDB 本地存储结构 ====================
-// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
-export interface ConversationStoreMeta {
- privateMessageMaxId: number // 私聊消息最大编号
- groupMessageMaxId: number // 群聊消息最大编号
- channelMessageMaxId?: number // 频道消息最大编号
- conversations: ConversationMeta[] // 会话索引(不含 messages)
+export interface ConversationDO extends Conversation {
+ clientConversationId: string // `${type}:${targetId}`
+}
+
+export interface MessageDO extends Omit {
+ messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
+ conversationType: number // 会话类型,对齐 ImConversationType
+ clientConversationId: string // ConversationDO.clientConversationId
+}
+
+export interface SettingDO {
+ key: string
+ value: T
+ updateTime?: number
}
// ==================== 群 / 群成员 ====================
diff --git a/src/views/im/manager/face/pack/FacePackItemForm.vue b/src/views/im/manager/face/pack/FacePackItemForm.vue
index 679ac8222..4e0bed3dc 100644
--- a/src/views/im/manager/face/pack/FacePackItemForm.vue
+++ b/src/views/im/manager/face/pack/FacePackItemForm.vue
@@ -74,6 +74,7 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
import { probeImageSize } from '@/views/im/utils/image'
+import type { FormRules } from 'element-plus'
defineOptions({ name: 'ImManagerFacePackItemForm' })
@@ -99,7 +100,7 @@ const formData = ref({
sort: 0,
status: CommonStatusEnum.ENABLE
})
-const formRules = reactive({
+const formRules = reactive({
url: [{ required: true, message: '表情图不能为空', trigger: 'change' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
// 宽高自动探测后允许手改,但提交前必须落 1-2048 范围;防止用户清空后 submit 让后端报错
diff --git a/src/views/im/utils/db.ts b/src/views/im/utils/db.ts
new file mode 100644
index 000000000..20334c76f
--- /dev/null
+++ b/src/views/im/utils/db.ts
@@ -0,0 +1,482 @@
+import { toRaw } from 'vue'
+
+import { getCurrentUserId } from './storage'
+import { ImConversationType } from './constants'
+import type { MessageDO, SettingDO } from '../home/types'
+
+export const DB_SCHEMA_VERSION = 1
+
+export type DbStoreName =
+ | 'conversations'
+ | 'messages'
+ | 'friends'
+ | 'friendRequests'
+ | 'groups'
+ | 'groupMembers'
+ | 'groupRequests'
+ | 'channels'
+ | 'settings'
+
+// TODO @AI:是不是继续使用 IDBTransaction,不用新的类型定义;
+export type DbTx = IDBTransaction
+
+let currentDb: IDBDatabase | null = null
+let currentUserId: number | null = null
+let currentSession = 0
+
+/** 校验当前 IM IndexedDB session 仍有效 */
+export function isCurrentDbSession(session: number): boolean {
+ return session === currentSession
+}
+
+/** 获取当前 IM IndexedDB session */
+export function getDbSession(): number {
+ return currentSession
+}
+
+/** 拼接当前用户 IM DB 名称 */
+function getDbName(userId: number): string {
+ return `im:${userId}`
+}
+
+/** 包装 IndexedDB request */
+function requestToPromise(request: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+ })
+}
+
+/** 等待事务完成 */
+function transactionDone(transaction: IDBTransaction): Promise {
+ return new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(transaction.error)
+ })
+}
+
+/** 创建索引 */
+function createIndex(
+ store: IDBObjectStore,
+ name: string,
+ keyPath: string | string[],
+ options?: IDBIndexParameters
+) {
+ if (!store.indexNames.contains(name)) {
+ store.createIndex(name, keyPath, options)
+ }
+}
+
+/** 初始化 schema */
+function upgradeSchema(db: IDBDatabase) {
+ if (!db.objectStoreNames.contains('conversations')) {
+ const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' })
+ createIndex(store, 'lastSendTime', 'lastSendTime')
+ }
+ if (!db.objectStoreNames.contains('messages')) {
+ const store = db.createObjectStore('messages', { keyPath: 'messageKey' })
+ createIndex(store, 'clientConversationId', 'clientConversationId')
+ createIndex(store, 'clientConversationId+sendTime', ['clientConversationId', 'sendTime'])
+ createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true })
+ }
+ if (!db.objectStoreNames.contains('friends')) {
+ const store = db.createObjectStore('friends', { keyPath: 'id' })
+ createIndex(store, 'friendUserId', 'friendUserId', { unique: true })
+ createIndex(store, 'status', 'status')
+ }
+ if (!db.objectStoreNames.contains('friendRequests')) {
+ const store = db.createObjectStore('friendRequests', { keyPath: 'id' })
+ createIndex(store, 'status', 'status')
+ createIndex(store, 'createTime', 'createTime')
+ }
+ if (!db.objectStoreNames.contains('groups')) {
+ const store = db.createObjectStore('groups', { keyPath: 'id' })
+ createIndex(store, 'name', 'name')
+ createIndex(store, 'status', 'status')
+ }
+ if (!db.objectStoreNames.contains('groupMembers')) {
+ const store = db.createObjectStore('groupMembers', { keyPath: 'id' })
+ createIndex(store, 'groupId', 'groupId')
+ createIndex(store, 'groupId+userId', ['groupId', 'userId'], { unique: true })
+ }
+ if (!db.objectStoreNames.contains('groupRequests')) {
+ const store = db.createObjectStore('groupRequests', { keyPath: 'id' })
+ createIndex(store, 'status', 'status')
+ createIndex(store, 'createTime', 'createTime')
+ }
+ if (!db.objectStoreNames.contains('channels')) {
+ const store = db.createObjectStore('channels', { keyPath: 'id' })
+ createIndex(store, 'status', 'status')
+ createIndex(store, 'sort', 'sort')
+ }
+ if (!db.objectStoreNames.contains('settings')) {
+ db.createObjectStore('settings', { keyPath: 'key' })
+ }
+}
+
+/** 打开 IM IndexedDB */
+// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
+function openDb(name: string): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(name, DB_SCHEMA_VERSION)
+ request.onupgradeneeded = () => upgradeSchema(request.result)
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+ })
+}
+
+/** 初始化当前用户 IM DB */
+// TODO @AI:是不是方法里,代码段的注释,是不是要增加下?
+export async function initDb(): Promise {
+ const userId = getCurrentUserId()
+ if (!Number.isFinite(userId) || userId <= 0) {
+ throw new Error('当前用户不存在,无法初始化 IM DB')
+ }
+ if (currentDb && currentUserId === userId) {
+ return
+ }
+ currentDb?.close()
+ currentSession++
+ currentUserId = userId
+ currentDb = await openDb(getDbName(userId))
+}
+
+/** 关闭当前 IM DB session */
+// TODO @AI:是不是需要被调用下???
+export function closeDbSession() {
+ currentSession++
+ currentDb?.close()
+ currentDb = null
+ currentUserId = null
+}
+
+/** 获取当前 IM DB */
+function getRawDb(): IDBDatabase {
+ if (!currentDb) {
+ throw new Error('IM DB 未初始化')
+ }
+ return currentDb
+}
+
+/** 校验单次写入 session */
+function guardSession(session: number) {
+ if (!isCurrentDbSession(session)) {
+ throw new Error('IM DB session 已失效')
+ }
+}
+
+/** 克隆可入库对象 */
+function toDbValue(value: T): T {
+ return toRaw(value) as T
+}
+
+// TODO @AI:我们讨论下,DbWrapper 会不会有点怪?
+// TODO @AI:是不是改成 selectOne、selectAll、selectList、insert、update、delete、save 这种。
+class DbWrapper {
+ /** 获取单条记录 */
+ // TODO @AI:是不是不用缩写,tx 改成 transaction 更好理解;
+ async get(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise {
+ if (tx) {
+ return requestToPromise(tx.objectStore(storeName).get(key))
+ }
+ return this.transaction([storeName], 'readonly', (innerTx) =>
+ this.get(storeName, key, innerTx)
+ )
+ }
+
+ /** 获取 store 全量记录 */
+ async getAll(storeName: DbStoreName, tx?: DbTx): Promise {
+ if (tx) {
+ return requestToPromise(tx.objectStore(storeName).getAll())
+ }
+ return this.transaction([storeName], 'readonly', (innerTx) =>
+ this.getAll(storeName, innerTx)
+ )
+ }
+
+ /** 按唯一索引获取单条记录 */
+ async getByIndex(
+ storeName: DbStoreName,
+ indexName: string,
+ query: IDBValidKey | IDBKeyRange,
+ tx?: DbTx
+ ): Promise {
+ if (tx) {
+ return requestToPromise(tx.objectStore(storeName).index(indexName).get(query))
+ }
+ return this.transaction([storeName], 'readonly', (innerTx) =>
+ this.getByIndex(storeName, indexName, query, innerTx)
+ )
+ }
+
+ /** 按索引获取记录列表 */
+ async getAllByIndex(
+ storeName: DbStoreName,
+ indexName: string,
+ query?: IDBValidKey | IDBKeyRange,
+ tx?: DbTx
+ ): Promise {
+ if (tx) {
+ return requestToPromise(tx.objectStore(storeName).index(indexName).getAll(query))
+ }
+ return this.transaction([storeName], 'readonly', (innerTx) =>
+ this.getAllByIndex(storeName, indexName, query, innerTx)
+ )
+ }
+
+ /** 写入记录 */
+ async put(storeName: DbStoreName, value: T, tx?: DbTx): Promise {
+ if (tx) {
+ await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
+ return
+ }
+ await this.transaction([storeName], 'readwrite', (innerTx) =>
+ this.put(storeName, value, innerTx)
+ )
+ }
+
+ /** 删除记录 */
+ async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTx): Promise {
+ if (tx) {
+ await requestToPromise(tx.objectStore(storeName).delete(key))
+ return
+ }
+ await this.transaction([storeName], 'readwrite', (innerTx) =>
+ this.delete(storeName, key, innerTx)
+ )
+ }
+
+ /** 按索引删除记录 */
+ async deleteByIndex(
+ storeName: DbStoreName,
+ indexName: string,
+ query: IDBValidKey | IDBKeyRange,
+ tx?: DbTx
+ ): Promise {
+ if (!tx) {
+ await this.transaction([storeName], 'readwrite', (innerTx) =>
+ this.deleteByIndex(storeName, indexName, query, innerTx)
+ )
+ return
+ }
+ const index = tx.objectStore(storeName).index(indexName)
+ await new Promise((resolve, reject) => {
+ const request = index.openCursor(query)
+ request.onerror = () => reject(request.error)
+ request.onsuccess = () => {
+ const cursor = request.result
+ if (!cursor) {
+ resolve()
+ return
+ }
+ cursor.delete()
+ cursor.continue()
+ }
+ })
+ }
+
+ /** 执行事务 */
+ // TODO @AI:方法里的方法段的注释,需要写么?
+ async transaction(
+ storeNames: DbStoreName[],
+ mode: IDBTransactionMode,
+ runner: (tx: DbTx) => Promise
+ ): Promise {
+ const session = getDbSession()
+ guardSession(session)
+ const tx = getRawDb().transaction(storeNames, mode)
+ const done = transactionDone(tx)
+ let result: T
+ try {
+ result = await runner(tx)
+ } catch (e) {
+ // TODO @AI:这种 logger error 要打印么?
+ try {
+ tx.abort()
+ } catch {}
+ await done.catch(() => undefined)
+ throw e
+ }
+ await done
+ guardSession(session)
+ return result
+ }
+
+ /** 按会话分页获取消息 */
+ // TODO @AI:这个方法里,代码段的注释,是不是要增加下?比如说,分页的逻辑,游标的逻辑,等等;
+ // TODO @AI:项目里,一般方法名,是使用 getListByXXXX;
+ async getMessagesByConversation(
+ clientConversationId: string,
+ options?: { beforeSendTime?: number; limit?: number },
+ tx?: DbTx
+ ): Promise {
+ const limit = options?.limit ?? 50
+ const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
+ const range = IDBKeyRange.bound(
+ [clientConversationId, 0],
+ [clientConversationId, upper],
+ false,
+ true
+ )
+ const read = async (innerTx: DbTx): Promise => {
+ const index = innerTx.objectStore('messages').index('clientConversationId+sendTime')
+ const out: MessageDO[] = []
+ await new Promise((resolve, reject) => {
+ const request = index.openCursor(range, 'prev')
+ request.onerror = () => reject(request.error)
+ request.onsuccess = () => {
+ const cursor = request.result
+ if (!cursor || out.length >= limit) {
+ resolve()
+ return
+ }
+ out.push(cursor.value as MessageDO)
+ cursor.continue()
+ }
+ })
+ return out.reverse()
+ }
+ if (tx) {
+ return read(tx)
+ }
+ return this.transaction(['messages'], 'readonly', read)
+ }
+
+ /** 读取设置 */
+ async getSetting(key: string, tx?: DbTx): Promise {
+ const item = await this.get>('settings', key, tx)
+ return item?.value
+ }
+
+ /** 写入设置 */
+ async setSetting(key: string, value: T, tx?: DbTx): Promise {
+ await this.put>('settings', { key, value, updateTime: Date.now() }, tx)
+ }
+}
+
+const dbWrapper = new DbWrapper()
+
+/** 获取当前 IM DB wrapper */
+export function getDb(): DbWrapper {
+ return dbWrapper
+}
+
+/** 当前用户会话主键 */
+export function getClientConversationId(type: number, targetId: number): string {
+ return `${type}:${targetId}`
+}
+
+/** 解析当前用户会话主键 */
+export function parseClientConversationId(
+ clientConversationId: string
+): { type: number; targetId: number } | null {
+ const [typeText, targetIdText] = clientConversationId.split(':')
+ const type = Number(typeText)
+ const targetId = Number(targetIdText)
+ if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
+ // TODO @AI:logger info?
+ return null
+ }
+ return { type, targetId }
+}
+
+/** 服务端消息主键 */
+export function getServerMessageKey(conversationType: number, id: number): string {
+ return `${conversationType}:${id}`
+}
+
+/** 客户端临时消息主键 */
+export function getClientMessageKey(clientMessageId: string): string {
+ return `client:${clientMessageId}`
+}
+
+/** 解析本地消息主键 */
+// TODO @AI:这个方法,貌似没调用;
+export function parseMessageKey(
+ messageKey: string
+):
+ | { kind: 'client'; clientMessageId: string }
+ | { kind: 'server'; conversationType: number; id: number }
+ | null {
+ if (!messageKey) {
+ return null
+ }
+ if (messageKey.startsWith('client:')) {
+ const clientMessageId = messageKey.slice('client:'.length)
+ return clientMessageId ? { kind: 'client', clientMessageId } : null
+ }
+ const [conversationTypeText, idText] = messageKey.split(':')
+ const conversationType = Number(conversationTypeText)
+ const id = Number(idText)
+ if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
+ return null
+ }
+ return { kind: 'server', conversationType, id }
+}
+
+/** 更新消息拉取游标 */
+export async function setMessageMaxId(
+ conversationType: number,
+ maxId: number | undefined,
+ tx?: DbTx
+): Promise {
+ if (!maxId) {
+ return
+ }
+ let key: string
+ switch (conversationType) {
+ case ImConversationType.PRIVATE:
+ key = 'privateMessageMaxId'
+ break
+ case ImConversationType.GROUP:
+ key = 'groupMessageMaxId'
+ break
+ case ImConversationType.CHANNEL:
+ key = 'channelMessageMaxId'
+ break
+ default:
+ throw new Error(`未知 IM 会话类型:${conversationType}`)
+ }
+ const db = getDb()
+ const current = (await db.getSetting(key, tx)) || 0
+ if (maxId > current) {
+ await db.setSetting(key, maxId, tx)
+ }
+}
+
+/** 停止当前 IM DB session */
+// TODO @AI:这里的注释,要写下;方法注释;
+export async function stopRequests(): Promise {
+ const [
+ { useMessageStoreWithOut },
+ { useConversationStoreWithOut },
+ { useFriendStoreWithOut },
+ { useGroupStoreWithOut },
+ { useChannelStoreWithOut },
+ { useGroupRequestStoreWithOut },
+ { useDraftStoreWithOut },
+ { useFaceStoreWithOut }
+ ] = await Promise.all([
+ import('../home/store/messageStore'),
+ import('../home/store/conversationStore'),
+ import('../home/store/friendStore'),
+ import('../home/store/groupStore'),
+ import('../home/store/channelStore'),
+ import('../home/store/groupRequestStore'),
+ import('../home/store/draftStore'),
+ import('../home/store/faceStore')
+ ])
+ currentSession++
+ useMessageStoreWithOut().clear()
+ useConversationStoreWithOut().clear()
+ useFriendStoreWithOut().clear()
+ useGroupStoreWithOut().clear()
+ useChannelStoreWithOut().clear()
+ useGroupRequestStoreWithOut().reset()
+ useDraftStoreWithOut().clear()
+ useFaceStoreWithOut().reset()
+ currentDb?.close()
+ currentDb = null
+ currentUserId = null
+}
diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts
index f04376459..3830cc825 100644
--- a/src/views/im/utils/message.ts
+++ b/src/views/im/utils/message.ts
@@ -414,7 +414,7 @@ function mapMessageToMergeItem(
): MergeMessageItem {
const snapshot = senderSnapshots.get(message.senderId)
return {
- messageId: message.id,
+ messageId: message.id || 0,
senderId: message.senderId,
senderNickname: snapshot?.nickname ?? String(message.senderId),
senderAvatar: snapshot?.avatar ?? '',
@@ -555,7 +555,7 @@ export const removeQuotePayload = (content: string): string => {
/** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */
export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
return {
- messageId: message.id,
+ messageId: message.id || 0,
senderId: message.senderId,
type: message.type,
content: removeQuotePayload(message.content)
@@ -882,7 +882,10 @@ export function resolveRtcCallTipSegments(message: {
}
if (message.type === ImMessageType.RTC_CALL_START) {
return payload.inviterUserId
- ? [tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), tipText(' 发起了语音通话')]
+ ? [
+ tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
+ tipText(' 发起了语音通话')
+ ]
: []
}
if (message.type === ImMessageType.RTC_CALL_END) {
diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts
index 7536d468c..da48cb7db 100644
--- a/src/views/im/utils/storage.ts
+++ b/src/views/im/utils/storage.ts
@@ -4,47 +4,23 @@ import { toRaw } from 'vue'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
/**
- * IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
- *
- * 为什么不用 localStorage 直接存:
- * 1. 配额:localStorage 整体上限 5~10MB,多会话长历史很容易撑爆
- * 2. 写放大:localStorage 必须按 key 整体写入,单次写就是 MB 级序列化阻塞主线程
- *
- * 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key;
- * IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果
+ * IM 模块本地缓存实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
*/
export const imStorage = localforage.createInstance({
name: 'im',
storeName: 'conversation',
- description: 'IM 会话索引与消息缓存'
+ description: 'IM 本地缓存'
})
/**
* 存储 key 统一在此生成
*
- * - 会话 / 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
+ * - 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
*
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
*/
export const StorageKeys = {
- /**
- * 会话索引:游标 + 会话元数据(不含 messages),对应 ConversationStoreMeta
- *
- * 任何会话级元数据变更(top / silent / unread / 列表增删 / 排序)都会重写这一个 key;由于 messages 已剥离到独立 key,单次写体积稳定(仅元数据,量级 KB 级)
- */
- conversationMeta: (userId: number | string) => `conversation:meta:${userId}`,
- /**
- * 单会话消息:按 (type, targetId) 分桶,存 Message[]
- *
- * - type:私聊 / 群聊(对齐 ImConversationType)
- * - targetId:私聊的对方 userId / 群聊的 groupId
- *
- * 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大;软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留
- */
- conversationMessages: (userId: number | string, type: number, targetId: number) =>
- `conversation:messages:${userId}:${type}:${targetId}`,
-
/**
* 输入框草稿整桶:Record<`${type}:${targetId}`, DraftSnapshot>
*
@@ -59,8 +35,7 @@ export const StorageKeys = {
/** 频道列表整桶;频道量级很小,整桶整写够用 */
channels: (userId: number | string) => `channels:${userId}`,
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
- groupMembers: (userId: number | string, groupId: number) =>
- `groupMembers:${userId}:${groupId}`,
+ groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
/** 最近转发会话 key 列表(按 userId 分桶);ConversationPickerPanel 左栏顶部头像区使用 */
recentForwardConversationKeys: (userId: number | string) =>
@@ -81,11 +56,40 @@ export function getCurrentUserId(): number {
/** IDB 写入:fire-and-forget */
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
- // toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
- const raw = value && typeof value === 'object' ? toRaw(value) : value
+ const raw = toStorageValue(value)
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
}
export function removeQuietly(key: string, errorLabel: string): void {
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
}
+
+/** 转换为 IndexedDB 可存储的数据 */
+// TODO @AI:后续,是不是可以删除掉?尽量使用 db.ts 对不对哈?
+function toStorageValue(value: T, seen = new WeakMap