feat(im): IM 5 个 store 补 HMR + 抽 atAll 常量 + 全面补齐 JSDoc

- 全部 5 个 store(conversation / friend / group / ui / websocket)加
  acceptHMRUpdate;Pinia 单例的 actions 是 wrapper 闭包,Vite 推新模块时
  不会自动替换闭包内的旧函数体,导致改 store 后看着热重载、跑的还是旧逻辑
- 抽 IM_AT_ALL_USER_ID(-1)+ IM_AT_ALL_NICKNAME('所有人')到
  utils/constants.ts;conversationStore 删本地 AT_ALL_FLAG 改用共享常量;
  MentionPicker 渲染虚拟项 / ChatGroupMember 类型注释也都引这两个常量
- groupStore.loadGroups 改成合并而非全量替换:用 groupMap 按 id 找已有项,
  保留 loadGroupMembers 写过的 members / memberCount / muted(这三个字段
  不在 ImGroupRespVO 里,全量替换会被冲掉)
- groupStore.loadGroupMembers 重写为分步注释(1. 缓存 / 2. 拉取 /
  3. 回填 muted / 4.1 占位 / 4.2 直写);await 之后必须重新 getGroup
  防 race(loadGroupMembers 与 loadGroups 并发时用入口快照会把真实 name
  覆盖成 String(groupId))
- types/GroupMember 补 muted 字段,convertGroupMember 透传,
  解决 vue-tsc TS2339 / TS2353
- 5 个 store 缺 JSDoc 的方法全部补齐:removePrivateConversation /
  removeGroupConversation / getFriend / getActiveFriends / isFriend /
  loadGroupInfo / upsertGroup / stopHeartbeat
