feat(im): 修一批前端性能 / 跨账号防御与侧边栏占位

- friendStore.getFriend 改 friendMap 索引,高频反查从 O(N) 降到 O(1)
- faceStore 加 storeEpoch,切账号后旧表情拉取 / 增删响应不再回写新账号
- friendStore 写路径统一补 epoch 守卫(loadFriendInfo / 单查申请 / 删好友 /
  免打扰 / 置顶 / 拉黑 / 备注),切账号瞬间的旧响应不污染新账号好友状态
- 私聊侧边栏 friend 缺失时给加载占位,替代原本的空白抽屉
im
YunaiV 2026-05-22 08:38:56 +08:00
parent 9893aedbb2
commit 38ecc4f40c
3 changed files with 88 additions and 9 deletions

View File

@ -13,7 +13,15 @@
append-to-body
modal-class="im-conversation-private-side__modal"
>
<div v-if="friend" class="flex flex-col h-full bg-[var(--el-bg-color)]">
<!-- friend 缺失场景陌生人会话刚打开 / 好友数据还没补拉到空白会让用户以为抽屉坏了给个加载占位 -->
<div
v-if="!friend"
v-loading="true"
class="flex flex-col items-center justify-center h-full text-13px text-[var(--el-text-color-placeholder)] bg-[var(--el-bg-color)]"
>
加载中
</div>
<div v-else class="flex flex-col h-full bg-[var(--el-bg-color)]">
<div class="flex-1 overflow-y-auto bg-[var(--el-fill-color-light)]">
<!-- 好友宫格 tile + "+" tile对齐 GroupSide 视觉让两种抽屉看起来是一家的 -->
<div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--el-bg-color)]">
@ -194,7 +202,7 @@ watch(visible, (open) => {
}
})
/** 备注 popover 点击保存:先走 store API 同步后端,成功后再关 popover + 提示 */
/** 备注 popover 点击保存:先走 store API 同步后端,成功后再关 popover + 提示(接口错误由全局拦截器统一 toast不重复 catch */
async function handleSaveDisplayName() {
if (!props.friend) {
return

View File

@ -28,6 +28,9 @@ export const useFaceStore = defineStore('imFace', () => {
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
const faceUserItems = ref<ImFaceUserItemVO[]>([])
/** reset() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store防跨账号数据泄漏 */
let storeEpoch = 0
/**
* promiseensureFacePacks cache
* - null =
@ -38,13 +41,19 @@ export const useFaceStore = defineStore('imFace', () => {
/** 按需拉取系统表情包(已拉过则直接复用 cached promise */
async function ensureFacePacks(): Promise<void> {
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<void> {
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<boolean> {
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<boolean> {
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 {

View File

@ -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<number, Friend> => {
const map = new Map<number, Friend>()
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', {
/** 删除好友(单向软删,本端置 DISABLEclear=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