diff --git a/apps/web-antd/src/views/im/home/components/rtc/rtc-group-call-banner.vue b/apps/web-antd/src/views/im/home/components/rtc/rtc-group-call-banner.vue index 95c217c35..426fd66d3 100644 --- a/apps/web-antd/src/views/im/home/components/rtc/rtc-group-call-banner.vue +++ b/apps/web-antd/src/views/im/home/components/rtc/rtc-group-call-banner.vue @@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc' import { getCurrentUserId } from '#/views/im/utils/auth' import { useGroupCallMembers } from '../../composables/useGroupCallMembers' +import { useGroupStore } from '../../store/groupStore' import { useRtcStore } from '../../store/rtcStore' import { UserAvatar } from '../user' @@ -21,6 +22,7 @@ const props = defineProps<{ }>() const rtcStore = useRtcStore() +const groupStore = useGroupStore() const popoverVisible = ref(false) @@ -40,24 +42,48 @@ const pillText = computed(() => { * 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取 */ watch( - () => [props.groupId, activeCall.value?.room] as const, + () => + [ + props.groupId, + activeCall.value?.room, + groupStore.isGroupActiveCallExpired(props.groupId) + ] as const, async ([groupId, room], oldValues) => { - if (!groupId || !activeCall.value) { + if (!groupId) { return } - // 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建 + if (!activeCall.value) { + if (!groupStore.isGroupActiveCallExpired(groupId)) { + return + } + try { + const data = await getActiveCall(groupId) + if (data) { + rtcStore.setGroupCall(data, true) + } else { + rtcStore.removeGroupCall(groupId) + } + } catch (error) { + console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error) + } + return + } + + // 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次 const groupChanged = !oldValues || oldValues[0] !== groupId const roomChanged = oldValues && oldValues[1] !== room const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 + const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId) if ( - rtcStore.isGroupCallParticipantsLoaded(groupId, room) || - (!groupChanged && !roomChanged && participantsLoaded) + !activeCallExpired && + (rtcStore.isGroupCallParticipantsLoaded(groupId, room) || + (!groupChanged && !roomChanged && participantsLoaded)) ) { return } - // 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话 + // 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存 try { const data = await getActiveCall(groupId) if (data) { diff --git a/apps/web-antd/src/views/im/home/composables/useMessagePuller.ts b/apps/web-antd/src/views/im/home/composables/useMessagePuller.ts index 14d87715a..95cf36db0 100644 --- a/apps/web-antd/src/views/im/home/composables/useMessagePuller.ts +++ b/apps/web-antd/src/views/im/home/composables/useMessagePuller.ts @@ -290,6 +290,7 @@ export const useMessagePuller = () => { // 1. 清理连接级缓存 messageStore.clearPrivateReadMaxIdCache() rtcStore.clearGroupCallCache() + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupInfoExpired() groupStore.markAllGroupMembersExpired() // 2. 并发补偿远端状态 diff --git a/apps/web-antd/src/views/im/home/composables/useMessageSender.ts b/apps/web-antd/src/views/im/home/composables/useMessageSender.ts index 230f273b9..456212817 100644 --- a/apps/web-antd/src/views/im/home/composables/useMessageSender.ts +++ b/apps/web-antd/src/views/im/home/composables/useMessageSender.ts @@ -240,12 +240,12 @@ export const useMessageSender = () => { } } const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( conversation.type, conversation.targetId, maxMessageId ) - if (readCovered) { + if (readReported) { conversationStore.markConversationRead(conversation.type, conversation.targetId) return } @@ -275,6 +275,11 @@ export const useMessageSender = () => { } else { await apiReadChannelMessages(conversation.targetId, maxMessageId) } + conversationStore.markConversationReadReported( + conversation.type, + conversation.targetId, + maxMessageId + ) } catch (error) { console.error( '[IM] 标记已读失败', diff --git a/apps/web-antd/src/views/im/home/index.vue b/apps/web-antd/src/views/im/home/index.vue index ca7a64c1c..c9a408722 100644 --- a/apps/web-antd/src/views/im/home/index.vue +++ b/apps/web-antd/src/views/im/home/index.vue @@ -61,6 +61,7 @@ onMounted(async () => { const hasFriendRows = cacheResults[2] const hasGroupRows = cacheResults[3] const hasChannelRows = cacheResults[4] + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupMembersExpired() childRouteReady.value = true // 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻); diff --git a/apps/web-antd/src/views/im/home/store/conversationStore.ts b/apps/web-antd/src/views/im/home/store/conversationStore.ts index a834806d0..d96c6a777 100644 --- a/apps/web-antd/src/views/im/home/store/conversationStore.ts +++ b/apps/web-antd/src/views/im/home/store/conversationStore.ts @@ -27,7 +27,19 @@ import { useMessageStore } from './messageStore' const PERSIST_DRAFT_DEBOUNCE_MS = 500 const pendingDraftConversations = new Set() -type LegacyConversationDO = ConversationDO & { readMessageId?: number } +/** 创建会话读位置记录 */ +function createConversationRead( + type: number, + targetId: number, + messageId: number +): ConversationRead { + return { + conversationType: type, + targetId, + messageId, + updateTime: Date.now() + } +} /** 创建草稿保存防抖函数 */ function createDraftDebounce(fn: () => void, wait: number) { @@ -75,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO { lastReceiptStatus: conversation.lastReceiptStatus, lastSelfSend: conversation.lastSelfSend, lastSenderDisplayName: conversation.lastSenderDisplayName, + readMessageId: conversation.readMessageId, deleted: conversation.deleted, top: conversation.top, silent: conversation.silent, @@ -86,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO { } /** IndexedDB 记录转会话 */ -function fromConversationDO(conversation: LegacyConversationDO): Conversation { +function fromConversationDO(conversation: ConversationDO): Conversation { const { clientConversationId: _clientConversationId, - readMessageId: _readMessageId, ...rest } = conversation return rest @@ -207,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', { const item = fromConversationReadDO(record) nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item } - const migratedReads: ConversationRead[] = [] - for (const conversation of conversations as LegacyConversationDO[]) { - if (!conversation.readMessageId) { - continue - } - const key = getClientConversationId(conversation.type, conversation.targetId) - if (nextConversationReads[key]) { - continue - } - const record = { - conversationType: conversation.type, - targetId: conversation.targetId, - messageId: conversation.readMessageId - } - nextConversationReads[key] = record - migratedReads.push(record) - } - const nextConversations = (conversations as LegacyConversationDO[]).map((conversation) => - fromConversationDO(conversation) - ) + const nextConversations = conversations.map((conversation) => fromConversationDO(conversation)) this.conversationReads = nextConversationReads await this.applyLocalConversationReads(nextConversations) this.conversations = nextConversations - if (migratedReads.length > 0) { - void this.saveConversationReadRecord(migratedReads).catch((error) => - console.warn('[IM conversationStore] 会话读位置迁移失败', error) - ) - } if (Array.isArray(recent)) { this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) } @@ -344,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', { return !!record && record.messageId >= messageId }, + /** 判断服务端已读位置是否覆盖消息编号 */ + isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean { + if (!messageId) { + return false + } + const conversation = this.getConversation(type, targetId) + return (conversation?.readMessageId || 0) >= messageId + }, + /** 应用读位置到会话 */ applyReadToConversation(conversation: Conversation, messageId: number): boolean { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { @@ -397,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', { } const current = this.conversationReads[clientConversationId] const messageId = Math.max(record.messageId, current?.messageId || 0) + const conversation = this.getConversation(record.conversationType, record.targetId) + if (conversation && record.messageId > (conversation.readMessageId || 0)) { + conversation.readMessageId = record.messageId + changedConversations.set(clientConversationId, conversation) + } if (!current || messageId > current.messageId) { const next = { conversationType: record.conversationType, @@ -408,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', { changedReads.set(clientConversationId, next) } - const conversation = this.getConversation(record.conversationType, record.targetId) if (conversation && this.applyReadToConversation(conversation, messageId)) { changedConversations.set(clientConversationId, conversation) } else if (conversation) { @@ -690,7 +691,7 @@ export const useConversationStore = defineStore('imConversationStore', { }, /** 标记会话已读 */ - markConversationRead(type: number, targetId: number, messageId?: number) { + markConversationRead(type: number, targetId: number, messageId?: number): void { const conversation = this.getConversation(type, targetId) if (!conversation) { return @@ -717,12 +718,36 @@ export const useConversationStore = defineStore('imConversationStore', { await this.saveConversationRecord(conversation, tx) await this.saveConversationReadRecord(record, tx) }) - .catch((error) => console.warn('[IM conversationStore] 会话已读写入失败', error)) + .catch((error) => + console.warn( + '[IM conversationStore] 会话已读写入失败', + { + conversationType: type, + targetId, + messageId, + conversationKey: key + }, + error + ) + ) return } this.saveConversation(conversation) }, + /** 标记会话已上报服务端读位置 */ + markConversationReadReported(type: number, targetId: number, messageId?: number): void { + if (!messageId) { + return + } + const conversation = this.getConversation(type, targetId) + if (!conversation || messageId <= (conversation.readMessageId || 0)) { + return + } + conversation.readMessageId = messageId + this.saveConversation(conversation) + }, + // ==================== 最近转发 ==================== /** 推送最近转发会话 */ diff --git a/apps/web-antd/src/views/im/home/store/groupStore.ts b/apps/web-antd/src/views/im/home/store/groupStore.ts index e745fe70b..8db5512fd 100644 --- a/apps/web-antd/src/views/im/home/store/groupStore.ts +++ b/apps/web-antd/src/views/im/home/store/groupStore.ts @@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n /** 构建群 IndexedDB 记录 */ function buildGroupDO(group: Group): GroupDO { const { + activeCallExpired: _activeCallExpired, + activeCallLoaded: _activeCallLoaded, infoLoaded: _infoLoaded, members: _members, membersLoaded: _membersLoaded, @@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', { this.groups = fresh.map((group) => { const existing = groupMap.get(group.id) if (!existing) { - return { ...group, infoLoaded: true } + return { ...group, activeCallExpired: true, infoLoaded: true } } return { ...group, infoLoaded: true, + activeCallExpired: existing.activeCallExpired, + activeCallLoaded: existing.activeCallLoaded, members: existing.members, memberCount: existing.memberCount ?? group.memberCount, membersLoaded: existing.membersLoaded, @@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', { } }, + /** 失效全部群通话探测缓存 */ + markAllGroupActiveCallsExpired() { + for (const group of this.groups) { + group.activeCallExpired = true + } + }, + + /** 标记群通话探测已加载 */ + markGroupActiveCallLoaded(groupId: number) { + const group = this.getGroup(groupId) + if (!group) { + return + } + group.activeCallLoaded = true + group.activeCallExpired = false + }, + + /** 判断群通话是否需要重新探测 */ + isGroupActiveCallExpired(groupId: number): boolean { + const group = this.getGroup(groupId) + return !group?.activeCallLoaded || !!group.activeCallExpired + }, + /** 失效指定群成员缓存 */ markGroupMembersExpired(groupId: number) { const group = this.getGroup(groupId) diff --git a/apps/web-antd/src/views/im/home/store/rtcStore.ts b/apps/web-antd/src/views/im/home/store/rtcStore.ts index 2e10479d8..4922a6b75 100644 --- a/apps/web-antd/src/views/im/home/store/rtcStore.ts +++ b/apps/web-antd/src/views/im/home/store/rtcStore.ts @@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => { if (!payload?.groupId) { return } + useGroupStore().markGroupActiveCallLoaded(payload.groupId) // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 const existing = groupActiveCalls.value.get(payload.groupId) const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded @@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => { /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ function removeGroupCall(groupId: number) { + if (!groupId) { + return + } clearGroupCallCache(groupId) + useGroupStore().markGroupActiveCallLoaded(groupId) } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ diff --git a/apps/web-antd/src/views/im/home/store/websocketStore.ts b/apps/web-antd/src/views/im/home/store/websocketStore.ts index 3cc0abe6c..2a320db32 100644 --- a/apps/web-antd/src/views/im/home/store/websocketStore.ts +++ b/apps/web-antd/src/views/im/home/store/websocketStore.ts @@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ) if (isActive) { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id @@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.CHANNEL, websocketMessage.channelId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (!readCovered) { + if (!readReported) { apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.CHANNEL, + websocketMessage.channelId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 频道自动已读上报失败', @@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.PRIVATE, peerId, websocketMessage.id @@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.PRIVATE, peerId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { + if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) { apiReadPrivateMessages(peerId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.PRIVATE, + peerId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 私聊自动已读上报失败', @@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id @@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.GROUP, websocketMessage.groupId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { + if (MESSAGE_GROUP_READ_ENABLED && !readReported) { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.GROUP, + websocketMessage.groupId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 群聊自动已读上报失败', diff --git a/apps/web-antd/src/views/im/home/types/index.ts b/apps/web-antd/src/views/im/home/types/index.ts index 7af29ae76..303095d81 100644 --- a/apps/web-antd/src/views/im/home/types/index.ts +++ b/apps/web-antd/src/views/im/home/types/index.ts @@ -110,6 +110,7 @@ export interface Conversation { silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean // 群聊:是否有人 @我 atAll?: boolean // 群聊:是否有人 @全体成员 + readMessageId?: number // 已上报到服务端的最大已读消息编号 draft?: { html: string // 输入框 HTML plain: string // 输入框纯文本 @@ -147,6 +148,7 @@ export interface Message { // ==================== IndexedDB 本地存储结构 ==================== +/** 会话 IndexedDB 存储结构 */ export interface ConversationDO extends Conversation { clientConversationId: string // `${type}:${targetId}` } @@ -158,16 +160,19 @@ export interface ConversationRead { updateTime?: number // 更新时间 } +/** 会话读位置 IndexedDB 存储结构 */ export interface ConversationReadDO extends ConversationRead { clientConversationId: string // `${conversationType}:${targetId}` } +/** 消息 IndexedDB 存储结构 */ export interface MessageDO extends Omit { messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` conversationType: number // 会话类型,对齐 ImConversationType clientConversationId: string // ConversationDO.clientConversationId } +/** 设置 IndexedDB 存储结构 */ export interface SettingDO { key: string value: T @@ -195,12 +200,18 @@ export interface Group { groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) members?: GroupMember[] // 群成员缓存(按需懒加载) infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 + activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化 + activeCallExpired?: boolean // 群活跃通话探测是否已过期 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 memberCount?: number // 成员总数 } -export type GroupDO = Omit +/** 群 IndexedDB 存储结构 */ +export type GroupDO = Omit< + Group, + 'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded' +> // 群成员实体(前端内部结构) export interface GroupMember { diff --git a/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-group-call-banner.vue b/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-group-call-banner.vue index 5b57de8c7..e7aabcced 100644 --- a/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-group-call-banner.vue +++ b/apps/web-antdv-next/src/views/im/home/components/rtc/rtc-group-call-banner.vue @@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc' import { getCurrentUserId } from '#/views/im/utils/auth' import { useGroupCallMembers } from '../../composables/useGroupCallMembers' +import { useGroupStore } from '../../store/groupStore' import { useRtcStore } from '../../store/rtcStore' import { UserAvatar } from '../user' @@ -21,6 +22,7 @@ const props = defineProps<{ }>() const rtcStore = useRtcStore() +const groupStore = useGroupStore() const popoverVisible = ref(false) @@ -40,24 +42,48 @@ const pillText = computed(() => { * 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取 */ watch( - () => [props.groupId, activeCall.value?.room] as const, + () => + [ + props.groupId, + activeCall.value?.room, + groupStore.isGroupActiveCallExpired(props.groupId) + ] as const, async ([groupId, room], oldValues) => { - if (!groupId || !activeCall.value) { + if (!groupId) { return } - // 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建 + if (!activeCall.value) { + if (!groupStore.isGroupActiveCallExpired(groupId)) { + return + } + try { + const data = await getActiveCall(groupId) + if (data) { + rtcStore.setGroupCall(data, true) + } else { + rtcStore.removeGroupCall(groupId) + } + } catch (error) { + console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error) + } + return + } + + // 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次 const groupChanged = !oldValues || oldValues[0] !== groupId const roomChanged = oldValues && oldValues[1] !== room const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 + const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId) if ( - rtcStore.isGroupCallParticipantsLoaded(groupId, room) || - (!groupChanged && !roomChanged && participantsLoaded) + !activeCallExpired && + (rtcStore.isGroupCallParticipantsLoaded(groupId, room) || + (!groupChanged && !roomChanged && participantsLoaded)) ) { return } - // 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话 + // 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存 try { const data = await getActiveCall(groupId) if (data) { diff --git a/apps/web-antdv-next/src/views/im/home/composables/useMessagePuller.ts b/apps/web-antdv-next/src/views/im/home/composables/useMessagePuller.ts index 14d87715a..95cf36db0 100644 --- a/apps/web-antdv-next/src/views/im/home/composables/useMessagePuller.ts +++ b/apps/web-antdv-next/src/views/im/home/composables/useMessagePuller.ts @@ -290,6 +290,7 @@ export const useMessagePuller = () => { // 1. 清理连接级缓存 messageStore.clearPrivateReadMaxIdCache() rtcStore.clearGroupCallCache() + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupInfoExpired() groupStore.markAllGroupMembersExpired() // 2. 并发补偿远端状态 diff --git a/apps/web-antdv-next/src/views/im/home/composables/useMessageSender.ts b/apps/web-antdv-next/src/views/im/home/composables/useMessageSender.ts index 230f273b9..456212817 100644 --- a/apps/web-antdv-next/src/views/im/home/composables/useMessageSender.ts +++ b/apps/web-antdv-next/src/views/im/home/composables/useMessageSender.ts @@ -240,12 +240,12 @@ export const useMessageSender = () => { } } const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( conversation.type, conversation.targetId, maxMessageId ) - if (readCovered) { + if (readReported) { conversationStore.markConversationRead(conversation.type, conversation.targetId) return } @@ -275,6 +275,11 @@ export const useMessageSender = () => { } else { await apiReadChannelMessages(conversation.targetId, maxMessageId) } + conversationStore.markConversationReadReported( + conversation.type, + conversation.targetId, + maxMessageId + ) } catch (error) { console.error( '[IM] 标记已读失败', diff --git a/apps/web-antdv-next/src/views/im/home/index.vue b/apps/web-antdv-next/src/views/im/home/index.vue index 19371af24..30b436390 100644 --- a/apps/web-antdv-next/src/views/im/home/index.vue +++ b/apps/web-antdv-next/src/views/im/home/index.vue @@ -61,6 +61,7 @@ onMounted(async () => { const hasFriendRows = cacheResults[2] const hasGroupRows = cacheResults[3] const hasChannelRows = cacheResults[4] + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupMembersExpired() childRouteReady.value = true // 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻); diff --git a/apps/web-antdv-next/src/views/im/home/store/conversationStore.ts b/apps/web-antdv-next/src/views/im/home/store/conversationStore.ts index b826d3036..d96c6a777 100644 --- a/apps/web-antdv-next/src/views/im/home/store/conversationStore.ts +++ b/apps/web-antdv-next/src/views/im/home/store/conversationStore.ts @@ -27,8 +27,6 @@ import { useMessageStore } from './messageStore' const PERSIST_DRAFT_DEBOUNCE_MS = 500 const pendingDraftConversations = new Set() -type LegacyConversationDO = ConversationDO & { readMessageId?: number } - /** 创建会话读位置记录 */ function createConversationRead( type: number, @@ -89,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO { lastReceiptStatus: conversation.lastReceiptStatus, lastSelfSend: conversation.lastSelfSend, lastSenderDisplayName: conversation.lastSenderDisplayName, + readMessageId: conversation.readMessageId, deleted: conversation.deleted, top: conversation.top, silent: conversation.silent, @@ -100,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO { } /** IndexedDB 记录转会话 */ -function fromConversationDO(conversation: LegacyConversationDO): Conversation { +function fromConversationDO(conversation: ConversationDO): Conversation { const { clientConversationId: _clientConversationId, - readMessageId: _readMessageId, ...rest } = conversation return rest @@ -221,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', { const item = fromConversationReadDO(record) nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item } - const migratedReads: ConversationRead[] = [] - for (const conversation of conversations as LegacyConversationDO[]) { - if (!conversation.readMessageId) { - continue - } - const key = getClientConversationId(conversation.type, conversation.targetId) - if (nextConversationReads[key]) { - continue - } - const record = { - conversationType: conversation.type, - targetId: conversation.targetId, - messageId: conversation.readMessageId - } - nextConversationReads[key] = record - migratedReads.push(record) - } - const nextConversations = (conversations as LegacyConversationDO[]).map((conversation) => - fromConversationDO(conversation) - ) + const nextConversations = conversations.map((conversation) => fromConversationDO(conversation)) this.conversationReads = nextConversationReads await this.applyLocalConversationReads(nextConversations) this.conversations = nextConversations - if (migratedReads.length > 0) { - void this.saveConversationReadRecord(migratedReads).catch((error) => - console.warn('[IM conversationStore] 会话读位置迁移失败', error) - ) - } if (Array.isArray(recent)) { this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) } @@ -358,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', { return !!record && record.messageId >= messageId }, + /** 判断服务端已读位置是否覆盖消息编号 */ + isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean { + if (!messageId) { + return false + } + const conversation = this.getConversation(type, targetId) + return (conversation?.readMessageId || 0) >= messageId + }, + /** 应用读位置到会话 */ applyReadToConversation(conversation: Conversation, messageId: number): boolean { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { @@ -411,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', { } const current = this.conversationReads[clientConversationId] const messageId = Math.max(record.messageId, current?.messageId || 0) + const conversation = this.getConversation(record.conversationType, record.targetId) + if (conversation && record.messageId > (conversation.readMessageId || 0)) { + conversation.readMessageId = record.messageId + changedConversations.set(clientConversationId, conversation) + } if (!current || messageId > current.messageId) { const next = { conversationType: record.conversationType, @@ -422,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', { changedReads.set(clientConversationId, next) } - const conversation = this.getConversation(record.conversationType, record.targetId) if (conversation && this.applyReadToConversation(conversation, messageId)) { changedConversations.set(clientConversationId, conversation) } else if (conversation) { @@ -748,6 +735,19 @@ export const useConversationStore = defineStore('imConversationStore', { this.saveConversation(conversation) }, + /** 标记会话已上报服务端读位置 */ + markConversationReadReported(type: number, targetId: number, messageId?: number): void { + if (!messageId) { + return + } + const conversation = this.getConversation(type, targetId) + if (!conversation || messageId <= (conversation.readMessageId || 0)) { + return + } + conversation.readMessageId = messageId + this.saveConversation(conversation) + }, + // ==================== 最近转发 ==================== /** 推送最近转发会话 */ diff --git a/apps/web-antdv-next/src/views/im/home/store/groupStore.ts b/apps/web-antdv-next/src/views/im/home/store/groupStore.ts index e745fe70b..8db5512fd 100644 --- a/apps/web-antdv-next/src/views/im/home/store/groupStore.ts +++ b/apps/web-antdv-next/src/views/im/home/store/groupStore.ts @@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n /** 构建群 IndexedDB 记录 */ function buildGroupDO(group: Group): GroupDO { const { + activeCallExpired: _activeCallExpired, + activeCallLoaded: _activeCallLoaded, infoLoaded: _infoLoaded, members: _members, membersLoaded: _membersLoaded, @@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', { this.groups = fresh.map((group) => { const existing = groupMap.get(group.id) if (!existing) { - return { ...group, infoLoaded: true } + return { ...group, activeCallExpired: true, infoLoaded: true } } return { ...group, infoLoaded: true, + activeCallExpired: existing.activeCallExpired, + activeCallLoaded: existing.activeCallLoaded, members: existing.members, memberCount: existing.memberCount ?? group.memberCount, membersLoaded: existing.membersLoaded, @@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', { } }, + /** 失效全部群通话探测缓存 */ + markAllGroupActiveCallsExpired() { + for (const group of this.groups) { + group.activeCallExpired = true + } + }, + + /** 标记群通话探测已加载 */ + markGroupActiveCallLoaded(groupId: number) { + const group = this.getGroup(groupId) + if (!group) { + return + } + group.activeCallLoaded = true + group.activeCallExpired = false + }, + + /** 判断群通话是否需要重新探测 */ + isGroupActiveCallExpired(groupId: number): boolean { + const group = this.getGroup(groupId) + return !group?.activeCallLoaded || !!group.activeCallExpired + }, + /** 失效指定群成员缓存 */ markGroupMembersExpired(groupId: number) { const group = this.getGroup(groupId) diff --git a/apps/web-antdv-next/src/views/im/home/store/rtcStore.ts b/apps/web-antdv-next/src/views/im/home/store/rtcStore.ts index 2e10479d8..4922a6b75 100644 --- a/apps/web-antdv-next/src/views/im/home/store/rtcStore.ts +++ b/apps/web-antdv-next/src/views/im/home/store/rtcStore.ts @@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => { if (!payload?.groupId) { return } + useGroupStore().markGroupActiveCallLoaded(payload.groupId) // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 const existing = groupActiveCalls.value.get(payload.groupId) const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded @@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => { /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ function removeGroupCall(groupId: number) { + if (!groupId) { + return + } clearGroupCallCache(groupId) + useGroupStore().markGroupActiveCallLoaded(groupId) } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ diff --git a/apps/web-antdv-next/src/views/im/home/store/websocketStore.ts b/apps/web-antdv-next/src/views/im/home/store/websocketStore.ts index 3cc0abe6c..2a320db32 100644 --- a/apps/web-antdv-next/src/views/im/home/store/websocketStore.ts +++ b/apps/web-antdv-next/src/views/im/home/store/websocketStore.ts @@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ) if (isActive) { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id @@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.CHANNEL, websocketMessage.channelId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (!readCovered) { + if (!readReported) { apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.CHANNEL, + websocketMessage.channelId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 频道自动已读上报失败', @@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.PRIVATE, peerId, websocketMessage.id @@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.PRIVATE, peerId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { + if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) { apiReadPrivateMessages(peerId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.PRIVATE, + peerId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 私聊自动已读上报失败', @@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id @@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.GROUP, websocketMessage.groupId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { + if (MESSAGE_GROUP_READ_ENABLED && !readReported) { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.GROUP, + websocketMessage.groupId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 群聊自动已读上报失败', diff --git a/apps/web-antdv-next/src/views/im/home/types/index.ts b/apps/web-antdv-next/src/views/im/home/types/index.ts index 7af29ae76..303095d81 100644 --- a/apps/web-antdv-next/src/views/im/home/types/index.ts +++ b/apps/web-antdv-next/src/views/im/home/types/index.ts @@ -110,6 +110,7 @@ export interface Conversation { silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean // 群聊:是否有人 @我 atAll?: boolean // 群聊:是否有人 @全体成员 + readMessageId?: number // 已上报到服务端的最大已读消息编号 draft?: { html: string // 输入框 HTML plain: string // 输入框纯文本 @@ -147,6 +148,7 @@ export interface Message { // ==================== IndexedDB 本地存储结构 ==================== +/** 会话 IndexedDB 存储结构 */ export interface ConversationDO extends Conversation { clientConversationId: string // `${type}:${targetId}` } @@ -158,16 +160,19 @@ export interface ConversationRead { updateTime?: number // 更新时间 } +/** 会话读位置 IndexedDB 存储结构 */ export interface ConversationReadDO extends ConversationRead { clientConversationId: string // `${conversationType}:${targetId}` } +/** 消息 IndexedDB 存储结构 */ export interface MessageDO extends Omit { messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` conversationType: number // 会话类型,对齐 ImConversationType clientConversationId: string // ConversationDO.clientConversationId } +/** 设置 IndexedDB 存储结构 */ export interface SettingDO { key: string value: T @@ -195,12 +200,18 @@ export interface Group { groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) members?: GroupMember[] // 群成员缓存(按需懒加载) infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 + activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化 + activeCallExpired?: boolean // 群活跃通话探测是否已过期 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 memberCount?: number // 成员总数 } -export type GroupDO = Omit +/** 群 IndexedDB 存储结构 */ +export type GroupDO = Omit< + Group, + 'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded' +> // 群成员实体(前端内部结构) export interface GroupMember { diff --git a/apps/web-ele/src/views/im/home/components/rtc/rtc-group-call-banner.vue b/apps/web-ele/src/views/im/home/components/rtc/rtc-group-call-banner.vue index 106029b1e..59f7b057e 100644 --- a/apps/web-ele/src/views/im/home/components/rtc/rtc-group-call-banner.vue +++ b/apps/web-ele/src/views/im/home/components/rtc/rtc-group-call-banner.vue @@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc' import { getCurrentUserId } from '#/views/im/utils/auth' import { useGroupCallMembers } from '../../composables/useGroupCallMembers' +import { useGroupStore } from '../../store/groupStore' import { useRtcStore } from '../../store/rtcStore' import { UserAvatar } from '../user' @@ -21,6 +22,7 @@ const props = defineProps<{ }>() const rtcStore = useRtcStore() +const groupStore = useGroupStore() const popoverVisible = ref(false) @@ -40,24 +42,48 @@ const pillText = computed(() => { * 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取 */ watch( - () => [props.groupId, activeCall.value?.room] as const, + () => + [ + props.groupId, + activeCall.value?.room, + groupStore.isGroupActiveCallExpired(props.groupId) + ] as const, async ([groupId, room], oldValues) => { - if (!groupId || !activeCall.value) { + if (!groupId) { return } - // 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建 + if (!activeCall.value) { + if (!groupStore.isGroupActiveCallExpired(groupId)) { + return + } + try { + const data = await getActiveCall(groupId) + if (data) { + rtcStore.setGroupCall(data, true) + } else { + rtcStore.removeGroupCall(groupId) + } + } catch (error) { + console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error) + } + return + } + + // 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次 const groupChanged = !oldValues || oldValues[0] !== groupId const roomChanged = oldValues && oldValues[1] !== room const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 + const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId) if ( - rtcStore.isGroupCallParticipantsLoaded(groupId, room) || - (!groupChanged && !roomChanged && participantsLoaded) + !activeCallExpired && + (rtcStore.isGroupCallParticipantsLoaded(groupId, room) || + (!groupChanged && !roomChanged && participantsLoaded)) ) { return } - // 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话 + // 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存 try { const data = await getActiveCall(groupId) if (data) { diff --git a/apps/web-ele/src/views/im/home/composables/useMessagePuller.ts b/apps/web-ele/src/views/im/home/composables/useMessagePuller.ts index 14d87715a..95cf36db0 100644 --- a/apps/web-ele/src/views/im/home/composables/useMessagePuller.ts +++ b/apps/web-ele/src/views/im/home/composables/useMessagePuller.ts @@ -290,6 +290,7 @@ export const useMessagePuller = () => { // 1. 清理连接级缓存 messageStore.clearPrivateReadMaxIdCache() rtcStore.clearGroupCallCache() + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupInfoExpired() groupStore.markAllGroupMembersExpired() // 2. 并发补偿远端状态 diff --git a/apps/web-ele/src/views/im/home/composables/useMessageSender.ts b/apps/web-ele/src/views/im/home/composables/useMessageSender.ts index 230f273b9..456212817 100644 --- a/apps/web-ele/src/views/im/home/composables/useMessageSender.ts +++ b/apps/web-ele/src/views/im/home/composables/useMessageSender.ts @@ -240,12 +240,12 @@ export const useMessageSender = () => { } } const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( conversation.type, conversation.targetId, maxMessageId ) - if (readCovered) { + if (readReported) { conversationStore.markConversationRead(conversation.type, conversation.targetId) return } @@ -275,6 +275,11 @@ export const useMessageSender = () => { } else { await apiReadChannelMessages(conversation.targetId, maxMessageId) } + conversationStore.markConversationReadReported( + conversation.type, + conversation.targetId, + maxMessageId + ) } catch (error) { console.error( '[IM] 标记已读失败', diff --git a/apps/web-ele/src/views/im/home/index.vue b/apps/web-ele/src/views/im/home/index.vue index 581170c72..639c1e74d 100644 --- a/apps/web-ele/src/views/im/home/index.vue +++ b/apps/web-ele/src/views/im/home/index.vue @@ -61,6 +61,7 @@ onMounted(async () => { const hasFriendRows = cacheResults[2] const hasGroupRows = cacheResults[3] const hasChannelRows = cacheResults[4] + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupMembersExpired() childRouteReady.value = true // 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻); diff --git a/apps/web-ele/src/views/im/home/store/conversationStore.ts b/apps/web-ele/src/views/im/home/store/conversationStore.ts index b826d3036..d96c6a777 100644 --- a/apps/web-ele/src/views/im/home/store/conversationStore.ts +++ b/apps/web-ele/src/views/im/home/store/conversationStore.ts @@ -27,8 +27,6 @@ import { useMessageStore } from './messageStore' const PERSIST_DRAFT_DEBOUNCE_MS = 500 const pendingDraftConversations = new Set() -type LegacyConversationDO = ConversationDO & { readMessageId?: number } - /** 创建会话读位置记录 */ function createConversationRead( type: number, @@ -89,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO { lastReceiptStatus: conversation.lastReceiptStatus, lastSelfSend: conversation.lastSelfSend, lastSenderDisplayName: conversation.lastSenderDisplayName, + readMessageId: conversation.readMessageId, deleted: conversation.deleted, top: conversation.top, silent: conversation.silent, @@ -100,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO { } /** IndexedDB 记录转会话 */ -function fromConversationDO(conversation: LegacyConversationDO): Conversation { +function fromConversationDO(conversation: ConversationDO): Conversation { const { clientConversationId: _clientConversationId, - readMessageId: _readMessageId, ...rest } = conversation return rest @@ -221,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', { const item = fromConversationReadDO(record) nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item } - const migratedReads: ConversationRead[] = [] - for (const conversation of conversations as LegacyConversationDO[]) { - if (!conversation.readMessageId) { - continue - } - const key = getClientConversationId(conversation.type, conversation.targetId) - if (nextConversationReads[key]) { - continue - } - const record = { - conversationType: conversation.type, - targetId: conversation.targetId, - messageId: conversation.readMessageId - } - nextConversationReads[key] = record - migratedReads.push(record) - } - const nextConversations = (conversations as LegacyConversationDO[]).map((conversation) => - fromConversationDO(conversation) - ) + const nextConversations = conversations.map((conversation) => fromConversationDO(conversation)) this.conversationReads = nextConversationReads await this.applyLocalConversationReads(nextConversations) this.conversations = nextConversations - if (migratedReads.length > 0) { - void this.saveConversationReadRecord(migratedReads).catch((error) => - console.warn('[IM conversationStore] 会话读位置迁移失败', error) - ) - } if (Array.isArray(recent)) { this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) } @@ -358,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', { return !!record && record.messageId >= messageId }, + /** 判断服务端已读位置是否覆盖消息编号 */ + isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean { + if (!messageId) { + return false + } + const conversation = this.getConversation(type, targetId) + return (conversation?.readMessageId || 0) >= messageId + }, + /** 应用读位置到会话 */ applyReadToConversation(conversation: Conversation, messageId: number): boolean { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { @@ -411,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', { } const current = this.conversationReads[clientConversationId] const messageId = Math.max(record.messageId, current?.messageId || 0) + const conversation = this.getConversation(record.conversationType, record.targetId) + if (conversation && record.messageId > (conversation.readMessageId || 0)) { + conversation.readMessageId = record.messageId + changedConversations.set(clientConversationId, conversation) + } if (!current || messageId > current.messageId) { const next = { conversationType: record.conversationType, @@ -422,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', { changedReads.set(clientConversationId, next) } - const conversation = this.getConversation(record.conversationType, record.targetId) if (conversation && this.applyReadToConversation(conversation, messageId)) { changedConversations.set(clientConversationId, conversation) } else if (conversation) { @@ -748,6 +735,19 @@ export const useConversationStore = defineStore('imConversationStore', { this.saveConversation(conversation) }, + /** 标记会话已上报服务端读位置 */ + markConversationReadReported(type: number, targetId: number, messageId?: number): void { + if (!messageId) { + return + } + const conversation = this.getConversation(type, targetId) + if (!conversation || messageId <= (conversation.readMessageId || 0)) { + return + } + conversation.readMessageId = messageId + this.saveConversation(conversation) + }, + // ==================== 最近转发 ==================== /** 推送最近转发会话 */ diff --git a/apps/web-ele/src/views/im/home/store/groupStore.ts b/apps/web-ele/src/views/im/home/store/groupStore.ts index e745fe70b..8db5512fd 100644 --- a/apps/web-ele/src/views/im/home/store/groupStore.ts +++ b/apps/web-ele/src/views/im/home/store/groupStore.ts @@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n /** 构建群 IndexedDB 记录 */ function buildGroupDO(group: Group): GroupDO { const { + activeCallExpired: _activeCallExpired, + activeCallLoaded: _activeCallLoaded, infoLoaded: _infoLoaded, members: _members, membersLoaded: _membersLoaded, @@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', { this.groups = fresh.map((group) => { const existing = groupMap.get(group.id) if (!existing) { - return { ...group, infoLoaded: true } + return { ...group, activeCallExpired: true, infoLoaded: true } } return { ...group, infoLoaded: true, + activeCallExpired: existing.activeCallExpired, + activeCallLoaded: existing.activeCallLoaded, members: existing.members, memberCount: existing.memberCount ?? group.memberCount, membersLoaded: existing.membersLoaded, @@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', { } }, + /** 失效全部群通话探测缓存 */ + markAllGroupActiveCallsExpired() { + for (const group of this.groups) { + group.activeCallExpired = true + } + }, + + /** 标记群通话探测已加载 */ + markGroupActiveCallLoaded(groupId: number) { + const group = this.getGroup(groupId) + if (!group) { + return + } + group.activeCallLoaded = true + group.activeCallExpired = false + }, + + /** 判断群通话是否需要重新探测 */ + isGroupActiveCallExpired(groupId: number): boolean { + const group = this.getGroup(groupId) + return !group?.activeCallLoaded || !!group.activeCallExpired + }, + /** 失效指定群成员缓存 */ markGroupMembersExpired(groupId: number) { const group = this.getGroup(groupId) diff --git a/apps/web-ele/src/views/im/home/store/rtcStore.ts b/apps/web-ele/src/views/im/home/store/rtcStore.ts index 2e10479d8..4922a6b75 100644 --- a/apps/web-ele/src/views/im/home/store/rtcStore.ts +++ b/apps/web-ele/src/views/im/home/store/rtcStore.ts @@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => { if (!payload?.groupId) { return } + useGroupStore().markGroupActiveCallLoaded(payload.groupId) // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 const existing = groupActiveCalls.value.get(payload.groupId) const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded @@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => { /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ function removeGroupCall(groupId: number) { + if (!groupId) { + return + } clearGroupCallCache(groupId) + useGroupStore().markGroupActiveCallLoaded(groupId) } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ diff --git a/apps/web-ele/src/views/im/home/store/websocketStore.ts b/apps/web-ele/src/views/im/home/store/websocketStore.ts index 3cc0abe6c..2a320db32 100644 --- a/apps/web-ele/src/views/im/home/store/websocketStore.ts +++ b/apps/web-ele/src/views/im/home/store/websocketStore.ts @@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ) if (isActive) { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id @@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.CHANNEL, websocketMessage.channelId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (!readCovered) { + if (!readReported) { apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.CHANNEL, + websocketMessage.channelId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 频道自动已读上报失败', @@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.PRIVATE, peerId, websocketMessage.id @@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.PRIVATE, peerId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { + if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) { apiReadPrivateMessages(peerId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.PRIVATE, + peerId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 私聊自动已读上报失败', @@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id @@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.GROUP, websocketMessage.groupId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { + if (MESSAGE_GROUP_READ_ENABLED && !readReported) { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.GROUP, + websocketMessage.groupId, + websocketMessage.id + ) + ) .catch((error) => { console.warn( '[IM WS] 群聊自动已读上报失败', diff --git a/apps/web-ele/src/views/im/home/types/index.ts b/apps/web-ele/src/views/im/home/types/index.ts index 7af29ae76..303095d81 100644 --- a/apps/web-ele/src/views/im/home/types/index.ts +++ b/apps/web-ele/src/views/im/home/types/index.ts @@ -110,6 +110,7 @@ export interface Conversation { silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean // 群聊:是否有人 @我 atAll?: boolean // 群聊:是否有人 @全体成员 + readMessageId?: number // 已上报到服务端的最大已读消息编号 draft?: { html: string // 输入框 HTML plain: string // 输入框纯文本 @@ -147,6 +148,7 @@ export interface Message { // ==================== IndexedDB 本地存储结构 ==================== +/** 会话 IndexedDB 存储结构 */ export interface ConversationDO extends Conversation { clientConversationId: string // `${type}:${targetId}` } @@ -158,16 +160,19 @@ export interface ConversationRead { updateTime?: number // 更新时间 } +/** 会话读位置 IndexedDB 存储结构 */ export interface ConversationReadDO extends ConversationRead { clientConversationId: string // `${conversationType}:${targetId}` } +/** 消息 IndexedDB 存储结构 */ export interface MessageDO extends Omit { messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` conversationType: number // 会话类型,对齐 ImConversationType clientConversationId: string // ConversationDO.clientConversationId } +/** 设置 IndexedDB 存储结构 */ export interface SettingDO { key: string value: T @@ -195,12 +200,18 @@ export interface Group { groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) members?: GroupMember[] // 群成员缓存(按需懒加载) infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 + activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化 + activeCallExpired?: boolean // 群活跃通话探测是否已过期 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 memberCount?: number // 成员总数 } -export type GroupDO = Omit +/** 群 IndexedDB 存储结构 */ +export type GroupDO = Omit< + Group, + 'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded' +> // 群成员实体(前端内部结构) export interface GroupMember {