- 全局"墓碑"措辞统一为"软删保留记录",types / friendStore / groupStore 三处
- groupStore 删冗余注释(与代码自描述重复的)若干处;变量 g/old 改 group/existing
im
YunaiV 2026-04-27 13:10:15 +08:00
parent a0ed0d800c
commit 3ea04663f2
7 changed files with 105 additions and 33 deletions

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore, acceptHMRUpdate } from 'pinia'
import { toRaw } from 'vue'
import { store } from '@/store'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
@ -7,6 +7,7 @@ import {
ImConversationType,
ImMessageType,
ImMessageStatus,
IM_AT_ALL_USER_ID,
TIME_TIP_GAP_MS
} from '../../utils/constants'
import { imStorage, StorageKeys } from '../../utils/storage'
@ -19,8 +20,6 @@ import {
} from '../../utils/message'
import type { Conversation, ConversationStoreMeta, Message } from '../types'
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识atUserIds 中包含 -1 表示 @all
// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
// TODO @芋艿首次拉取消息时如果消息过多可能导致渲染卡顿。1% 场景)
@ -303,10 +302,12 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations()
},
/** 删私聊会话的语义糖friendStore 删好友时调,避免外面手写 ImConversationType.PRIVATE */
removePrivateConversation(friendId: number) {
this.removeConversation(ImConversationType.PRIVATE, friendId)
},
/** 删群聊会话的语义糖groupStore 群解散时调,避免外面手写 ImConversationType.GROUP */
removeGroupConversation(groupId: number) {
this.removeConversation(ImConversationType.GROUP, groupId)
},
@ -371,7 +372,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (currentUserId && messageInfo.atUserIds.includes(currentUserId)) {
conversation.atMe = true
}
if (messageInfo.atUserIds.includes(AT_ALL_FLAG)) {
if (messageInfo.atUserIds.includes(IM_AT_ALL_USER_ID)) {
conversation.atAll = true
}
}
@ -674,3 +675,9 @@ export const useConversationStore = defineStore('imConversationStore', {
export const useConversationStoreWithOut = () => {
return useConversationStore(store)
}
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
}

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore, acceptHMRUpdate } from 'pinia'
import { store } from '@/store'
import { CommonStatusEnum } from '@/utils/constants'
@ -29,14 +29,17 @@ export const useFriendStore = defineStore('imFriendStore', {
}),
getters: {
/** 按 friendUserId 找好友(含已软删的 DISABLE 记录,调用方自行判定) */
getFriend:
(state) =>
(friendUserId: number): Friend | undefined => {
return state.friends.find((f) => f.friendUserId === friendUserId)
},
/** 当前生效的好友列表(过滤掉 DISABLE 软删记录) */
getActiveFriends: (state): Friend[] => {
return state.friends.filter((f) => f.status !== CommonStatusEnum.DISABLE)
},
/** 判断对方是否是当前用户的有效好友(存在 + 非 DISABLE */
isFriend() {
return (friendUserId: number): boolean => {
const entry = this.getFriend(friendUserId)
@ -93,7 +96,7 @@ export const useFriendStore = defineStore('imFriendStore', {
}
},
/** 删除好友(保留墓碑记录,同时级联清理本地私聊会话) */
/** 删除好友(软删,保留记录但置 DISABLE同时级联清理本地私聊会话) */
async deleteFriend(friendUserId: number) {
await apiDeleteFriend(friendUserId)
this.removeFriend(friendUserId)
@ -168,3 +171,9 @@ function convertFriend(vo: ImFriendRespVO): Friend {
}
export const useFriendStoreWithOut = () => useFriendStore(store)
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot))
}

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore, acceptHMRUpdate } from 'pinia'
import { store } from '@/store'
import {
@ -46,19 +46,34 @@ export const useGroupStore = defineStore('imGroupStore', {
}
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers
const list = await apiGetMyGroupList()
this.groups = (list || []).map(convertGroup)
const fresh = (list || []).map(convertGroup)
// 合并而非全量替换:保留 loadGroupMembers 已经写入的 members / memberCount / muted
// (这些字段不在 ImGroupRespVO 里,全量替换会把成员级数据全冲掉)
const groupMap = new Map(this.groups.map((group) => [group.id, group]))
this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id)
if (!existing) {
return group
}
return {
...group,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
muted: existing.muted ?? group.muted
}
})
this.loaded = true
const conversationStore = useConversationStore()
for (const g of this.groups) {
conversationStore.updateConversation(ImConversationType.GROUP, g.id, {
name: g.name,
avatar: g.avatar,
muted: g.muted
for (const group of this.groups) {
conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
name: group.name,
avatar: group.avatar,
muted: group.muted
})
}
},
/** 刷新单个群详情 */
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert常用于 GROUP_UPDATE 推送后或手动 reload */
async loadGroupInfo(groupId: number) {
try {
const data = await apiGetGroup(groupId)
@ -71,24 +86,39 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 按群拉取成员带缓存force=true 强制刷新) */
/**
* force=true
*
* 1. group members
* 2. + /im/group-member/list GroupMember
* 3. muted
* mutedapiGetMyGroupList 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<GroupMember[]> {
// 命中缓存群已加载且成员列表已就绪直接返回force=true 时强制刷)
const group = this.getGroup(groupId)
if (group && group.members && !force) {
return group.members
// 1. 缓存
const cached = this.getGroup(groupId)
if (cached && cached.members && !force) {
return cached.members
}
// 拉取该群所有成员(聚合自 AdminUser含 nickname / avatar / displayUserName
// 2. 拉取 + 转换
const list = await apiGetGroupMemberList(groupId)
const members = (list || []).map((member) => convertGroupMember(member, groupId))
// 后端只在成员维度返回当前用户的 mutedapiGetMyGroupList 不带),借这次拉成员一起回填
// 否则冷启动 / 清缓存后,服务端已免打扰的群在会话列表里仍显示为未免打扰
// 3. 回填 muted
const userStore = useUserStore()
const currentUserId = Number(userStore.getUser?.id) || 0
const me = members.find((m) => m.userId === currentUserId)
const muted = !!me?.muted
// 成员列表可能在群列表之前触发,此时需要占位一个 group
// 4. 落地(必须 await 之后重新 getGroup避免踩 race
const group = this.getGroup(groupId)
if (!group) {
this.upsertGroup({
id: groupId,
@ -101,22 +131,20 @@ export const useGroupStore = defineStore('imGroupStore', {
group.members = members
group.memberCount = members.length
group.muted = muted
// 已有 group 分支没走 upsertGroup单独把 muted 推回 conversation 保证会话列表展示一致
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, groupId, { muted })
}
return members
},
/** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把 name / avatar / muted 推到对应会话 */
upsertGroup(group: Group) {
// 按 id 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
const index = this.groups.findIndex((g) => g.id === group.id)
if (index >= 0) {
this.groups[index] = { ...this.groups[index], ...group }
} else {
this.groups.push(group)
}
// 同步对应群聊会话的展示
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
name: group.name,
@ -125,16 +153,15 @@ export const useGroupStore = defineStore('imGroupStore', {
})
},
/** 本地移除(WebSocket GROUP_DEL 事件触发;同时级联清群聊会话 */
/** 本地移除(WebSocket GROUP_DEL 事件触发) */
removeGroup(id: number) {
// 直接从本地列表里移除(群解散是硬删,不留墓碑,区别于好友的软删)
// 群解散是硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群
this.groups = this.groups.filter((g) => g.id !== id)
// 级联清理:对应群聊会话也软删,避免会话列表里留着已解散的群
const conversationStore = useConversationStore()
conversationStore.removeGroupConversation(id)
},
/** 切换免打扰:调 /im/group-member/update 推后端,再把当前用户在该群的 muted 标记落到本地 */
/** 切换免打扰:推后端 + 落本地 */
async setMuted(id: number, muted: boolean) {
await apiUpdateGroupMember({ groupId: id, muted })
const group = this.getGroup(id)
@ -176,3 +203,8 @@ function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): Group
}
export const useGroupStoreWithOut = () => useGroupStore(store)
// dev: 让 Pinia 的 actions 改动支持 HMR免去每次改 store 都要硬刷
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot))
}

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore, acceptHMRUpdate } from 'pinia'
import { reactive } from 'vue'
import type { UserInfo } from '../types'
@ -80,3 +80,9 @@ export const useImUiStore = defineStore('imUiStore', () => {
closeContextMenu
}
})
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useImUiStore, import.meta.hot))
}

View File

@ -1,4 +1,4 @@
import { defineStore } from 'pinia'
import { defineStore, acceptHMRUpdate } from 'pinia'
import { store } from '@/store'
import { getRefreshToken } from '@/utils/auth'
import { useUserStore } from '@/store/modules/user'
@ -582,6 +582,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}, 5000)
},
/** 停心跳disconnect / 重连前调,避免老 timer 在新 socket 上继续触发 sendHeartBeat */
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
@ -594,3 +595,9 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
export const useImWebSocketStoreWithOut = () => {
return useImWebSocketStore(store)
}
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useImWebSocketStore, import.meta.hot))
}

View File

@ -141,7 +141,7 @@ export interface Friend {
nickname: string // 好友昵称
avatar?: string // 好友头像
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除/墓碑
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
}

View File

@ -73,3 +73,14 @@ export const GROUP_MESSAGE_PULL_SIZE = 100
/** 会话之间插入"时间分隔线"的阈值10 分钟 */
export const TIME_TIP_GAP_MS = 10 * 60 * 1000
/**
* @ userId atUserIds -1 @
*
* atUserIds -1
* MentionPicker + conversationStore.applyAt atAll
*/
export const IM_AT_ALL_USER_ID = -1
/** @全体成员 的展示名(对齐微信 PC */
export const IM_AT_ALL_NICKNAME = '所有人'