diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 73d1b162c..1aca1f123 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -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)) +} diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index e5f4e8c1a..eba08013a 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -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)) +} diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index dabd623c4..51f5775c0 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -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: + * 后端只在成员维度返回 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 { - // 命中缓存:群已加载且成员列表已就绪,直接返回(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)) - // 后端只在成员维度返回当前用户的 muted(apiGetMyGroupList 不带),借这次拉成员一起回填 - // 否则冷启动 / 清缓存后,服务端已免打扰的群在会话列表里仍显示为未免打扰 + + // 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)) +} diff --git a/src/views/im/home/store/uiStore.ts b/src/views/im/home/store/uiStore.ts index e992e0f6d..9e223808e 100644 --- a/src/views/im/home/store/uiStore.ts +++ b/src/views/im/home/store/uiStore.ts @@ -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)) +} diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 07918f3d1..e1cb27ddc 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -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)) +} diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 661140389..c2016235b 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -141,7 +141,7 @@ export interface Friend { nickname: string // 好友昵称 avatar?: string // 好友头像 muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) - status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除/墓碑) + status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录) addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) } diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 993cc5e44..cd3eaaf7d 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -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 = '所有人'