✨ feat(im): 修一批前端性能 / 跨账号防御与侧边栏占位
- friendStore.getFriend 改 friendMap 索引,高频反查从 O(N) 降到 O(1) - faceStore 加 storeEpoch,切账号后旧表情拉取 / 增删响应不再回写新账号 - friendStore 写路径统一补 epoch 守卫(loadFriendInfo / 单查申请 / 删好友 / 免打扰 / 置顶 / 拉黑 / 备注),切账号瞬间的旧响应不污染新账号好友状态 - 私聊侧边栏 friend 缺失时给加载占位,替代原本的空白抽屉im
parent
9893aedbb2
commit
38ecc4f40c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export const useFaceStore = defineStore('imFace', () => {
|
|||
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
|
||||
const faceUserItems = ref<ImFaceUserItemVO[]>([])
|
||||
|
||||
/** 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<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 {
|
||||
|
|
|
|||
|
|
@ -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', {
|
|||
|
||||
/** 删除好友(单向软删,本端置 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue