From e90f9e5237644bb9dd48f4109228f749702d464e Mon Sep 17 00:00:00 2001 From: YunaiV Date: Wed, 29 Apr 2026 15:50:49 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E5=A2=9E=E5=8A=A0=20fr?= =?UTF-8?q?iend=E3=80=81group=20=E7=9B=B8=E5=85=B3=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/im/home/index.vue | 73 +++-- .../conversation/ConversationItem.vue | 13 +- .../components/message/MessageItem.vue | 3 +- .../components/message/MessagePanel.vue | 38 ++- .../im/home/pages/conversation/index.vue | 6 +- src/views/im/home/store/conversationStore.ts | 97 +++++-- src/views/im/home/store/friendStore.ts | 62 +++- src/views/im/home/store/groupStore.ts | 266 ++++++++++++++---- src/views/im/home/store/websocketStore.ts | 15 +- src/views/im/home/types/index.ts | 12 +- src/views/im/utils/conversation.ts | 39 +-- src/views/im/utils/storage.ts | 40 ++- src/views/im/utils/user.ts | 36 +-- 13 files changed, 518 insertions(+), 182 deletions(-) diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index c7cb187ba..080fad295 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -41,6 +41,7 @@ import ContextMenu from './components/ContextMenu.vue' defineOptions({ name: 'ImIndex' }) const conversationStore = useConversationStore() +// TODO @AI:webSocketStore 全称更合适。 const wsStore = useImWebSocketStore() const friendStore = useFriendStore() const groupStore = useGroupStore() @@ -48,31 +49,60 @@ const { pullOnce } = useMessagePuller() const { readActive, syncPrivateReadStatus } = useMessageSender() /** 初始化:本地缓存恢复 → 远端通信/同步 → 默认视图 */ +// TODO @AI:上面的“初始化:本地缓存恢复 → 远端通信/同步 → 默认视图”,有点不好理解。 onMounted(async () => { - // ========== 1. 本地状态准备 ========== - // 1.1 整段初始化期间 loading=true:阻断 saveConversations 抖动写盘 + 让 WS 普通消息进缓冲区, - // 避免 connect 后到 pullOnce 之间到达的实时消息推进 maxId,导致 pull 跳过断线积压消息 + // TODO @AI:WS 全称 WebSocket,不要缩写。其他地方也是 + // loading=true 整段阻断 saveConversations 抖动写盘 + WS 普通消息进缓冲, + // 避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息 conversationStore.loading = true - // 1.2 从 IndexedDB 恢复本地会话数据(按会话分桶,需 await 以保证后续步骤拿到完整列表) - await conversationStore.loadConversations() + try { + // TODO @AI:1 和 2,是不是改成 1.1 1.2;先拉取本地缓存。拉不到在拉远端数据。感觉更清晰一些。 + // 1. IDB 并发恢复(loadConversations 返回 void;load{Friends,Groups} 返回是否有缓存) + const [, hasCachedFriends, hasCachedGroups] = await Promise.all([ + conversationStore.loadConversations(), + friendStore.loadFriends(), + groupStore.loadGroups() + ]) - // ========== 2. 远端通信 + 数据同步 ========== - // 2.1 建立 WebSocket 长连接(跨 Tab 持续保持,不因路由切换断开) - wsStore.connect() - // 2.2 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 friendStore / groupStore 补会话 name/avatar; - // 发送人名渲染时再走 utils/user 实时算,不依赖这里的 store 数据,但避免冷启动期间 ConversationItem 显示 senderId 数字 - await Promise.all([ - friendStore.loadFriends().catch((e) => console.warn('[IM] 预拉好友失败', e)), - groupStore.loadGroups().catch((e) => console.warn('[IM] 预拉群列表失败', e)) - ]) - // 2.3 增量拉取离线消息(私聊 + 群聊,使用各自 minId 游标);pullOnce finally 里把 loading 归位 - await pullOnce() + // TODO @AI:SWR 这个注释,看看怎么更好的理解。 + // TODO @AI:下面这个注释,感觉没啥层次感。 + // 2. SWR 刷新:有缓存背景刷;无缓存必须 await + 抛错中断——否则 pullOnce 会用 senderId + // 数字给会话起名落到 IDB 后续很难自愈。无缓存分支两个 fetch 并发 Promise.all 省一个 RTT + const requiredFetches: Promise[] = [] + if (hasCachedFriends) { + void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e)) + } else { + requiredFetches.push(friendStore.fetchFriends()) + } + if (hasCachedGroups) { + void groupStore.fetchGroups().catch((e) => console.warn('[IM] 后台刷群列表失败', e)) + } else { + requiredFetches.push(groupStore.fetchGroups()) + } + if (requiredFetches.length > 0) { + await Promise.all(requiredFetches) + } + // TODO @AI:3.1 3.2 是不是一起。一个是 websocket 加载数据;一个是加载离线消息。本质是解决实时通信; + // 3. 数据就绪后再 connect——无缓存 fetch 失败会走外层 catch 提前 return,避免 WS 已连 + // 但 friend/group store 空,handle*Message 用 senderId 数字落库 + wsStore.connect() + // 4. 拉离线消息;pullOnce finally 里把 loading 归位 + await pullOnce() - // ========== 3. 默认视图 ========== - // 3.1 默认选中第一个会话(仅在消息 Tab 可见) - const sorted = conversationStore.getSortedConversations - if (sorted.length > 0 && !conversationStore.activeConversation) { - conversationStore.setActiveConversation(sorted[0]) + // 5. 默认选中第一个会话 + const sorted = conversationStore.getSortedConversations + if (sorted.length > 0 && !conversationStore.activeConversation) { + conversationStore.setActiveConversation(sorted[0]) + } + } catch (e) { + // TODO @AI:注释可以写的超过一行;尽量换行的时候,是一个事情写完,不然读起来很累。【其他地方也是!!!】例子如下: + // TODO !首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断。 + // TODO WS 不在这里 disconnect —— 路由离开走 onUnmounted 自然清理,用户也可以刷新重试 + // 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里), + // 否则后续 saveConversations 全被早 return 阻断。WS 不在这里 disconnect—— + // 路由离开走 onUnmounted 自然清理,用户也可以刷新重试 + conversationStore.loading = false + console.error('[IM] 初始化失败', e) } }) @@ -81,6 +111,7 @@ onUnmounted(() => { wsStore.disconnect() }) +// TODO @AI:要说下,当前对话的处理。因为不涉及其他对话呀。 /** * 会话切换时自动标记为已读 + 私聊下拉对方已读位置: * - 立刻清零本地未读 diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue index 73a9d6ecd..63786e1ed 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue @@ -108,13 +108,18 @@ const isActive = computed( const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP) -/** 最后一条消息发送者的展示名:按 conversation 上下文走 WeChat 优先级实时算 */ +/** 最后一条消息发送者的展示名:实时算 + 快照 fallback(getSenderDisplayName 算不出时兜底) */ const lastSenderDisplayName = computed(() => { const senderId = props.conversation.lastSenderId if (!senderId) { return '' } - return getSenderDisplayName(senderId, props.conversation.type, props.conversation.targetId) + return getSenderDisplayName( + senderId, + props.conversation.type, + props.conversation.targetId, + props.conversation.lastSenderDisplayName + ) }) /** 群聊 + 有最后发送者 + 最后一条是普通消息 时,显示发送者前缀 */ @@ -146,7 +151,8 @@ const lastContentDisplay = computed(() => { props.conversation.lastSenderId, !!props.conversation.lastSelfSend, props.conversation.type, - props.conversation.targetId + props.conversation.targetId, + props.conversation.lastSenderDisplayName ) } return props.conversation.lastContent @@ -177,6 +183,7 @@ function handleTop() { ) } +// TODO @AI:这块注释,会不会台复杂了。 /** * 切换免打扰:乐观 UI(先落本地,再异步推后端),失败回滚 + 提示 * diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 15eff899f..d04f345c6 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -493,7 +493,8 @@ const showGroupReadStatus = computed(() => { /** * 当前群成员(供 MessageReadStatus 计算未读列表用) * - * 群成员是按需懒加载到 groupStore(loadGroupMembers),未加载完 group?.members 为 undefined → + * // TODO @AI:下面的注释,会不会台复杂了。是不是拿到方法体里? + * 群成员是按需懒加载到 groupStore(loadGroupMembers / fetchGroupMembers),未加载完 group?.members 为 undefined → * 兜底空数组,MessageReadStatus 拿空数组就不渲染未读名单,不会出错 */ const groupMembersForReadStatus = computed(() => { diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index be22ff914..3b3c02e17 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -212,25 +212,33 @@ const groupFriends = computed(() => })) ) -/** - * 切换到群会话时,自动从后端拉取 group / members / 好友(store 内自带缓存) - * - * 三件事各自 fire-and-forget + 各自 catch:之前用 Promise.all 时任意一项失败会让其它 - * 已成功的结果只记一条笼统日志,丢掉具体出错点。这里拆开,谁挂谁单独记,不互相牵连。 - * 错误日志把 groupId 一起带上,多群环境下排查问题能直接定位 - */ -function ensureGroupData(groupId: number) { - groupStore.loadGroupInfo(groupId).catch((error) => { - console.warn('[IM MessagePanel] loadGroupInfo 失败', { groupId }, error) +// TODO @AI:SWR 这个缩写,大家不一定看的懂。 +/** 切换到群会话时按 SWR 同步群 / 成员 / 好友;各自 fire-and-forget + catch,任何一项失败不牵连其它 */ +async function ensureGroupData(groupId: number) { + // TODO @AI:从远程异步拉取群信息,保证数据是最新的 + groupStore.fetchGroupInfo(groupId).catch((error) => { + console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error) }) - groupStore.loadGroupMembers(groupId).catch((error) => { + + // TODO @AI:先从 IDB 同步加载群成员,保证首帧就有成员名 / 头像;这样注释更好? + // 先吃 IDB 让首帧立即出成员名 / 头像 + await groupStore.loadGroupMembers(groupId).catch((error) => { console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error) + return null }) - friendStore.loadFriends().catch((error) => { - console.warn('[IM MessagePanel] loadFriends 失败', { groupId }, error) + // TODO @AI:再从远程异步拉取群成员信息,保证数据是最新的 + // force=true 跳过上一行刚塞进 in-memory 的短路,保证每次进群拿到最新成员状态 + groupStore.fetchGroupMembers(groupId, true).catch((error) => { + console.warn('[IM MessagePanel] fetchGroupMembers 失败', { groupId }, error) + }) + + // TODO @AI:每次切换群,不用拉取 friend 吧?开销太大了。只要首屏拉取就行了呀。 + friendStore.fetchFriends().catch((error) => { + console.warn('[IM MessagePanel] fetchFriends 失败', { groupId }, error) }) } +// TODO @AI:是不是只要说,刷新就好了。然后下面的 await 相关注释,写到方法体里。 /** * 群信息抽屉里点"刷新"等触发:强拉一次最新群元数据 + 群成员(force=true 跳过缓存) * @@ -241,8 +249,8 @@ function reloadGroupData() { if (!conversation || conversation.type !== ImConversationType.GROUP) { return } - groupStore.loadGroupInfo(conversation.targetId) - groupStore.loadGroupMembers(conversation.targetId, true) + groupStore.fetchGroupInfo(conversation.targetId) + groupStore.fetchGroupMembers(conversation.targetId, true) } const historyVisible = ref(false) diff --git a/src/views/im/home/pages/conversation/index.vue b/src/views/im/home/pages/conversation/index.vue index ebd1259fa..db426a896 100644 --- a/src/views/im/home/pages/conversation/index.vue +++ b/src/views/im/home/pages/conversation/index.vue @@ -112,12 +112,14 @@ const friends = computed(() => /** 加好友成功后强制刷新好友列表,让群聊弹窗的勾选项也能看到新好友 */ async function handleFriendAdded() { - await friendStore.loadFriends(true) + // TODO @AI:添加完后,不要重新啥新,成本太高了。。。 + await friendStore.fetchFriends(true) } /** 建群成功后刷新群列表,并直接打开新群会话(自动选中并渲染到右侧 MessagePanel) */ async function handleGroupCreated(groupId: number) { - await groupStore.loadGroups(true) + // TODO @AI:建群成功后,是不是可以不加载 group;按道理说,新建完,直接写入 groups 里面就好了。这里只负责 get 下; + await groupStore.fetchGroups(true) const group = groupStore.getGroup(groupId) if (!group) { return diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 699877c3e..4d712062a 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -1,7 +1,6 @@ import { defineStore, acceptHMRUpdate } from 'pinia' import { toRaw } from 'vue' import { store } from '@/store' -import { CACHE_KEY, useCache } from '@/hooks/web/useCache' import { ImConversationType, @@ -10,21 +9,50 @@ import { IM_AT_ALL_USER_ID, TIME_TIP_GAP_MS } from '../../utils/constants' -import { imStorage, StorageKeys } from '../../utils/storage' +import { getCurrentUserId, imStorage, safeImRemove, StorageKeys } from '../../utils/storage' import { generateClientMessageId, parseRecallMessageId } from '../../utils/message' import { resolveConversationLastContent } from '../../utils/conversation' +import { getSenderDisplayName } from '../../utils/user' +import { useGroupStore } from './groupStore' import type { Conversation, ConversationStoreMeta, Message } from '../types' +// TODO @AI:这个是不是 user.ts 增加一个类似的方法。只有解析到,才返回,没解析到,就返回 undefined 的。然后需要的地方,自己按需 set 到 conversation 里? +/** + * 刷新 lastSenderDisplayName 快照——必须在调用方更新 conversation.lastSenderId + * **之前**执行,靠它判断是否"同一发送人"决定旧快照保留 / 清空 + */ +function refreshLastSenderDisplayName(conversation: Conversation, senderId: number): void { + const liveSenderName = getSenderDisplayName(senderId, conversation.type, conversation.targetId) + const isRealName = liveSenderName !== String(senderId) + const isSameSender = conversation.lastSenderId === senderId + if (isRealName) { + conversation.lastSenderDisplayName = liveSenderName + return + } + + // 群聊算不出真名:单靠快照覆盖不了"换发送人 + members 没加载",主动补成员(store 内部已单飞) + // TODO @AI:是不是可以增加一个,补齐单个人?这样,改造这个方法。支持传递 groupId + memberUserId; + if (conversation.type === ImConversationType.GROUP) { + useGroupStore() + .fetchGroupMembers(conversation.targetId, true) + .catch((e) => + console.warn( + '[IM conversationStore] 兜底拉群成员失败', + { groupId: conversation.targetId }, + e + ) + ) + } + + // 同发送人沿用旧快照(冷拉期间常见),换人则清掉避免显示成上一个人 + if (!isSameSender) { + conversation.lastSenderDisplayName = undefined + } +} + // TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。 // TODO @芋艿:首次拉取消息时,如果消息过多,可能导致渲染卡顿。(1% 场景) -/** 获取当前登录用户编号 */ -function getCurrentUserId(): number { - const { wsCache } = useCache() - const user = wsCache.get(CACHE_KEY.USER)?.user - return Number(user?.id) || 0 -} - export const useConversationStore = defineStore('imConversationStore', { state: () => ({ conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊) @@ -104,7 +132,7 @@ export const useConversationStore = defineStore('imConversationStore', { try { const messages = (await imStorage.getItem( - StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId) + StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId) )) || [] // 发送中状态的消息标记为失败:重启后不可能仍处在发送中 messages.forEach((message) => { @@ -167,7 +195,7 @@ export const useConversationStore = defineStore('imConversationStore', { // 不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢) tasks.push( imStorage.setItem( - StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId), + StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId), toRaw(conversation.messages) ) ) @@ -182,9 +210,10 @@ export const useConversationStore = defineStore('imConversationStore', { if (!userId) { return } - void imStorage - .removeItem(StorageKeys.conversationMessage(userId, type, targetId)) - .catch((e) => console.error('[IM] 本地消息缓存删除失败', e)) + safeImRemove( + StorageKeys.conversationMessages(userId, type, targetId), + '[IM] 本地消息缓存删除失败' + ) }, // ==================== 会话查找 / 打开 ==================== @@ -361,12 +390,13 @@ export const useConversationStore = defineStore('imConversationStore', { return } - // 2.1 更新会话摘要(lastContent / lastSendTime + 事实索引 lastSenderId / lastMessageType / lastSelfSend); - // 发送人名不存快照,由 ConversationItem 渲染时通过 utils/user.getSenderDisplayName 实时算 + // 2.1 更新会话摘要 + 事实索引 + 发送人名快照 + refreshLastSenderDisplayName(conversation, messageInfo.senderId) conversation.lastContent = resolveConversationLastContent( messageInfo, conversation.type, - conversation.targetId + conversation.targetId, + conversation.lastSenderDisplayName ) conversation.lastSendTime = messageInfo.sendTime || Date.now() conversation.lastSenderId = messageInfo.senderId @@ -496,12 +526,13 @@ export const useConversationStore = defineStore('imConversationStore', { // content 不再写撤回文案:渲染层走 buildRecallTip(senderId, selfSend, ...) 实时算 // 这里清空,避免老 content 被误认为有效消息文本 message.content = '' - // 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引 + // 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引(lastSenderId 不变,沿用快照) if (conversation.messages[conversation.messages.length - 1]?.id === messageId) { conversation.lastContent = resolveConversationLastContent( message, conversation.type, - conversation.targetId + conversation.targetId, + conversation.lastSenderDisplayName ) conversation.lastMessageType = ImMessageType.RECALL } @@ -613,16 +644,28 @@ export const useConversationStore = defineStore('imConversationStore', { return } conversation.messages.splice(index, 1) - // 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引 + // 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引 + 发送人名快照 if (index === conversation.messages.length) { const last = conversation.messages[conversation.messages.length - 1] - conversation.lastContent = last - ? resolveConversationLastContent(last, conversation.type, conversation.targetId) - : '' - conversation.lastSendTime = last?.sendTime || conversation.lastSendTime - conversation.lastSenderId = last?.senderId - conversation.lastMessageType = last?.type - conversation.lastSelfSend = last?.selfSend + if (last) { + refreshLastSenderDisplayName(conversation, last.senderId) + conversation.lastContent = resolveConversationLastContent( + last, + conversation.type, + conversation.targetId, + conversation.lastSenderDisplayName + ) + conversation.lastSendTime = last.sendTime || conversation.lastSendTime + conversation.lastSenderId = last.senderId + conversation.lastMessageType = last.type + conversation.lastSelfSend = last.selfSend + } else { + conversation.lastContent = '' + conversation.lastSenderDisplayName = undefined + conversation.lastSenderId = undefined + conversation.lastMessageType = undefined + conversation.lastSelfSend = undefined + } } this.saveConversations(conversation) }, diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index aa650bb8d..ed5382596 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -12,6 +12,7 @@ import { } from '@/api/im/friend' import { useConversationStore } from './conversationStore' import { ImConversationType } from '../../utils/constants' +import { getCurrentUserId, imStorage, safeImSet, StorageKeys } from '../../utils/storage' import { getFriendDisplayName } from '../../utils/user' import type { Friend } from '../types' @@ -26,6 +27,7 @@ import type { Friend } from '../types' export const useFriendStore = defineStore('imFriendStore', { state: () => ({ friends: [] as Friend[], + // 仅 fetchFriends 成功后置位;loadFriends(IDB)不置位,否则后台 SWR 刷新会被短路 loaded: false }), @@ -50,8 +52,45 @@ export const useFriendStore = defineStore('imFriendStore', { }, actions: { - /** 从后端拉取并覆盖本地列表;同步刷新对应私聊会话的展示名 / 头像 */ - async loadFriends(force = false) { + // ==================== 本地缓存 ==================== + + // TODO @AI:是不是不用 “不更新 conversationStore——会话缓存和好友缓存是同一会话写入的,名字头像天然一致” 注释。只要说明 boolean 是啥就行了把。 + /** + * 从 IDB 恢复好友列表,返回 boolean 给首屏 SWR 决策用 + * + * 不更新 conversationStore——会话缓存和好友缓存是同一会话写入的,名字头像天然一致 + */ + async loadFriends(): Promise { + const userId = getCurrentUserId() + if (!userId) { + return false + } + try { + const cached = await imStorage.getItem(StorageKeys.friends(userId)) + if (!cached || cached.length === 0) { + return false + } + this.friends = cached + return true + } catch (e) { + console.warn('[IM friendStore] 本地好友缓存读取失败', e) + return false + } + }, + + /** 整桶持久化好友列表(量级有限,不维护增量) */ + saveFriends(): void { + const userId = getCurrentUserId() + if (!userId) { + return + } + safeImSet(StorageKeys.friends(userId), this.friends, '[IM friendStore] 本地好友缓存写入失败') + }, + + // ==================== 远端拉取 ==================== + + /** 从后端拉取并覆盖本地列表;同步刷新对应私聊会话的展示名 / 头像 + 落 IDB */ + async fetchFriends(force = false) { if (this.loaded && !force) { return } @@ -60,13 +99,14 @@ export const useFriendStore = defineStore('imFriendStore', { this.loaded = true // 同步 conversationStore 私聊会话的展示名 / 头像 / 免打扰 const conversationStore = useConversationStore() - for (const f of this.friends) { - conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, { - name: getFriendDisplayName(f), - avatar: f.avatar, - muted: f.muted + for (const friend of this.friends) { + conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, { + name: getFriendDisplayName(friend), + avatar: friend.avatar, + muted: friend.muted }) } + this.saveFriends() }, /** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */ @@ -82,7 +122,7 @@ export const useFriendStore = defineStore('imFriendStore', { } }, - /** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 loadFriends 刷新) */ + /** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 fetchFriends 刷新) */ async addFriend(friendUserId: number, preview?: Partial) { await apiAddFriend(friendUserId) if (preview) { @@ -127,6 +167,7 @@ export const useFriendStore = defineStore('imFriendStore', { avatar: friend.avatar, muted: friend.muted }) + this.saveFriends() }, /** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */ @@ -140,6 +181,7 @@ export const useFriendStore = defineStore('imFriendStore', { // 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友 const conversationStore = useConversationStore() conversationStore.removePrivateConversation(friendUserId) + this.saveFriends() }, /** 切换免打扰 */ @@ -148,6 +190,7 @@ export const useFriendStore = defineStore('imFriendStore', { const friend = this.getFriend(friendUserId) if (friend) { friend.muted = muted + this.saveFriends() } }, @@ -168,10 +211,11 @@ export const useFriendStore = defineStore('imFriendStore', { conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, { name: getFriendDisplayName(friend) }) + this.saveFriends() } }, - /** 切换用户时清空 */ + /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */ clear() { this.friends = [] this.loaded = false diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index be42d2f4b..591106f0a 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -11,11 +11,26 @@ import { updateGroupMember as apiUpdateGroupMember, type ImGroupMemberRespVO } from '@/api/im/group/member' -import { useUserStore } from '@/store/modules/user' import { useConversationStore } from './conversationStore' import { ImConversationType } from '../../utils/constants' +import { + getCurrentUserId, + imStorage, + safeImRemove, + safeImSet, + StorageKeys +} from '../../utils/storage' import type { Group, GroupMember } from '../types' +/** + * fetchGroupMembers 单飞表:同 groupId 并发只打一次接口 + * + * key 必须带 userId——账号切换时 A 的 in-flight 不能被 B 复用,否则 IIFE 内部 + * 的 saveGroupMembers 会把 A 的成员数据写进 B 的 IDB 桶 + */ +const pendingMemberFetches = new Map>() +const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}` + /** * IM 群 Store * @@ -27,6 +42,7 @@ import type { Group, GroupMember } from '../types' export const useGroupStore = defineStore('imGroupStore', { state: () => ({ groups: [] as Group[], + // 仅 fetchGroups 成功后置位;loadGroups(IDB)不置位,否则后台 SWR 刷新会被短路 loaded: false }), @@ -39,15 +55,115 @@ export const useGroupStore = defineStore('imGroupStore', { }, actions: { - /** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 */ - async loadGroups(force = false) { + // ==================== 本地缓存 ==================== + + // TODO @AI:简化注释,参考 friendStore; + /** + * 从 IDB 恢复群列表(不带 members),返回 boolean 给首屏 SWR 决策用 + * + * 不更新 conversationStore——会话缓存和群缓存是同一会话写入的,名字头像天然一致 + */ + async loadGroups(): Promise { + const userId = getCurrentUserId() + if (!userId) { + return false + } + try { + const cached = await imStorage.getItem(StorageKeys.groups(userId)) + if (!cached || cached.length === 0) { + return false + } + this.groups = cached + return true + } catch (e) { + console.warn('[IM groupStore] 本地群缓存读取失败', e) + return false + } + }, + + /** 整桶持久化群列表;剥离 members 字段,成员另走 groupMembers:${groupId} 分桶 */ + saveGroups(): void { + const userId = getCurrentUserId() + if (!userId) { + return + } + const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest) + safeImSet( + StorageKeys.groups(userId), + groupsWithoutMembers, + '[IM groupStore] 本地群缓存写入失败' + ) + }, + + // TODO @AI:命中返回数组(caller 紧接渲染省一次二次访问),未命中返回 null 是不是没必要注释?只是说返回结果而已。。。 + /** 从 IDB 恢复指定群成员;命中返回数组(caller 紧接渲染省一次二次访问),未命中返回 null */ + async loadGroupMembers(groupId: number): Promise { + const userId = getCurrentUserId() + if (!userId) { + return null + } + // in-memory 已就位(同会话二次进群 / fetchGroupMembers 已跑过):直接复用 + const cachedInMemory = this.getGroup(groupId)?.members + if (cachedInMemory && cachedInMemory.length > 0) { + return cachedInMemory + } + try { + const cached = await imStorage.getItem( + StorageKeys.groupMembers(userId, groupId) + ) + if (!cached || cached.length === 0) { + return null + } + // 把 IDB 拿到的成员落到对应 group + const group = this.getGroup(groupId) + if (!group) { + // group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup 。 + // 原因:避免把假名灌进 conversation.name + groups IDB 桶;等 fetchGroups 浅合并时被真名覆盖 + this.groups.push({ + id: groupId, + name: '', + members: cached, + memberCount: cached.length + }) + } else { + group.members = cached + group.memberCount = cached.length + } + return cached + } catch (e) { + console.warn('[IM groupStore] 本地群成员缓存读取失败', { groupId }, e) + return null + } + }, + + /** 整桶持久化指定群成员 */ + saveGroupMembers(groupId: number): void { + const userId = getCurrentUserId() + if (!userId) { + return + } + const members = this.getGroup(groupId)?.members + if (!members) { + return + } + safeImSet( + StorageKeys.groupMembers(userId, groupId), + members, + `[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})` + ) + }, + + // ==================== 远端拉取 ==================== + + /** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */ + async fetchGroups(force = false) { if (this.loaded && !force) { return } - // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers) + // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers) const list = await apiGetMyGroupList() const fresh = (list || []).map(convertGroup) - // 合并而非全量替换:保留 loadGroupMembers 已经写入的 members / memberCount / muted + // 合并而非全量替换:保留 loadGroupMembers / fetchGroupMembers 已经写入的 members / memberCount / muted // (这些字段不在 ImGroupRespVO 里,全量替换会把成员级数据全冲掉) const groupMap = new Map(this.groups.map((group) => [group.id, group])) this.groups = fresh.map((group) => { @@ -71,10 +187,11 @@ export const useGroupStore = defineStore('imGroupStore', { muted: group.muted }) } + this.saveGroups() }, /** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */ - async loadGroupInfo(groupId: number) { + async fetchGroupInfo(groupId: number) { try { const data = await apiGetGroup(groupId) if (!data) { @@ -82,59 +199,80 @@ export const useGroupStore = defineStore('imGroupStore', { } this.upsertGroup(convertGroup(data)) } catch (e) { - console.warn('[IM groupStore] loadGroupInfo 失败', e) + console.warn('[IM groupStore] fetchGroupInfo 失败', e) } }, - /** - * 按群拉取成员(带缓存,force=true 强制刷新) - * - * 1. 缓存:group 已加载且 members 就绪 → 直接返回 - * 2. 拉取 + 转换:调 /im/group-member/list 后映射成本地 GroupMember - * 3. 回填当前用户的 muted: - * 后端只在成员维度返回 muted(apiGetMyGroupList 不带),借这次拉成员把它落到 group / conversation; - * 否则冷启动 / 清缓存后,服务端已免打扰的群在会话列表里仍显示为未免打扰 - * 4. 落地(关键:race-safe 重新 getGroup): - * apiGetGroupMemberList 期间 loadGroups 可能已经把真实 group 填进 store, - * 沿用入口快照会让我们错走 4.1 分支、把真实 name 覆盖成 String(groupId) - * 4.1 group 还没就位(loadGroupMembers 跑在 loadGroups 之前)→ 占位 upsertGroup - * 4.2 group 已就位 → 直接写 members 字段,并把 muted 单独推回 conversation - */ - async loadGroupMembers(groupId: number, force = false): Promise { - // 1. 缓存 + // TODO @AI:in-flight 单飞;这个注释有点奇怪 + /** 按群拉取成员(in-memory 缓存 + in-flight 单飞,force=true 强刷)+ 落 IDB */ + fetchGroupMembers(groupId: number, force = false): Promise { const cached = this.getGroup(groupId) if (cached && cached.members && !force) { - return cached.members + return Promise.resolve(cached.members) } - - // 2. 拉取 + 转换 - const list = await apiGetGroupMemberList(groupId) - const members = (list || []).map((member) => convertGroupMember(member, groupId)) - - // 3. 回填 muted - const userStore = useUserStore() - const currentUserId = Number(userStore.getUser?.id) || 0 - const me = members.find((m) => m.userId === currentUserId) - const muted = !!me?.muted - - // 4. 落地(必须 await 之后重新 getGroup,避免踩 race) - const group = this.getGroup(groupId) - if (!group) { - this.upsertGroup({ - id: groupId, - name: String(groupId), - members, - memberCount: members.length, - muted - }) - } else { - group.members = members - group.memberCount = members.length - group.muted = muted - const conversationStore = useConversationStore() - conversationStore.updateConversation(ImConversationType.GROUP, groupId, { muted }) + const requestUserId = getCurrentUserId() + if (!requestUserId) { + return Promise.resolve([]) } - return members + // TODO @AI:最好这里注释下。 + const key = pendingMemberKey(requestUserId, groupId) + const inflight = pendingMemberFetches.get(key) + if (inflight) { + return inflight + } + const promise = (async () => { + // TODO @AI:这里是不是要注释下 + const list = await apiGetGroupMemberList(groupId) + const members = (list || []).map((member) => convertGroupMember(member, groupId)) + // 网络往返期间用户可能已切——A 的数据写到 B 的 store / IDB 是数据互串,丢弃 + // TODO @AI:这个应该不存在把?有点过度设计了。 + if (getCurrentUserId() !== requestUserId) { + return [] + } + + // muted 是成员维度字段(apiGetMyGroupList 不带),借这次拉成员回填到 group / conversation + const me = members.find((m) => m.userId === requestUserId) + const muted = !!me?.muted + + // 必须 await 之后重新 getGroup,避免 fetchGroups 已并发写入真实 group 的 race + const group = this.getGroup(groupId) + const isPlaceholder = !group + let mutedChanged = false + if (!group) { + // group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup + // 避免把假名灌进 conversation.name + groups IDB 桶。等 fetchGroups 浅合并时被真名覆盖 + this.groups.push({ + id: groupId, + name: '', + members, + memberCount: members.length, + muted + }) + } else { + group.members = members + group.memberCount = members.length + // TODO @AI:这里最好注释下。 + if (group.muted !== muted) { + group.muted = muted + mutedChanged = true + const conversationStore = useConversationStore() + conversationStore.updateConversation(ImConversationType.GROUP, groupId, { muted }) + } + } + + // TODO @AI:“避免批量进群 fan-out 时重复重写整桶”调整下。fan-out 不太好理解。 + // groups 桶仅在 muted 实际变化时写——避免批量进群 fan-out 时重复重写整桶 + this.saveGroupMembers(groupId) + if (!isPlaceholder && mutedChanged) { + this.saveGroups() + } + return members + // TODO @AI:finally 最注释下,好理解; + })().finally(() => pendingMemberFetches.delete(key)) + + // TODO @AI:这里是不是要注释下 + pendingMemberFetches.set(key, promise) + return promise }, /** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把 name / avatar / muted 推到对应会话 */ @@ -145,12 +283,15 @@ export const useGroupStore = defineStore('imGroupStore', { } else { this.groups.push(group) } + // TODO @AI:这里注释下 const conversationStore = useConversationStore() conversationStore.updateConversation(ImConversationType.GROUP, group.id, { name: group.name, avatar: group.avatar, muted: group.muted }) + // TODO @AI:这里注释下 + this.saveGroups() }, /** 本地移除(由 WebSocket GROUP_DEL 事件触发) */ @@ -159,21 +300,36 @@ export const useGroupStore = defineStore('imGroupStore', { this.groups = this.groups.filter((g) => g.id !== id) const conversationStore = useConversationStore() conversationStore.removeGroupConversation(id) + this.saveGroups() + // TODO @AI:避免 IDB 留 orphan;注释调整下,orphan 有点不好理解。 + // 把对应的群成员桶物理删掉,避免 IDB 留 orphan + const userId = getCurrentUserId() + if (userId) { + safeImRemove( + StorageKeys.groupMembers(userId, id), + `[IM groupStore] 群成员缓存删除失败 (groupId=${id})` + ) + } }, /** 切换免打扰:推后端 + 落本地 */ async setMuted(id: number, muted: boolean) { await apiUpdateGroupMember({ groupId: id, muted }) const group = this.getGroup(id) - if (group) { - group.muted = muted + if (!group) { + return } + group.muted = muted + this.saveGroups() }, - /** 切换用户时清空 */ + /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */ clear() { this.groups = [] this.loaded = false + // TODO @AI:in-flight 这种调整下,不好理解。 + // 旧账号的 in-flight 即便 resolve 也会被 IIFE 内部的 userId 校验丢弃,索性清掉避免悬挂 + pendingMemberFetches.clear() } } }) diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index e6c745dd8..c5cde9076 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -394,7 +394,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底) const group = groupStore.getGroup(websocketMessage.groupId) if (!group) { - groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined) + groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined) } // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}` @@ -512,13 +512,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** GROUP_CREATE:本端入群(建群 / 被拉入);拉取群详情入库 */ handleGroupCreate(websocketMessage: ImGroupMessageDTO) { const groupStore = useGroupStore() - groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined) + groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined) }, /** GROUP_UPDATE:群信息变更,重新拉一次群详情 */ handleGroupUpdate(websocketMessage: ImGroupMessageDTO) { const groupStore = useGroupStore() - groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined) + groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined) }, /** GROUP_DELETE:群解散 / 自己退群 / 被踢出;本端清除群 + 级联清理群聊会话 */ @@ -527,10 +527,15 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { groupStore.removeGroup(websocketMessage.groupId) }, - /** GROUP_MEMBER_UPDATE:多端同步自己在某群的成员属性变更(当前主要是免打扰);重新拉群详情 */ + /** + * GROUP_MEMBER_UPDATE:多端同步成员属性变更(昵称 / 免打扰 / 退群等) + * + * 必须强刷成员而非群元数据——这些字段都在 ImGroupMemberRespVO 上,apiGetMyGroupList 不带; + * 持久化后若不强刷,IDB 成员桶会长期陈旧 + */ handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) { const groupStore = useGroupStore() - groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined) + groupStore.fetchGroupMembers(websocketMessage.groupId, true).catch(() => undefined) }, // ==================== 心跳 / 重连 ==================== diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index a638e1f59..8006d746f 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -49,14 +49,13 @@ export interface Conversation { lastSendTime: number // 最后一条消息时间,用于排序 unreadCount: number // 未读数 messages: Message[] // 消息列表 - /** - * 最后一条消息的事实索引("谁、什么类型、是不是我发的") - * 给会话列表前缀 / 撤回摘要等位置实时算展示文案——发送人名走 utils/user.getSenderDisplayName, - * 永远不存名字快照,改备注 / 改群昵称后所有界面会自动响应式刷新 - */ + // TODO @AI:lastMessage 对象,会不会更干净一点。然后把需要的字段放进去? + /** 最后一条消息的事实索引;展示名实时算(getSenderDisplayName),不存名字快照 */ lastSenderId?: number lastMessageType?: number lastSelfSend?: boolean + /** 发送人显示名快照——仅作 getSenderDisplayName 算不出名字时的 fallback */ + lastSenderDisplayName?: string // ========== UI 状态 ========== deleted?: boolean // 是否已删除(软删标记,持久化时过滤) @@ -130,10 +129,11 @@ export interface GroupMember { userId: number // 用户编号 avatar?: string // 头像 nickname: string // 用户昵称 + // TODO @AI:还不是把 muted 字段是不是放到 Group 里?displayUserName、displayGroupName、muted; displayUserName?: string // 组内显示名(不与 nickname 合并,由消费方按需取舍) displayGroupName?: string // 群显示备注(当前用户对该群的自定义名) status?: number // 在群 / 退群状态,对齐 CommonStatusEnum - muted?: boolean // 当前成员对该群的免打扰开关(loadGroupMembers 用它回填 Group.muted) + muted?: boolean // 当前成员对该群的免打扰开关(fetchGroupMembers 用它回填 Group.muted) // ========== 前端扩展字段 ========== isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算) diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts index 07184c430..96d51361a 100644 --- a/src/views/im/utils/conversation.ts +++ b/src/views/im/utils/conversation.ts @@ -1,9 +1,13 @@ // ==================================================================== // IM 会话 / 撤回展示 utility // ==================================================================== +// TODO @AI:这里的注释,不用写历史,只写当下。 // 职责:基于会话上下文 + sender 信息实时算"展示文案"。 -// 之前这些值是写入消息时固化到 Message.senderShowName / Conversation.senderShowName, -// 改备注 / 改群昵称后历史消息不会刷新;改成实时算后字段语义彻底干净。 +// 历史上 Message / Conversation 上有 senderShowName 快照字段,改备注 / 改群昵称后历史消息 +// 不会刷新;现在 Message 不再带任何名字快照,发送人名一律由 utils/user.getSenderDisplayName +// 实时算。Conversation.lastSenderDisplayName 仅作 fallback 快照(解决"没打开过的群" +// members 没加载时的兜底显示),通过 fallback 参数透传到本文件的 buildRecallTip / +// resolveConversationLastContent 而非内部硬编码读取 // // 与 utils/user.ts 的关系: // user.ts 回答"谁叫什么名字",conversation.ts 在它基础上拼"撤回 tip / 摘要"等文案 @@ -14,35 +18,33 @@ import { parseMessage, resolveTipText, type TextMessage } from './message' import { getSenderDisplayName } from './user' import type { Message } from '../home/types' -/** - * 撤回提示文案:自己撤回固定 "你撤回了一条消息",对方撤回带按 WeChat 优先级算的发送人名 - * - * 发送人名一律实时算(改备注 / 改群昵称后立即刷新),store 没 ready 时由 - * getSenderDisplayName 内部退到 String(senderId),最差兜底显示"对方" - */ +/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallback 兜底) */ +// TODO @AI:fallbackName export function buildRecallTip( senderId: number, selfSend: boolean, conversationType: number, - conversationTargetId: number + conversationTargetId: number, + fallback?: string ): string { if (selfSend) { return '你撤回了一条消息' } - const senderDisplayName = getSenderDisplayName(senderId, conversationType, conversationTargetId) + const senderDisplayName = getSenderDisplayName( + senderId, + conversationType, + conversationTargetId, + fallback + ) return `${senderDisplayName || '对方'} 撤回了一条消息` } -/** - * 根据消息类型计算会话列表最后一条摘要 - * - * RECALL 分支走实时 buildRecallTip(不再依赖 message 上的 senderShowName 快照); - * 其它分支照旧由 message.content 派生 - */ +/** 会话列表最后一条摘要:RECALL 走 buildRecallTip + fallback;其它按消息类型派生 */ export function resolveConversationLastContent( message: Message, conversationType: number, - conversationTargetId: number + conversationTargetId: number, + fallback?: string ): string { switch (message.type) { case ImMessageType.IMAGE: @@ -58,7 +60,8 @@ export function resolveConversationLastContent( message.senderId, message.selfSend, conversationType, - conversationTargetId + conversationTargetId, + fallback ) case ImMessageType.TEXT: return parseMessage(message.content)?.content ?? '' diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts index a6c38f9e2..4542e0ab5 100644 --- a/src/views/im/utils/storage.ts +++ b/src/views/im/utils/storage.ts @@ -1,4 +1,7 @@ import localforage from 'localforage' +import { toRaw } from 'vue' + +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' /** * IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage) @@ -19,10 +22,11 @@ export const imStorage = localforage.createInstance({ /** * 存储 key 统一在此生成 * - * - 会话相关(meta / message)走 imStorage(IndexedDB),key 形如 `conversation:xxx:{userId}:...` + * - 会话 / 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶 * - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB * - * 所有会话相关 key 都注入 userId:多账号切换时按用户隔离,避免数据互串。 + * 所有业务 key 都注入 userId:多账号切换按用户隔离,避免数据互串;账号切换时只清 in-memory, + * IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照 */ export const StorageKeys = { /** @@ -41,9 +45,37 @@ export const StorageKeys = { * 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大。 * 软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留。 */ - conversationMessage: (userId: number | string, type: number, targetId: number) => - `conversation:message:${userId}:${type}:${targetId}`, + conversationMessages: (userId: number | string, type: number, targetId: number) => + `conversation:messages:${userId}:${type}:${targetId}`, + + /** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */ + friends: (userId: number | string) => `friends:${userId}`, + /** 群列表整桶(不含 members,剥离到独立 key),保证整桶写不带成员爆量 */ + groups: (userId: number | string) => `groups:${userId}`, + /** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */ + groupMembers: (userId: number | string, groupId: number) => + `groupMembers:${userId}:${groupId}`, /** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */ asideWidth: 'im:aside' } as const + +/** 取当前登录用户编号;返回 0 表示未登录,调用方一律早 return 不写无主 key */ +export function getCurrentUserId(): number { + const { wsCache } = useCache() + const user = wsCache.get(CACHE_KEY.USER)?.user + return Number(user?.id) || 0 +} + +/** IDB 写入:fire-and-forget */ +// TODO @AI:setQuietly?会不会更好? +export function safeImSet(key: string, value: unknown, errorLabel: string): void { + // toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘 + const raw = value && typeof value === 'object' ? toRaw(value) : value + void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e)) +} + +// TODO @AI:removeQuietly?会不会更好? +export function safeImRemove(key: string, errorLabel: string): void { + void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e)) +} diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts index 02ba052cd..76497e768 100644 --- a/src/views/im/utils/user.ts +++ b/src/views/im/utils/user.ts @@ -46,26 +46,26 @@ export function getMemberDisplayName( return resolveRemark(friend) || member.displayUserName || member.nickname } +// TODO @AI:fallbackName?这样更清晰? /** - * 消息发送者「显示名」:渲染时实时算,按 conversation 上下文走 WeChat 优先级 + * 消息发送者显示名:渲染时实时算,按 WeChat 优先级 * - * - 自己(senderId === currentUserId):当前用户真实昵称 + * - 自己:userStore.nickname * - 私聊对方:好友备注 > 真实昵称 * - 群聊对方:好友备注 > 群备注(displayUserName) > 真实昵称 - * - 查不到(store 还没 ready / 陌生人):兜底返回 String(senderId) - * - * 用在所有"展示给用户看的发送人名"位置(气泡上方、群聊列表前缀、撤回 tip 等)。 - * 不写入 message 字段——改备注 / 改群昵称后历史消息能跟着 Vue 响应式自动刷新 + * - 查不到:fallback || String(senderId) */ export function getSenderDisplayName( senderId: number, conversationType: number, - conversationTargetId: number + conversationTargetId: number, + fallback?: string ): string { + // TODO @AI:getCurrentUserId;貌似可以复用; const userStore = useUserStore() - const selfId = Number(userStore.getUser?.id) || 0 + const selfId = Number(userStore.getUser?.id) || 0 // TODO @AI:selfUserId 更好一点; - // 群聊场景所有人(含自己)都走 member + friend 三级——自己设了"我在本群昵称"也要生效 + // 自己也走 member 分支:要尊重"我在本群昵称"(GroupMember.displayUserName) if (conversationType === ImConversationType.GROUP) { const group = useGroupStore().getGroup(conversationTargetId) const member = group?.members?.find((m) => m.userId === senderId) @@ -73,32 +73,33 @@ export function getSenderDisplayName( const friend = useFriendStore().getFriend(senderId) return getMemberDisplayName(member, friend) } - // member 没加载到——self 兜底走 userStore,对方兜底走 senderId 字符串 + // member 没加载——self 走 userStore,对方走 fallback if (senderId === selfId) { - return userStore.getUser?.nickname || String(senderId) + return userStore.getUser?.nickname || fallback || String(senderId) } - return String(senderId) + return fallback || String(senderId) } // 私聊场景:自己直接走 userStore;对方走好友备注 > 真实昵称 if (conversationType === ImConversationType.PRIVATE) { if (senderId === selfId) { - return userStore.getUser?.nickname || String(senderId) + return userStore.getUser?.nickname || fallback || String(senderId) } const friend = useFriendStore().getFriend(senderId) if (friend) { return getFriendDisplayName(friend) } - return String(senderId) + return fallback || String(senderId) } // 未知会话类型兜底 if (senderId === selfId) { - return userStore.getUser?.nickname || String(senderId) + return userStore.getUser?.nickname || fallback || String(senderId) } - return String(senderId) + return fallback || String(senderId) } +// TODO @AI:是不是参考 getSenderDisplayName 注释风格。- xxx - xxx; /** * 消息发送者「真实昵称」:永远是 nickname,不掺备注 * @@ -110,6 +111,7 @@ export function getSenderRealNickname( conversationType: number, conversationTargetId: number ): string { + // TODO @AI:getCurrentUserId;貌似可以复用; const userStore = useUserStore() const selfId = Number(userStore.getUser?.id) || 0 @@ -126,6 +128,7 @@ export function getSenderRealNickname( return String(senderId) } + // TODO @AI:这里要注释下么? if (conversationType === ImConversationType.PRIVATE) { if (senderId === selfId) { return userStore.getUser?.nickname || String(senderId) @@ -134,6 +137,7 @@ export function getSenderRealNickname( return friend?.nickname || String(senderId) } + // TODO @AI:这里要注释下么? if (senderId === selfId) { return userStore.getUser?.nickname || String(senderId) }