From 38ecc4f40ced7b4428f672f62852ef71cfec9606 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 22 May 2026 08:38:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BF=AE=E4=B8=80?= =?UTF-8?q?=E6=89=B9=E5=89=8D=E7=AB=AF=E6=80=A7=E8=83=BD=20/=20=E8=B7=A8?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E9=98=B2=E5=BE=A1=E4=B8=8E=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - friendStore.getFriend 改 friendMap 索引,高频反查从 O(N) 降到 O(1) - faceStore 加 storeEpoch,切账号后旧表情拉取 / 增删响应不再回写新账号 - friendStore 写路径统一补 epoch 守卫(loadFriendInfo / 单查申请 / 删好友 / 免打扰 / 置顶 / 拉黑 / 备注),切账号瞬间的旧响应不污染新账号好友状态 - 私聊侧边栏 friend 缺失时给加载占位,替代原本的空白抽屉 --- .../conversation/ConversationPrivateSide.vue | 12 +++- src/views/im/home/store/faceStore.ts | 30 +++++++++- src/views/im/home/store/friendStore.ts | 55 +++++++++++++++++-- 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue index a02c41b18..604e07e9b 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue @@ -13,7 +13,15 @@ append-to-body modal-class="im-conversation-private-side__modal" > -
+ +
+ 加载中… +
+
@@ -194,7 +202,7 @@ watch(visible, (open) => { } }) -/** 备注 popover 点击保存:先走 store API 同步后端,成功后再关 popover + 提示 */ +/** 备注 popover 点击保存:先走 store API 同步后端,成功后再关 popover + 提示(接口错误由全局拦截器统一 toast,不重复 catch) */ async function handleSaveDisplayName() { if (!props.friend) { return diff --git a/src/views/im/home/store/faceStore.ts b/src/views/im/home/store/faceStore.ts index 3aa7fede8..3fe789492 100644 --- a/src/views/im/home/store/faceStore.ts +++ b/src/views/im/home/store/faceStore.ts @@ -28,6 +28,9 @@ export const useFaceStore = defineStore('imFace', () => { /** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */ const faceUserItems = ref([]) + /** reset() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store,防跨账号数据泄漏 */ + let storeEpoch = 0 + /** * 系统表情包拉取 promise;ensureFacePacks 内 cache: * - null = 还没拉过,下次调用真发请求 @@ -38,13 +41,19 @@ export const useFaceStore = defineStore('imFace', () => { /** 按需拉取系统表情包(已拉过则直接复用 cached promise) */ async function ensureFacePacks(): Promise { if (!facePacksPromise) { + const requestEpoch = storeEpoch facePacksPromise = apiGetFacePackList() .then((data) => { + if (requestEpoch !== storeEpoch) { + return + } facePacks.value = data || [] }) .catch((e) => { console.warn('[IM] 拉取表情包失败', e) - facePacksPromise = null + if (requestEpoch === storeEpoch) { + facePacksPromise = null + } throw e }) } @@ -56,13 +65,19 @@ export const useFaceStore = defineStore('imFace', () => { /** 按需拉取个人表情(已拉过则直接复用 cached promise) */ async function ensureFaceUserItems(): Promise { if (!faceUserItemsPromise) { + const requestEpoch = storeEpoch faceUserItemsPromise = apiGetFaceUserItemList() .then((data) => { + if (requestEpoch !== storeEpoch) { + return + } faceUserItems.value = data || [] }) .catch((e) => { console.warn('[IM] 拉取个人表情失败', e) - faceUserItemsPromise = null + if (requestEpoch === storeEpoch) { + faceUserItemsPromise = null + } throw e }) } @@ -76,11 +91,16 @@ export const useFaceStore = defineStore('imFace', () => { * 返回 true / false 与 removeFaceUserItem 风格对齐;调用方按 boolean 决定是否提示 */ async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise { + const requestEpoch = storeEpoch try { const id = await apiCreateFaceUserItem(reqVO) if (!id) { return false } + // reset 已切账号:旧请求拿到的 id 不能再 unshift 进新账号内存 + if (requestEpoch !== storeEpoch) { + return false + } // id 不在缓存里才插入;服务端唯一约束兜底了 race,本地理论上不会拿到重复 id if (!faceUserItems.value.some((item) => item.id === id)) { faceUserItems.value.unshift({ @@ -100,8 +120,13 @@ export const useFaceStore = defineStore('imFace', () => { /** 删除个人表情;本地立即移除 */ async function removeFaceUserItem(id: number): Promise { + const requestEpoch = storeEpoch try { await apiDeleteFaceUserItem(id) + // reset 已切账号:不要再 filter 新账号列表 + if (requestEpoch !== storeEpoch) { + return false + } faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id) return true } catch (e) { @@ -116,6 +141,7 @@ export const useFaceStore = defineStore('imFace', () => { faceUserItems.value = [] facePacksPromise = null faceUserItemsPromise = null + storeEpoch++ } return { diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index 85afc5bbd..be2a2f6ad 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -77,12 +77,23 @@ export const useFriendStore = defineStore('imFriendStore', { }), getters: { + /** + * friendUserId → Friend 的 O(1) 索引;从 friends 数组派生,Pinia 在 friends 变化时自动重算 + * + * 消息渲染需要按 senderId 反查发送人头像 / 备注,每条消息渲染都会 getFriend; + * 直接 find 时 N 条消息 × M 好友 = O(N×M);建索引后单次读 O(1),重建只在写好友(fetchFriends / upsertFriend 等)时发生 + */ + friendMap: (state): Map => { + const map = new Map() + for (const friend of state.friends) { + map.set(friend.friendUserId, friend) + } + return map + }, /** 按 friendUserId 找好友(含已软删的 DISABLE 记录,调用方自行判定) */ - getFriend: - (state) => - (friendUserId: number): Friend | undefined => { - return state.friends.find((friend) => friend.friendUserId === friendUserId) - }, + getFriend(): (friendUserId: number) => Friend | undefined { + return (friendUserId: number) => this.friendMap.get(friendUserId) + }, /** 当前生效的好友列表(过滤掉 DISABLE 软删记录) */ getActiveFriends: (state): Friend[] => { return state.friends.filter((friend) => friend.status !== CommonStatusEnum.DISABLE) @@ -195,11 +206,16 @@ export const useFriendStore = defineStore('imFriendStore', { /** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */ async loadFriendInfo(friendUserId: number) { + const requestEpoch = storeEpoch try { const data = await apiGetFriend(friendUserId) if (!data) { return } + // clear() 已切账号:旧请求的好友详情不能再 upsert 进新账号的 friends + if (requestEpoch !== storeEpoch) { + return + } this.upsertFriend(convertFriend(data)) } catch (e) { console.warn('[IM friendStore] loadFriendInfo 失败', e) @@ -301,10 +317,15 @@ export const useFriendStore = defineStore('imFriendStore', { /** 按 id 从后端单查并 upsert 到本地(dispatcher 兜底用,避免全量重拉);后端带越权过滤 */ async loadFriendRequest(requestId: number) { + const requestEpoch = storeEpoch const data = await apiGetMyFriendRequest(requestId) if (!data) { return } + // clear() 已切账号:旧请求的申请记录不能再写进新账号的 friendRequests + if (requestEpoch !== storeEpoch) { + return + } const next = convertFriendRequest(data) const existing = this.findFriendRequest(requestId) if (existing) { @@ -329,13 +350,21 @@ export const useFriendStore = defineStore('imFriendStore', { /** 删除好友(单向软删,本端置 DISABLE);clear=true 时级联清理本地相关数据(如私聊会话),并透传后端给多端同步 */ async deleteFriend(friendUserId: number, clear: boolean = true) { + const requestEpoch = storeEpoch await apiDeleteFriend(friendUserId, clear) + if (requestEpoch !== storeEpoch) { + return + } this.removeFriend(friendUserId, clear) }, /** 切换免打扰:同步会话的 silent 字段,避免会话列表 silent 图标等 1210 推到才更新 */ async setSilent(friendUserId: number, silent: boolean) { + const requestEpoch = storeEpoch await apiUpdateFriend({ friendUserId, silent }) + if (requestEpoch !== storeEpoch) { + return + } const friend = this.getFriend(friendUserId) if (friend) { friend.silent = silent @@ -347,7 +376,11 @@ export const useFriendStore = defineStore('imFriendStore', { /** 切换联系人置顶 */ async setPinned(friendUserId: number, pinned: boolean) { + const requestEpoch = storeEpoch await apiUpdateFriend({ friendUserId, pinned }) + if (requestEpoch !== storeEpoch) { + return + } const friend = this.getFriend(friendUserId) if (friend) { friend.pinned = pinned @@ -357,7 +390,11 @@ export const useFriendStore = defineStore('imFriendStore', { /** 拉黑好友:本端乐观更新 + 调接口;后端 FRIEND_BLOCK 推到时由 dispatcher 兜底同步多端 */ async blockFriend(friendUserId: number) { + const requestEpoch = storeEpoch await apiBlockFriend(friendUserId) + if (requestEpoch !== storeEpoch) { + return + } const friend = this.getFriend(friendUserId) if (friend) { friend.blocked = true @@ -367,7 +404,11 @@ export const useFriendStore = defineStore('imFriendStore', { /** 移出黑名单:本端乐观更新 + 调接口;后端 FRIEND_UNBLOCK 推到时由 dispatcher 兜底同步多端 */ async unblockFriend(friendUserId: number) { + const requestEpoch = storeEpoch await apiUnblockFriend(friendUserId) + if (requestEpoch !== storeEpoch) { + return + } const friend = this.getFriend(friendUserId) if (friend) { friend.blocked = false @@ -377,9 +418,13 @@ export const useFriendStore = defineStore('imFriendStore', { /** 修改好友展示备注(仅自己可见) */ async setDisplayName(friendUserId: number, displayName: string) { + const requestEpoch = storeEpoch const value = displayName.trim() // 后端 displayName 语义:null/undefined = 不改,"" = 清空,所以这里直接传 value(可能是空串) await apiUpdateFriend({ friendUserId, displayName: value }) + if (requestEpoch !== storeEpoch) { + return + } const friend = this.getFriend(friendUserId) if (friend) { friend.displayName = value