diff --git a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue index 1a3509bbb..33ca4cc6f 100644 --- a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue +++ b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue @@ -101,15 +101,18 @@ const pillText = computed(() => { watch( () => [props.groupId, activeCall.value?.room] as const, async ([groupId, room], oldValues) => { - if (!groupId) { + if (!groupId || !activeCall.value) { return } - // 决策是否需要拉取:切群 / room 切换必拉;同群同 room 且已加载 >= 2 人则跳过,避免参与者通知触发后重复请求 + // 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建 const groupChanged = !oldValues || oldValues[0] !== groupId const roomChanged = oldValues && oldValues[1] !== room - const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 - if (!groupChanged && !roomChanged && hydrated) { + const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 + if ( + rtcStore.isGroupCallParticipantsLoaded(groupId, room) || + (!groupChanged && !roomChanged && participantsLoaded) + ) { return } @@ -117,7 +120,7 @@ watch( try { const data = await getActiveCall(groupId) if (data) { - rtcStore.setGroupCall(data) + rtcStore.setGroupCall(data, true) } else { rtcStore.removeGroupCall(groupId) } diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 92bb93499..3ffd38a6c 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -6,6 +6,7 @@ import { useFriendStore } from '../store/friendStore' import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user' import { useGroupStore } from '../store/groupStore' import { useGroupRequestStore } from '../store/groupRequestStore' +import { useRtcStore } from '../store/rtcStore' import { pullPrivateMessageList as apiPullPrivateMessageList, getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, @@ -58,6 +59,7 @@ export const useMessagePuller = () => { const friendStore = useFriendStore() const groupStore = useGroupStore() const groupRequestStore = useGroupRequestStore() + const rtcStore = useRtcStore() const currentUserId = getCurrentUserId() /** 判断请求是否被主动取消 */ @@ -290,7 +292,12 @@ export const useMessagePuller = () => { * 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。 */ const pullStateEvents = async (): Promise => { + // 1. 清理连接级缓存 + messageStore.clearPrivateReadMaxIdCache() + rtcStore.clearGroupCallCache() + groupStore.markAllGroupInfoExpired() groupStore.markAllGroupMembersExpired() + // 2. 并发补偿远端状态 const results = await Promise.allSettled([ friendStore.pullFriends(), friendStore.pullFriendRequests(), @@ -408,6 +415,7 @@ export const useMessagePuller = () => { if (!isCurrentPull()) { return } + messageStore.updatePrivateReadMaxId(active.targetId, maxReadId) if (maxReadId) { messageStore.applyMessageReadReceipt({ conversationType: ImConversationType.PRIVATE, diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 8e4f7d219..12109493d 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -55,7 +55,7 @@ interface SendExtOptions { * 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化 * 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL,失败更新为 FAILED * 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退 - * 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化 + * 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志 */ export const useMessageSender = () => { const conversationStore = useConversationStore() @@ -221,7 +221,7 @@ export const useMessageSender = () => { /** * 触发当前会话的已读上报(切会话 / 进入页面时调用) - * 1. 本端立刻清未读数;服务端回包成功后再做持久化 + * 1. 本端立刻清未读数并推进读位置 * 2. 已读位置取已加载消息和会话末条消息的最大服务端 id */ const readActive = async () => { @@ -237,15 +237,24 @@ export const useMessageSender = () => { 0 ) const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) + const readCovered = conversationStore.isReadPositionCovered( + conversation.type, + conversation.targetId, + maxMessageId + ) + if (readCovered) { + conversationStore.markConversationRead(conversation.type, conversation.targetId) + return + } + const isPrivate = conversation.type === ImConversationType.PRIVATE + const isGroup = conversation.type === ImConversationType.GROUP + const isChannel = conversation.type === ImConversationType.CHANNEL // 本地标记已读:未读数清零(UI 立刻响应) conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId) if (!maxMessageId) { return } - // 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态 - const isPrivate = conversation.type === ImConversationType.PRIVATE - const isGroup = conversation.type === ImConversationType.GROUP - const isChannel = conversation.type === ImConversationType.CHANNEL + // 接口调用:按会话类型分发,并按对应已读开关控制 if (!isPrivate && !isGroup && !isChannel) { return } @@ -287,9 +296,21 @@ export const useMessageSender = () => { if (!MESSAGE_PRIVATE_READ_ENABLED) { return } + const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId) + if (cachedMaxReadId !== undefined) { + if (cachedMaxReadId > 0) { + messageStore.applyMessageReadReceipt({ + conversationType: ImConversationType.PRIVATE, + targetId: peerId, + privateReadMaxId: cachedMaxReadId + }) + } + return + } try { // 拉取对方已读到的最大消息 id const maxReadId = await apiGetPrivateMaxReadMessageId(peerId) + messageStore.updatePrivateReadMaxId(peerId, maxReadId) if (!maxReadId) { return } diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index 781b1d9de..3326cb117 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -41,6 +41,7 @@ import { useGroupStore } from './store/groupStore' import { useGroupRequestStore } from './store/groupRequestStore' import { useFaceStore } from './store/faceStore' import { useChannelStore } from './store/channelStore' +import { useRtcStore } from './store/rtcStore' import { useMessagePuller } from './composables/useMessagePuller' import { useMessageSender } from './composables/useMessageSender' import { useVoicePlayer } from './composables/useVoicePlayer' @@ -65,6 +66,7 @@ const groupStore = useGroupStore() const groupRequestStore = useGroupRequestStore() const faceStore = useFaceStore() const channelStore = useChannelStore() +const rtcStore = useRtcStore() const { pullOnce, cancelPull } = useMessagePuller() const { readActive, syncPrivateReadStatus } = useMessageSender() const voicePlayer = useVoicePlayer() @@ -173,10 +175,12 @@ function onBeforeUnload() { } window.addEventListener('beforeunload', onBeforeUnload) -/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */ +/** 离开 IM 主壳:取消 pull、断开 WebSocket、清理 RTC、保存草稿、停止语音、解绑 unload,并结束当前 IM session */ onUnmounted(() => { cancelPull() webSocketStore.disconnect() + rtcStore.reset() + rtcStore.clearGroupCallCache() conversationStore.flushConversationDraftSave() faceStore.clear() // 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响 diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 3ba0836bf..592b0f1ce 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -459,7 +459,7 @@ async function ensureGroupData(groupId: number) { }) const group = groupStore.getGroup(groupId) if (!group?.membersLoaded || group.membersExpired) { - groupStore.fetchGroupMemberList(groupId, true).catch((error) => { + groupStore.fetchGroupMemberList(groupId).catch((error) => { console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error) }) } @@ -471,7 +471,7 @@ function reloadGroupData() { if (!conversation || conversation.type !== ImConversationType.GROUP) { return } - groupStore.fetchGroupInfo(conversation.targetId) + groupStore.fetchGroupInfo(conversation.targetId, true) groupStore.fetchGroupMemberList(conversation.targetId, true) } diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 46571a2c7..e500508e9 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -30,6 +30,20 @@ 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() + } +} + /** 会话转 IndexedDB 记录 */ function toConversationDO(conversation: Conversation): ConversationDO { const draft = conversation.draft @@ -302,6 +316,15 @@ export const useConversationStore = defineStore('imConversationStore', { return !!record && message.id <= record.messageId }, + /** 判断会话读位置是否覆盖消息编号 */ + isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean { + if (!messageId) { + return false + } + const record = this.getConversationRead(type, targetId) + return !!record && record.messageId >= messageId + }, + /** 应用读位置到会话 */ applyReadToConversation(conversation: Conversation, messageId: number): boolean { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { @@ -652,7 +675,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 @@ -672,19 +695,25 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.atMe = false conversation.atAll = false if (readMessageIdAdvanced) { - const record = { - conversationType: type, - targetId, - messageId, - updateTime: Date.now() - } + const record = createConversationRead(type, targetId, messageId) this.conversationReads[key] = record void getDb() .transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => { await this.saveConversationRecord(conversation, tx) await this.saveConversationReadRecord(record, tx) }) - .catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e)) + .catch((e) => + console.warn( + '[IM conversationStore] 会话已读写入失败', + { + conversationType: type, + targetId, + messageId, + conversationKey: key + }, + e + ) + ) return } this.saveConversation(conversation) diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index c535ac769..498349112 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -51,6 +51,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n /** 构建群 IndexedDB 记录 */ function buildGroupDO(group: Group): GroupDO { const { + infoLoaded: _infoLoaded, members: _members, membersLoaded: _membersLoaded, membersExpired: _membersExpired, @@ -232,10 +233,11 @@ export const useGroupStore = defineStore('imGroupStore', { this.groups = fresh.map((group) => { const existing = groupMap.get(group.id) if (!existing) { - return group + return { ...group, infoLoaded: true } } return { ...group, + infoLoaded: true, members: existing.members, memberCount: existing.memberCount ?? group.memberCount, membersLoaded: existing.membersLoaded, @@ -272,6 +274,13 @@ export const useGroupStore = defineStore('imGroupStore', { } }, + /** 失效全部群详情缓存 */ + markAllGroupInfoExpired() { + for (const group of this.groups) { + group.infoLoaded = false + } + }, + /** 失效全部群成员缓存 */ markAllGroupMembersExpired() { this.groupMembersExpired = true @@ -291,13 +300,17 @@ export const useGroupStore = defineStore('imGroupStore', { }, /** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */ - async fetchGroupInfo(groupId: number) { + async fetchGroupInfo(groupId: number, force = false) { + const cached = this.getGroup(groupId) + if (cached?.infoLoaded && !force) { + return + } try { const data = await apiGetGroup(groupId) if (!data) { return } - this.upsertGroup(convertGroup(data)) + this.upsertGroup({ ...convertGroup(data), infoLoaded: true }) } catch (e) { console.warn('[IM groupStore] fetchGroupInfo 失败', e) } @@ -709,7 +722,7 @@ export const useGroupStore = defineStore('imGroupStore', { if (selfIsOperator && this.getGroup(groupId)) { return } - await this.fetchGroupInfo(groupId) + await this.fetchGroupInfo(groupId, true) }, /** 群名变更:按 newName 局部更新本地群名 */ @@ -724,12 +737,15 @@ export const useGroupStore = defineStore('imGroupStore', { this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' }) }, - /** 群信息变更(NAME / NOTICE 之外字段,当前承载头像变更) */ + /** 群信息变更:同步头像、进群审批 */ applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) { const fields: Partial = {} if (payload.newAvatar) { fields.avatar = payload.newAvatar } + if (payload.newJoinApproval != null) { + fields.joinApproval = payload.newJoinApproval + } if (Object.keys(fields).length > 0) { this.updateGroupFields(groupId, fields) } @@ -739,7 +755,7 @@ export const useGroupStore = defineStore('imGroupStore', { async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) { // 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空 if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) { - await this.fetchGroupInfo(groupId) + await this.fetchGroupInfo(groupId, true) } this.markGroupMembersExpired(groupId) this.fetchGroupMemberList(groupId, true).catch(() => undefined) @@ -750,7 +766,7 @@ export const useGroupStore = defineStore('imGroupStore', { const selfUserId = getCurrentUserId() // 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空 if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) { - await this.fetchGroupInfo(groupId) + await this.fetchGroupInfo(groupId, true) } this.markGroupMembersExpired(groupId) this.fetchGroupMemberList(groupId, true).catch(() => undefined) diff --git a/src/views/im/home/store/messageStore.ts b/src/views/im/home/store/messageStore.ts index de9032db5..2ce341a19 100644 --- a/src/views/im/home/store/messageStore.ts +++ b/src/views/im/home/store/messageStore.ts @@ -241,6 +241,7 @@ export const useMessageStore = defineStore('imMessageStore', { state: () => ({ messagesByConversation: {} as Record, loadedConversationKeys: [] as string[], + privateReadMaxIds: {} as Partial>, privateMessageMaxId: 0, groupMessageMaxId: 0, channelMessageMaxId: 0 @@ -265,6 +266,7 @@ export const useMessageStore = defineStore('imMessageStore', { }) this.messagesByConversation = {} this.loadedConversationKeys = [] + this.privateReadMaxIds = {} this.privateMessageMaxId = 0 this.groupMessageMaxId = 0 this.channelMessageMaxId = 0 @@ -304,6 +306,30 @@ export const useMessageStore = defineStore('imMessageStore', { } }, + /** 获取私聊对方已读位置缓存 */ + getPrivateReadMaxId(peerId: number): number | undefined { + return this.privateReadMaxIds[peerId] + }, + + /** 更新私聊对方已读位置缓存 */ + updatePrivateReadMaxId(peerId: number, maxReadId?: number | null): number { + if (!peerId) { + return 0 + } + const nextMaxReadId = maxReadId || 0 + const current = this.getPrivateReadMaxId(peerId) + if (current !== undefined && nextMaxReadId <= current) { + return current + } + this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId } + return nextMaxReadId + }, + + /** 清空私聊对方已读位置缓存 */ + clearPrivateReadMaxIdCache(): void { + this.privateReadMaxIds = {} + }, + /** 标记会话近期使用 */ touchConversationMessageCache(clientConversationId: string) { this.loadedConversationKeys = [ @@ -790,6 +816,7 @@ export const useMessageStore = defineStore('imMessageStore', { const changed: Message[] = [] // 1. 私聊回执批量更新自己发送的消息 if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) { + this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId) messages.forEach((message) => { if ( message.selfSend && diff --git a/src/views/im/home/store/rtcStore.ts b/src/views/im/home/store/rtcStore.ts index d8fc926d1..0cc5abf0e 100644 --- a/src/views/im/home/store/rtcStore.ts +++ b/src/views/im/home/store/rtcStore.ts @@ -14,6 +14,10 @@ import { getCurrentUserId } from '@/utils/auth' import { useFriendStore } from './friendStore' import { useGroupStore } from './groupStore' +type GroupActiveCallCache = ImRtcGroupCallRespVO & { + participantsLoaded?: boolean // 是否已拉取完整参与者列表 +} + // RTC_CALL 通话信令载荷;按 status 区分子类型语义 export interface ImRtcCallNotification { status: ImRtcParticipantStatusValue @@ -118,7 +122,7 @@ export const useRtcStore = defineStore('imRtc', () => { } /** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */ - const groupActiveCalls = ref>(new Map()) + const groupActiveCalls = ref>(new Map()) /** * 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除; @@ -249,20 +253,51 @@ export const useRtcStore = defineStore('imRtc', () => { * 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected); * 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新 */ - function setGroupCall(payload: ImRtcGroupCallRespVO) { + function setGroupCall(payload: ImRtcGroupCallRespVO, participantsLoaded?: boolean) { if (!payload?.groupId) { return } // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 const existing = groupActiveCalls.value.get(payload.groupId) - if (existing && isSameGroupCall(existing, payload)) { + const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded + if ( + existing && + isSameGroupCall(existing, payload) && + !!existing.participantsLoaded === nextParticipantsLoaded + ) { return } const newGroupActiveCalls = new Map(groupActiveCalls.value) - newGroupActiveCalls.set(payload.groupId, payload) + newGroupActiveCalls.set(payload.groupId, { + ...payload, + participantsLoaded: nextParticipantsLoaded + }) groupActiveCalls.value = newGroupActiveCalls } + /** 清空指定群的通话缓存 */ + function clearGroupCallCache(groupId?: number) { + if (!groupId) { + groupActiveCalls.value = new Map() + return + } + const next = new Map(groupActiveCalls.value) + next.delete(groupId) + groupActiveCalls.value = next + } + + /** 判断群通话是否已补齐 */ + function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean { + const call = groupActiveCalls.value.get(groupId) + return ( + !!groupId && + !!room && + !!call && + call.room === room && + !!call.participantsLoaded + ) + } + /** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */ function isSameGroupCall(a: ImRtcGroupCallRespVO, b: ImRtcGroupCallRespVO): boolean { if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) { @@ -274,12 +309,10 @@ export const useRtcStore = defineStore('imRtc', () => { /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ function removeGroupCall(groupId: number) { - if (!groupId || !groupActiveCalls.value.has(groupId)) { + if (!groupId) { return } - const newGroupActiveCalls = new Map(groupActiveCalls.value) - newGroupActiveCalls.delete(groupId) - groupActiveCalls.value = newGroupActiveCalls + clearGroupCallCache(groupId) } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ @@ -360,6 +393,10 @@ export const useRtcStore = defineStore('imRtc', () => { if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) { return } + if (nextJoined.length === 0 && nextInvitee.length === 0) { + removeGroupCall(groupId) + return + } setGroupCall({ ...existing, joinedUserIds: nextJoined, @@ -385,6 +422,8 @@ export const useRtcStore = defineStore('imRtc', () => { setGroupCall, removeGroupCall, getGroupCall, + isGroupCallParticipantsLoaded, + clearGroupCallCache, applyParticipantConnected, applyParticipantDisconnected, applyParticipantRejected, diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 012ca3a09..1c7713032 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -17,6 +17,7 @@ import { } from '../../utils/constants' import { getPrivateMessagePeerId, + parseRtcCallPayload, playAudioTip, resolveCallEndReasonText } from '../../utils/message' @@ -424,14 +425,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ) if (isActive) { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 - conversationStore.markConversationRead( + const readCovered = conversationStore.isReadPositionCovered( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id ) - apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => { - console.warn('[IM WS] 频道自动已读上报失败', e) - }) + conversationStore.markConversationRead( + ImConversationType.CHANNEL, + websocketMessage.channelId, + readCovered ? undefined : websocketMessage.id + ) + if (!readCovered) { + apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) + .catch((e) => { + console.warn( + '[IM WS] 频道自动已读上报失败', + { + conversationType: ImConversationType.CHANNEL, + channelId: websocketMessage.channelId, + messageId: websocketMessage.id, + messageType: websocketMessage.type + }, + e + ) + }) + } } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // 非当前会话且未免打扰:响一下提示音 playAudioTip() @@ -515,7 +533,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { this.handleGroupMemberNicknameUpdate(websocketMessage) break case ImContentType.RTC_CALL_START: - // 入库 + 渲染聊天 tip;胶囊条状态走 1602/1603,本帧不动 rtcStore,避免与首次填充竞争 + // 入库 + 渲染聊天 tip;同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员 + this.handleRtcCallStart(websocketMessage) ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage)) break case ImContentType.RTC_CALL_END: @@ -611,15 +630,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) - conversationStore.markConversationRead( + const readCovered = conversationStore.isReadPositionCovered( ImConversationType.PRIVATE, peerId, websocketMessage.id ) - if (MESSAGE_PRIVATE_READ_ENABLED) { - apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => { - console.warn('[IM WS] 自动已读上报失败', e) - }) + conversationStore.markConversationRead( + ImConversationType.PRIVATE, + peerId, + readCovered ? undefined : websocketMessage.id + ) + if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { + apiReadPrivateMessages(peerId, websocketMessage.id) + .catch((e) => { + console.warn( + '[IM WS] 私聊自动已读上报失败', + { + conversationType: ImConversationType.PRIVATE, + peerId, + messageId: websocketMessage.id, + messageType: websocketMessage.type, + senderId: websocketMessage.senderId + }, + e + ) + }) } } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响 @@ -713,7 +748,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底) const group = groupStore.getGroup(websocketMessage.groupId) if (!group) { - groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined) + groupStore.fetchGroupInfo(websocketMessage.groupId, true).catch(() => undefined) } // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}` @@ -750,15 +785,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 - conversationStore.markConversationRead( + const readCovered = conversationStore.isReadPositionCovered( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id ) - if (MESSAGE_GROUP_READ_ENABLED) { - apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => { - console.warn('[IM WS] 自动已读上报失败', e) - }) + conversationStore.markConversationRead( + ImConversationType.GROUP, + websocketMessage.groupId, + readCovered ? undefined : websocketMessage.id + ) + if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { + apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) + .catch((e) => { + console.warn( + '[IM WS] 群聊自动已读上报失败', + { + conversationType: ImConversationType.GROUP, + groupId: websocketMessage.groupId, + messageId: websocketMessage.id, + messageType: websocketMessage.type, + senderId: websocketMessage.senderId + }, + e + ) + }) } } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // GROUP_* 群广播事件等系统消息不响提示音 @@ -1063,6 +1114,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { } }, + /** RTC_CALL_START 通话开始 */ + handleRtcCallStart(websocketMessage: ImGroupMessageNotification) { + const payload = parseRtcCallPayload(websocketMessage.content) + if (!payload?.room || !payload.mediaType || !payload.inviterUserId) { + console.warn('[IM WS] RTC_CALL_START payload 不合法', { + groupId: websocketMessage.groupId, + messageId: websocketMessage.id, + contentLength: websocketMessage.content?.length ?? 0 + }) + return + } + useRtcStore().setGroupCall({ + room: payload.room, + groupId: websocketMessage.groupId, + mediaType: payload.mediaType, + inviterId: payload.inviterUserId, + joinedUserIds: [payload.inviterUserId], + inviteeIds: [] + }) + }, + /** * RTC_CALL_END 通话结束;私聊 + 群聊都走这一条;payload 携带 conversationType 区分 *

diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 5fea28717..753380c50 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -194,12 +194,13 @@ export interface Group { silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填 groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) members?: GroupMember[] // 群成员缓存(按需懒加载) + infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 memberCount?: number // 成员总数 } -export type GroupDO = Omit +export type GroupDO = Omit // 群成员实体(前端内部结构) export interface GroupMember { diff --git a/src/views/im/manager/statistics/components/OverviewCards.vue b/src/views/im/manager/statistics/components/OverviewCards.vue index 1528ccfc6..6522f397a 100644 --- a/src/views/im/manager/statistics/components/OverviewCards.vue +++ b/src/views/im/manager/statistics/components/OverviewCards.vue @@ -1,25 +1,30 @@