feat(im):优化已读上报、群详情缓存与 RTC 通话状态

- 已读上报增加本地读位置覆盖判断,避免切换会话和当前会话自动已读时重复调用 read 接口
- 标记会话已读时同步推进本地 read 游标并写入 IndexedDB,接口失败仅记录日志
- 缓存私聊对方 maxReadMessageId,并在状态补拉、回执更新和退出 IM 时维护缓存
- 增加群详情 infoLoaded 内存标记,减少切群时重复拉取群详情,手动刷新和关键通知仍强制刷新
- 同步 GROUP_INFO_UPDATE 的 joinApproval,避免群审批配置在前端缓存中陈旧
- 优化群通话胶囊条状态,记录 participantsLoaded,按需补齐参与者并在通话无人时移除胶囊
- RTC_CALL_START 生成群通话最小胶囊条,后续由参与者事件和 getActiveCall 补齐
- 退出 IM 时清理 RTC 状态和群通话缓存
- Vben antd/antd-next 调整媒体元素为函数 ref,修复 MediaStream 与元素挂载时序问题
- 修复 Vben 消息历史弹窗回调类型标注
This commit is contained in:
YunaiV
2026-06-19 10:05:22 -07:00
parent efc75e2608
commit c0ead15bc3
48 changed files with 840 additions and 235 deletions

View File

@@ -22,7 +22,7 @@ defineEmits<{
toggleSpeaker: []
}>()
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
</script>
<template>
@@ -35,7 +35,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
<!-- 视频呼叫自己摄像头预览铺底对方头像悬浮顶部 -->
<video
v-if="isVideo && localStream"
ref="localVideoRef"
:ref="setLocalVideoRef"
class="absolute inset-0 object-cover w-full h-full scale-x-[-1]"
autoplay
muted
@@ -63,7 +63,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggleMic')"
>
<!-- ant-design 系列 mic audio-muted-outlined 变体speaker / camera 没有 muted 变体off 态借 tabler:*-off 表达斜线 -->
<!-- Iconify mic 静音变体speaker / camera 没有同源静音变体off 态借 tabler:*-off 表达斜线 -->
<span
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"

View File

@@ -19,8 +19,8 @@ const props = defineProps<{
speakerEnabled: boolean
}>()
const videoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
</script>
<template>
@@ -36,7 +36,7 @@ const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant
<!-- 视频可用渲染 video否则渲染头像或默认占位 -->
<video
v-if="participant.videoStream"
ref="videoRef"
:ref="setVideoRef"
class="object-cover w-full h-full"
autoplay
playsinline
@@ -55,7 +55,7 @@ const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant
<!-- 远端音频通过 audio 元素播放本端静音避免回声扬声器关闭时整体静音 -->
<audio
v-if="participant.audioStream && !participant.isLocal"
ref="audioRef"
:ref="setAudioRef"
autoplay
:muted="!speakerEnabled"
></audio>

View File

@@ -50,9 +50,9 @@ const gridColsClass = computed(() => {
return 'grid-cols-3'
})
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const remoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const remoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
/** 1v1 视频:是否有远端视频流 */
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
@@ -121,7 +121,7 @@ const formattedDuration = computed(() =>
<template v-else-if="isVideo">
<div v-show="hasRemoteVideo" class="absolute inset-0">
<video
ref="remoteVideoRef"
:ref="setRemoteVideoRef"
class="object-cover w-full h-full"
autoplay
playsinline
@@ -143,7 +143,7 @@ const formattedDuration = computed(() =>
class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]"
>
<video
ref="localVideoRef"
:ref="setLocalVideoRef"
class="object-cover w-full h-full scale-x-[-1]"
autoplay
muted
@@ -168,7 +168,7 @@ const formattedDuration = computed(() =>
</template>
<audio
v-if="!isGroup && remoteAudioStream"
ref="remoteAudioRef"
:ref="setRemoteAudioRef"
autoplay
:muted="!speakerEnabled"
></audio>

View File

@@ -42,23 +42,26 @@ 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
}
// 拉最新参与者写回 store接口返回空 → 该群已无活跃通话,移除本地缓存
// 拉最新参与者写回 store接口返回空 → 该群已无活跃通话
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data)
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}

View File

@@ -1,4 +1,4 @@
import { ref, type Ref, watch } from 'vue'
import { ref, type VNodeRef, watch } from 'vue'
/**
* 把响应式 MediaStream 挂到 `<video>` / `<audio>` 元素的 srcObject 上;
@@ -6,16 +6,22 @@ import { ref, type Ref, watch } from 'vue'
*/
export function useMediaStreamElement<T extends HTMLMediaElement>(
streamSource: () => MediaStream | null | undefined
): Ref<T | undefined> {
): VNodeRef {
const elRef = ref<T>()
const syncStream = (stream = streamSource()) => {
if (elRef.value) {
elRef.value.srcObject = stream || null
}
}
watch(
streamSource,
(stream) => {
if (elRef.value) {
elRef.value.srcObject = stream || null
}
syncStream(stream)
},
{ flush: 'post', immediate: true }
)
return elRef
return (el) => {
elRef.value = el instanceof HTMLMediaElement ? (el as T) : undefined
syncStream()
}
}

View File

@@ -32,6 +32,7 @@ import { useFriendStore } from '../store/friendStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
import { useGroupStore } from '../store/groupStore'
import { type PulledMessage, useMessageStore } from '../store/messageStore'
import { useRtcStore } from '../store/rtcStore'
import { useImWebSocketStore } from '../store/websocketStore'
/** 三类消息 pull 接口返回的原始 VO 联合类型runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
@@ -55,6 +56,7 @@ export const useMessagePuller = () => {
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const rtcStore = useRtcStore()
const currentUserId = getCurrentUserId()
/** 判断请求是否被主动取消 */
@@ -285,7 +287,12 @@ export const useMessagePuller = () => {
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
*/
const pullStateEvents = async (): Promise<void> => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态
const results = await Promise.allSettled([
friendStore.pullFriends(),
friendStore.pullFriendRequests(),
@@ -403,6 +410,7 @@ export const useMessagePuller = () => {
if (!isCurrentPull()) {
return
}
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
if (maxReadId) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,

View File

@@ -57,7 +57,7 @@ interface SendExtOptions {
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL失败更新为 FAILED
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
@@ -223,7 +223,7 @@ export const useMessageSender = () => {
/**
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
* 1. 本端立刻清未读数并推进读位置
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
*/
const readActive = async () => {
@@ -240,15 +240,24 @@ export const useMessageSender = () => {
}
}
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
}
@@ -290,9 +299,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
}

View File

@@ -146,7 +146,7 @@ function onBeforeUnload() {
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload并结束当前 IM session */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()

View File

@@ -510,7 +510,7 @@ function locateMessage(messageId: number) {
width="640px"
:footer="null"
class="im-message-history__dialog"
@after-open-change="(open) => open && onDialogOpen()"
@after-open-change="(open: boolean) => open && onDialogOpen()"
>
<div class="flex flex-col gap-3 h-[520px]">
<!-- 搜索区activeFilter 存在时左侧出 chip × 可清,否则纯搜索框 -->

View File

@@ -250,7 +250,7 @@ function reloadGroupData() {
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
}
groupStore.fetchGroupInfo(conversation.targetId)
groupStore.fetchGroupInfo(conversation.targetId, true)
groupStore.fetchGroupMemberList(conversation.targetId, true)
}

View File

@@ -335,6 +335,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) {
@@ -604,11 +613,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!conversation) {
return
}
// 1. 清理会话级未读状态
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
// 2. 懒加载消息并保存会话摘要
// 懒加载消息并保存会话摘要
void useMessageStore().ensureConversationMessageListLoaded(conversation)
this.saveConversation(conversation)
},
@@ -705,12 +710,7 @@ 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) => {

View File

@@ -47,6 +47,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
membersExpired: _membersExpired,
@@ -228,10 +229,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,
@@ -251,6 +253,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.preloadMembersForEmptyAvatarGroups()
},
/** 失效全部群详情缓存 */
markAllGroupInfoExpired() {
for (const group of this.groups) {
group.infoLoaded = false
}
},
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
preloadMembersForEmptyAvatarGroups() {
for (const group of this.groups) {
@@ -287,13 +296,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 (error) {
console.warn('[IM groupStore] fetchGroupInfo 失败', error)
}
@@ -725,7 +738,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (selfIsOperator && this.getGroup(groupId)) {
return
}
await this.fetchGroupInfo(groupId)
await this.fetchGroupInfo(groupId, true)
},
/** 群名变更:按 newName 局部更新本地群名 */
@@ -740,12 +753,15 @@ export const useGroupStore = defineStore('imGroupStore', {
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
},
/** 群信息变更NAME / NOTICE 之外字段,当前承载头像变更) */
/** 群信息变更:同步头像、进群审批 */
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
const fields: Partial<Group> = {}
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)
}
@@ -755,7 +771,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)
@@ -766,7 +782,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)

View File

@@ -242,6 +242,7 @@ export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record<string, Message[]>,
loadedConversationKeys: [] as string[],
privateReadMaxIds: {} as Partial<Record<number, number>>,
privateMessageMaxId: 0,
groupMessageMaxId: 0,
channelMessageMaxId: 0
@@ -266,6 +267,7 @@ export const useMessageStore = defineStore('imMessageStore', {
})
this.messagesByConversation = {}
this.loadedConversationKeys = []
this.privateReadMaxIds = {}
this.privateMessageMaxId = 0
this.groupMessageMaxId = 0
this.channelMessageMaxId = 0
@@ -305,6 +307,30 @@ export const useMessageStore = defineStore('imMessageStore', {
}
},
/** 获取私聊对方已读位置缓存 */
getPrivateReadMaxId(peerId: number): number | undefined {
return this.privateReadMaxIds[peerId]
},
/** 更新私聊对方已读位置缓存 */
updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): 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 = [
@@ -797,6 +823,7 @@ export const useMessageStore = defineStore('imMessageStore', {
const changed: Message[] = []
// 1. 私聊回执批量更新自己发送的消息
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
const privateReadMaxId = options.privateReadMaxId
messages.forEach((message) => {
if (

View File

@@ -17,6 +17,10 @@ import {
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
type GroupActiveCallCache = {
participantsLoaded?: boolean // 是否已拉取完整参与者列表
} & ImRtcApi.RtcGroupCallRespVO
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
status: ImRtcParticipantStatusValue
@@ -121,7 +125,7 @@ export const useRtcStore = defineStore('imRtc', () => {
}
/** 群活跃通话索引groupId -> 群通话摘要;用于群聊顶部胶囊条 */
const groupActiveCalls = ref<Map<number, ImRtcApi.RtcGroupCallRespVO>>(new Map())
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
/**
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
@@ -252,20 +256,51 @@ export const useRtcStore = defineStore('imRtc', () => {
* 房内成员同步交给 LiveKit 客户端事件ParticipantConnected / Disconnected
* 胶囊条不实时刷新 joinedUserIds / inviteeIds展开 / 加入时再走 getActiveCall 接口拉最新
*/
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO) {
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, 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: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
@@ -285,12 +320,7 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId || !groupActiveCalls.value.has(groupId)) {
return
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.delete(groupId)
groupActiveCalls.value = newGroupActiveCalls
clearGroupCallCache(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
@@ -371,6 +401,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,
@@ -394,6 +428,8 @@ export const useRtcStore = defineStore('imRtc', () => {
markUserLeft,
isUserLeft,
setGroupCall,
isGroupCallParticipantsLoaded,
clearGroupCallCache,
removeGroupCall,
getGroupCall,
applyParticipantConnected,

View File

@@ -42,6 +42,7 @@ import {
} from '../../utils/constants'
import {
getPrivateMessagePeerId,
parseRtcCallPayload,
playAudioTip,
resolveCallEndReasonText
} from '../../utils/message'
@@ -462,14 +463,30 @@ 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((error) => {
console.warn('[IM WS] 频道自动已读上报失败', error)
})
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
)
if (!readCovered) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
{
conversationType: ImConversationType.CHANNEL,
channelId: websocketMessage.channelId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音
playAudioTip()
@@ -567,7 +584,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
break
}
case ImContentType.RTC_CALL_START: {
// 入库 + 渲染聊天 tip胶囊条状态走 1602/1603本帧不动 rtcStore避免与首次填充竞争
// 入库 + 渲染聊天 tip同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
this.handleRtcCallStart(websocketMessage)
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
break
}
@@ -660,15 +678,29 @@ 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((error) => {
console.warn('[IM WS] 自动已读上报失败', error)
})
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
{
conversationType: ImConversationType.PRIVATE,
peerId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTipFRIEND_* 等系统事件不响
@@ -799,17 +831,29 @@ 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(
(error) => {
console.warn('[IM WS] 自动已读上报失败', error)
}
)
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',
{
conversationType: ImConversationType.GROUP,
groupId: websocketMessage.groupId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// GROUP_* 群广播事件等系统消息不响提示音
@@ -1129,6 +1173,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 区分
* <p>

View File

@@ -194,12 +194,13 @@ export interface Group {
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'members' | 'membersExpired' | 'membersLoaded'>
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
// 群成员实体(前端内部结构)
export interface GroupMember {

View File

@@ -516,7 +516,8 @@ export async function stopRequests(): Promise<void> {
{ useGroupStoreWithOut },
{ useChannelStoreWithOut },
{ useGroupRequestStoreWithOut },
{ useFaceStoreWithOut }
{ useFaceStoreWithOut },
{ useRtcStore }
] = await Promise.all([
import('../home/store/messageStore'),
import('../home/store/conversationStore'),
@@ -524,7 +525,8 @@ export async function stopRequests(): Promise<void> {
import('../home/store/groupStore'),
import('../home/store/channelStore'),
import('../home/store/groupRequestStore'),
import('../home/store/faceStore')
import('../home/store/faceStore'),
import('../home/store/rtcStore')
])
useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear()
@@ -533,5 +535,7 @@ export async function stopRequests(): Promise<void> {
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().clear()
useFaceStoreWithOut().clear()
useRtcStore().reset()
useRtcStore().clearGroupCallCache()
closeDbConnection()
}

View File

@@ -700,10 +700,12 @@ export type GroupNotificationPayload = {
mutedUserId?: number
muteEndTime?: string
newAvatar?: string
newJoinApproval?: boolean
newName?: string
newNotice?: string
newOwnerUserId?: number
oldAvatar?: string
oldJoinApproval?: boolean
oldName?: string
oldNotice?: string
operatorUserId?: number

View File

@@ -22,8 +22,7 @@ defineEmits<{
toggleSpeaker: []
}>()
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
void localVideoRef
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
</script>
<template>
@@ -36,7 +35,7 @@ void localVideoRef
<!-- 视频呼叫自己摄像头预览铺底对方头像悬浮顶部 -->
<video
v-if="isVideo && localStream"
ref="localVideoRef"
:ref="setLocalVideoRef"
class="absolute inset-0 object-cover w-full h-full scale-x-[-1]"
autoplay
muted
@@ -64,7 +63,7 @@ void localVideoRef
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggleMic')"
>
<!-- ant-design 系列 mic audio-muted-outlined 变体speaker / camera 没有 muted 变体off 态借 tabler:*-off 表达斜线 -->
<!-- Iconify mic 静音变体speaker / camera 没有同源静音变体off 态借 tabler:*-off 表达斜线 -->
<span
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"

View File

@@ -19,10 +19,8 @@ const props = defineProps<{
speakerEnabled: boolean
}>()
const videoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
void audioRef
void videoRef
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
</script>
<template>
@@ -38,7 +36,7 @@ void videoRef
<!-- 视频可用渲染 video否则渲染头像或默认占位 -->
<video
v-if="participant.videoStream"
ref="videoRef"
:ref="setVideoRef"
class="object-cover w-full h-full"
autoplay
playsinline
@@ -57,7 +55,7 @@ void videoRef
<!-- 远端音频通过 audio 元素播放本端静音避免回声扬声器关闭时整体静音 -->
<audio
v-if="participant.audioStream && !participant.isLocal"
ref="audioRef"
:ref="setAudioRef"
autoplay
:muted="!speakerEnabled"
></audio>

View File

@@ -50,12 +50,9 @@ const gridColsClass = computed(() => {
return 'grid-cols-3'
})
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const remoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const remoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
void localVideoRef
void remoteAudioRef
void remoteVideoRef
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
/** 1v1 视频:是否有远端视频流 */
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
@@ -124,7 +121,7 @@ const formattedDuration = computed(() =>
<template v-else-if="isVideo">
<div v-show="hasRemoteVideo" class="absolute inset-0">
<video
ref="remoteVideoRef"
:ref="setRemoteVideoRef"
class="object-cover w-full h-full"
autoplay
playsinline
@@ -146,7 +143,7 @@ const formattedDuration = computed(() =>
class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]"
>
<video
ref="localVideoRef"
:ref="setLocalVideoRef"
class="object-cover w-full h-full scale-x-[-1]"
autoplay
muted
@@ -171,7 +168,7 @@ const formattedDuration = computed(() =>
</template>
<audio
v-if="!isGroup && remoteAudioStream"
ref="remoteAudioRef"
:ref="setRemoteAudioRef"
autoplay
:muted="!speakerEnabled"
></audio>

View File

@@ -42,23 +42,26 @@ 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
}
// 拉最新参与者写回 store接口返回空 → 该群已无活跃通话,移除本地缓存
// 拉最新参与者写回 store接口返回空 → 该群已无活跃通话
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data)
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}

View File

@@ -1,4 +1,4 @@
import { ref, type Ref, watch } from 'vue'
import { ref, type VNodeRef, watch } from 'vue'
/**
* 把响应式 MediaStream 挂到 `<video>` / `<audio>` 元素的 srcObject 上;
@@ -6,16 +6,22 @@ import { ref, type Ref, watch } from 'vue'
*/
export function useMediaStreamElement<T extends HTMLMediaElement>(
streamSource: () => MediaStream | null | undefined
): Ref<T | undefined> {
): VNodeRef {
const elRef = ref<T>()
const syncStream = (stream = streamSource()) => {
if (elRef.value) {
elRef.value.srcObject = stream || null
}
}
watch(
streamSource,
(stream) => {
if (elRef.value) {
elRef.value.srcObject = stream || null
}
syncStream(stream)
},
{ flush: 'post', immediate: true }
)
return elRef
return (el) => {
elRef.value = el instanceof HTMLMediaElement ? (el as T) : undefined
syncStream()
}
}

View File

@@ -32,6 +32,7 @@ import { useFriendStore } from '../store/friendStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
import { useGroupStore } from '../store/groupStore'
import { type PulledMessage, useMessageStore } from '../store/messageStore'
import { useRtcStore } from '../store/rtcStore'
import { useImWebSocketStore } from '../store/websocketStore'
/** 三类消息 pull 接口返回的原始 VO 联合类型runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
@@ -55,6 +56,7 @@ export const useMessagePuller = () => {
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const rtcStore = useRtcStore()
const currentUserId = getCurrentUserId()
/** 判断请求是否被主动取消 */
@@ -285,7 +287,12 @@ export const useMessagePuller = () => {
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
*/
const pullStateEvents = async (): Promise<void> => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态
const results = await Promise.allSettled([
friendStore.pullFriends(),
friendStore.pullFriendRequests(),
@@ -403,6 +410,7 @@ export const useMessagePuller = () => {
if (!isCurrentPull()) {
return
}
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
if (maxReadId) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,

View File

@@ -57,7 +57,7 @@ interface SendExtOptions {
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL失败更新为 FAILED
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
@@ -223,7 +223,7 @@ export const useMessageSender = () => {
/**
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
* 1. 本端立刻清未读数并推进读位置
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
*/
const readActive = async () => {
@@ -240,15 +240,24 @@ export const useMessageSender = () => {
}
}
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
}
@@ -290,9 +299,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
}

View File

@@ -146,7 +146,7 @@ function onBeforeUnload() {
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload并结束当前 IM session */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()

View File

@@ -250,7 +250,7 @@ function reloadGroupData() {
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
}
groupStore.fetchGroupInfo(conversation.targetId)
groupStore.fetchGroupInfo(conversation.targetId, true)
groupStore.fetchGroupMemberList(conversation.targetId, true)
}

View File

@@ -29,6 +29,20 @@ const pendingDraftConversations = new Set<Conversation>()
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) {
let timer: ReturnType<typeof setTimeout> | undefined
@@ -335,6 +349,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) {
@@ -604,11 +627,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!conversation) {
return
}
// 1. 清理会话级未读状态
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
// 2. 懒加载消息并保存会话摘要
// 懒加载消息并保存会话摘要
void useMessageStore().ensureConversationMessageListLoaded(conversation)
this.saveConversation(conversation)
},
@@ -685,7 +704,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
@@ -705,19 +724,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((error) => console.warn('[IM conversationStore] 会话已读写入失败', error))
.catch((error) =>
console.warn(
'[IM conversationStore] 会话已读写入失败',
{
conversationType: type,
targetId,
messageId,
conversationKey: key
},
error
)
)
return
}
this.saveConversation(conversation)

View File

@@ -47,6 +47,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
membersExpired: _membersExpired,
@@ -228,10 +229,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,
@@ -251,6 +253,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.preloadMembersForEmptyAvatarGroups()
},
/** 失效全部群详情缓存 */
markAllGroupInfoExpired() {
for (const group of this.groups) {
group.infoLoaded = false
}
},
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
preloadMembersForEmptyAvatarGroups() {
for (const group of this.groups) {
@@ -287,13 +296,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 (error) {
console.warn('[IM groupStore] fetchGroupInfo 失败', error)
}
@@ -725,7 +738,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (selfIsOperator && this.getGroup(groupId)) {
return
}
await this.fetchGroupInfo(groupId)
await this.fetchGroupInfo(groupId, true)
},
/** 群名变更:按 newName 局部更新本地群名 */
@@ -740,12 +753,15 @@ export const useGroupStore = defineStore('imGroupStore', {
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
},
/** 群信息变更NAME / NOTICE 之外字段,当前承载头像变更) */
/** 群信息变更:同步头像、进群审批 */
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
const fields: Partial<Group> = {}
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)
}
@@ -755,7 +771,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)
@@ -766,7 +782,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)

View File

@@ -242,6 +242,7 @@ export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record<string, Message[]>,
loadedConversationKeys: [] as string[],
privateReadMaxIds: {} as Partial<Record<number, number>>,
privateMessageMaxId: 0,
groupMessageMaxId: 0,
channelMessageMaxId: 0
@@ -266,6 +267,7 @@ export const useMessageStore = defineStore('imMessageStore', {
})
this.messagesByConversation = {}
this.loadedConversationKeys = []
this.privateReadMaxIds = {}
this.privateMessageMaxId = 0
this.groupMessageMaxId = 0
this.channelMessageMaxId = 0
@@ -305,6 +307,30 @@ export const useMessageStore = defineStore('imMessageStore', {
}
},
/** 获取私聊对方已读位置缓存 */
getPrivateReadMaxId(peerId: number): number | undefined {
return this.privateReadMaxIds[peerId]
},
/** 更新私聊对方已读位置缓存 */
updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): 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 = [
@@ -797,6 +823,7 @@ export const useMessageStore = defineStore('imMessageStore', {
const changed: Message[] = []
// 1. 私聊回执批量更新自己发送的消息
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
const privateReadMaxId = options.privateReadMaxId
messages.forEach((message) => {
if (

View File

@@ -17,6 +17,10 @@ import {
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
type GroupActiveCallCache = {
participantsLoaded?: boolean // 是否已拉取完整参与者列表
} & ImRtcApi.RtcGroupCallRespVO
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
status: ImRtcParticipantStatusValue
@@ -121,7 +125,7 @@ export const useRtcStore = defineStore('imRtc', () => {
}
/** 群活跃通话索引groupId -> 群通话摘要;用于群聊顶部胶囊条 */
const groupActiveCalls = ref<Map<number, ImRtcApi.RtcGroupCallRespVO>>(new Map())
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
/**
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
@@ -252,20 +256,51 @@ export const useRtcStore = defineStore('imRtc', () => {
* 房内成员同步交给 LiveKit 客户端事件ParticipantConnected / Disconnected
* 胶囊条不实时刷新 joinedUserIds / inviteeIds展开 / 加入时再走 getActiveCall 接口拉最新
*/
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO) {
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, 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: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
@@ -285,12 +320,7 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId || !groupActiveCalls.value.has(groupId)) {
return
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.delete(groupId)
groupActiveCalls.value = newGroupActiveCalls
clearGroupCallCache(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
@@ -371,6 +401,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,
@@ -394,6 +428,8 @@ export const useRtcStore = defineStore('imRtc', () => {
markUserLeft,
isUserLeft,
setGroupCall,
isGroupCallParticipantsLoaded,
clearGroupCallCache,
removeGroupCall,
getGroupCall,
applyParticipantConnected,

View File

@@ -42,6 +42,7 @@ import {
} from '../../utils/constants'
import {
getPrivateMessagePeerId,
parseRtcCallPayload,
playAudioTip,
resolveCallEndReasonText
} from '../../utils/message'
@@ -462,14 +463,30 @@ 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((error) => {
console.warn('[IM WS] 频道自动已读上报失败', error)
})
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
)
if (!readCovered) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
{
conversationType: ImConversationType.CHANNEL,
channelId: websocketMessage.channelId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音
playAudioTip()
@@ -567,7 +584,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
break
}
case ImContentType.RTC_CALL_START: {
// 入库 + 渲染聊天 tip胶囊条状态走 1602/1603本帧不动 rtcStore避免与首次填充竞争
// 入库 + 渲染聊天 tip同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
this.handleRtcCallStart(websocketMessage)
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
break
}
@@ -660,15 +678,29 @@ 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((error) => {
console.warn('[IM WS] 自动已读上报失败', error)
})
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
{
conversationType: ImConversationType.PRIVATE,
peerId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTipFRIEND_* 等系统事件不响
@@ -799,17 +831,29 @@ 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(
(error) => {
console.warn('[IM WS] 自动已读上报失败', error)
}
)
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',
{
conversationType: ImConversationType.GROUP,
groupId: websocketMessage.groupId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// GROUP_* 群广播事件等系统消息不响提示音
@@ -1129,6 +1173,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 区分
* <p>

View File

@@ -194,12 +194,13 @@ export interface Group {
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'members' | 'membersExpired' | 'membersLoaded'>
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
// 群成员实体(前端内部结构)
export interface GroupMember {

View File

@@ -516,7 +516,8 @@ export async function stopRequests(): Promise<void> {
{ useGroupStoreWithOut },
{ useChannelStoreWithOut },
{ useGroupRequestStoreWithOut },
{ useFaceStoreWithOut }
{ useFaceStoreWithOut },
{ useRtcStore }
] = await Promise.all([
import('../home/store/messageStore'),
import('../home/store/conversationStore'),
@@ -524,7 +525,8 @@ export async function stopRequests(): Promise<void> {
import('../home/store/groupStore'),
import('../home/store/channelStore'),
import('../home/store/groupRequestStore'),
import('../home/store/faceStore')
import('../home/store/faceStore'),
import('../home/store/rtcStore')
])
useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear()
@@ -533,5 +535,7 @@ export async function stopRequests(): Promise<void> {
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().clear()
useFaceStoreWithOut().clear()
useRtcStore().reset()
useRtcStore().clearGroupCallCache()
closeDbConnection()
}

View File

@@ -700,10 +700,12 @@ export type GroupNotificationPayload = {
mutedUserId?: number
muteEndTime?: string
newAvatar?: string
newJoinApproval?: boolean
newName?: string
newNotice?: string
newOwnerUserId?: number
oldAvatar?: string
oldJoinApproval?: boolean
oldName?: string
oldNotice?: string
operatorUserId?: number

View File

@@ -42,23 +42,26 @@ 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
}
// 拉最新参与者写回 store接口返回空 → 该群已无活跃通话,移除本地缓存
// 拉最新参与者写回 store接口返回空 → 该群已无活跃通话
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data)
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}

View File

@@ -32,6 +32,7 @@ import { useFriendStore } from '../store/friendStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
import { useGroupStore } from '../store/groupStore'
import { type PulledMessage, useMessageStore } from '../store/messageStore'
import { useRtcStore } from '../store/rtcStore'
import { useImWebSocketStore } from '../store/websocketStore'
/** 三类消息 pull 接口返回的原始 VO 联合类型runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
@@ -55,6 +56,7 @@ export const useMessagePuller = () => {
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const rtcStore = useRtcStore()
const currentUserId = getCurrentUserId()
/** 判断请求是否被主动取消 */
@@ -285,7 +287,12 @@ export const useMessagePuller = () => {
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
*/
const pullStateEvents = async (): Promise<void> => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态
const results = await Promise.allSettled([
friendStore.pullFriends(),
friendStore.pullFriendRequests(),
@@ -403,6 +410,7 @@ export const useMessagePuller = () => {
if (!isCurrentPull()) {
return
}
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
if (maxReadId) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,

View File

@@ -57,7 +57,7 @@ interface SendExtOptions {
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL失败更新为 FAILED
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
@@ -223,7 +223,7 @@ export const useMessageSender = () => {
/**
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
* 1. 本端立刻清未读数并推进读位置
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
*/
const readActive = async () => {
@@ -240,15 +240,24 @@ export const useMessageSender = () => {
}
}
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
}
@@ -290,9 +299,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
}

View File

@@ -146,7 +146,7 @@ function onBeforeUnload() {
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload并结束当前 IM session */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()

View File

@@ -250,7 +250,7 @@ function reloadGroupData() {
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
}
groupStore.fetchGroupInfo(conversation.targetId)
groupStore.fetchGroupInfo(conversation.targetId, true)
groupStore.fetchGroupMemberList(conversation.targetId, true)
}

View File

@@ -29,6 +29,20 @@ const pendingDraftConversations = new Set<Conversation>()
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) {
let timer: ReturnType<typeof setTimeout> | undefined
@@ -335,6 +349,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) {
@@ -604,11 +627,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!conversation) {
return
}
// 1. 清理会话级未读状态
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
// 2. 懒加载消息并保存会话摘要
// 懒加载消息并保存会话摘要
void useMessageStore().ensureConversationMessageListLoaded(conversation)
this.saveConversation(conversation)
},
@@ -685,7 +704,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
@@ -705,19 +724,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((error) => console.warn('[IM conversationStore] 会话已读写入失败', error))
.catch((error) =>
console.warn(
'[IM conversationStore] 会话已读写入失败',
{
conversationType: type,
targetId,
messageId,
conversationKey: key
},
error
)
)
return
}
this.saveConversation(conversation)

View File

@@ -47,6 +47,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
membersExpired: _membersExpired,
@@ -228,10 +229,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,
@@ -251,6 +253,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.preloadMembersForEmptyAvatarGroups()
},
/** 失效全部群详情缓存 */
markAllGroupInfoExpired() {
for (const group of this.groups) {
group.infoLoaded = false
}
},
/** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
preloadMembersForEmptyAvatarGroups() {
for (const group of this.groups) {
@@ -287,13 +296,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 (error) {
console.warn('[IM groupStore] fetchGroupInfo 失败', error)
}
@@ -725,7 +738,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (selfIsOperator && this.getGroup(groupId)) {
return
}
await this.fetchGroupInfo(groupId)
await this.fetchGroupInfo(groupId, true)
},
/** 群名变更:按 newName 局部更新本地群名 */
@@ -740,12 +753,15 @@ export const useGroupStore = defineStore('imGroupStore', {
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
},
/** 群信息变更NAME / NOTICE 之外字段,当前承载头像变更) */
/** 群信息变更:同步头像、进群审批 */
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
const fields: Partial<Group> = {}
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)
}
@@ -755,7 +771,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)
@@ -766,7 +782,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)

View File

@@ -242,6 +242,7 @@ export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record<string, Message[]>,
loadedConversationKeys: [] as string[],
privateReadMaxIds: {} as Partial<Record<number, number>>,
privateMessageMaxId: 0,
groupMessageMaxId: 0,
channelMessageMaxId: 0
@@ -266,6 +267,7 @@ export const useMessageStore = defineStore('imMessageStore', {
})
this.messagesByConversation = {}
this.loadedConversationKeys = []
this.privateReadMaxIds = {}
this.privateMessageMaxId = 0
this.groupMessageMaxId = 0
this.channelMessageMaxId = 0
@@ -305,6 +307,30 @@ export const useMessageStore = defineStore('imMessageStore', {
}
},
/** 获取私聊对方已读位置缓存 */
getPrivateReadMaxId(peerId: number): number | undefined {
return this.privateReadMaxIds[peerId]
},
/** 更新私聊对方已读位置缓存 */
updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): 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 = [
@@ -797,6 +823,7 @@ export const useMessageStore = defineStore('imMessageStore', {
const changed: Message[] = []
// 1. 私聊回执批量更新自己发送的消息
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
const privateReadMaxId = options.privateReadMaxId
messages.forEach((message) => {
if (

View File

@@ -17,6 +17,10 @@ import {
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
type GroupActiveCallCache = {
participantsLoaded?: boolean // 是否已拉取完整参与者列表
} & ImRtcApi.RtcGroupCallRespVO
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
status: ImRtcParticipantStatusValue
@@ -121,7 +125,7 @@ export const useRtcStore = defineStore('imRtc', () => {
}
/** 群活跃通话索引groupId -> 群通话摘要;用于群聊顶部胶囊条 */
const groupActiveCalls = ref<Map<number, ImRtcApi.RtcGroupCallRespVO>>(new Map())
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
/**
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
@@ -252,20 +256,51 @@ export const useRtcStore = defineStore('imRtc', () => {
* 房内成员同步交给 LiveKit 客户端事件ParticipantConnected / Disconnected
* 胶囊条不实时刷新 joinedUserIds / inviteeIds展开 / 加入时再走 getActiveCall 接口拉最新
*/
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO) {
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, 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: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
@@ -285,12 +320,7 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId || !groupActiveCalls.value.has(groupId)) {
return
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.delete(groupId)
groupActiveCalls.value = newGroupActiveCalls
clearGroupCallCache(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
@@ -371,6 +401,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,
@@ -394,6 +428,8 @@ export const useRtcStore = defineStore('imRtc', () => {
markUserLeft,
isUserLeft,
setGroupCall,
isGroupCallParticipantsLoaded,
clearGroupCallCache,
removeGroupCall,
getGroupCall,
applyParticipantConnected,

View File

@@ -42,6 +42,7 @@ import {
} from '../../utils/constants'
import {
getPrivateMessagePeerId,
parseRtcCallPayload,
playAudioTip,
resolveCallEndReasonText
} from '../../utils/message'
@@ -462,14 +463,30 @@ 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((error) => {
console.warn('[IM WS] 频道自动已读上报失败', error)
})
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
)
if (!readCovered) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
{
conversationType: ImConversationType.CHANNEL,
channelId: websocketMessage.channelId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音
playAudioTip()
@@ -567,7 +584,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
break
}
case ImContentType.RTC_CALL_START: {
// 入库 + 渲染聊天 tip胶囊条状态走 1602/1603本帧不动 rtcStore避免与首次填充竞争
// 入库 + 渲染聊天 tip同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
this.handleRtcCallStart(websocketMessage)
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
break
}
@@ -660,15 +678,29 @@ 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((error) => {
console.warn('[IM WS] 自动已读上报失败', error)
})
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
{
conversationType: ImConversationType.PRIVATE,
peerId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTipFRIEND_* 等系统事件不响
@@ -799,17 +831,29 @@ 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(
(error) => {
console.warn('[IM WS] 自动已读上报失败', error)
}
)
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.catch((error) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',
{
conversationType: ImConversationType.GROUP,
groupId: websocketMessage.groupId,
messageId: websocketMessage.id
},
error
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// GROUP_* 群广播事件等系统消息不响提示音
@@ -1129,6 +1173,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 区分
* <p>

View File

@@ -194,12 +194,13 @@ export interface Group {
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'members' | 'membersExpired' | 'membersLoaded'>
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
// 群成员实体(前端内部结构)
export interface GroupMember {

View File

@@ -516,7 +516,8 @@ export async function stopRequests(): Promise<void> {
{ useGroupStoreWithOut },
{ useChannelStoreWithOut },
{ useGroupRequestStoreWithOut },
{ useFaceStoreWithOut }
{ useFaceStoreWithOut },
{ useRtcStore }
] = await Promise.all([
import('../home/store/messageStore'),
import('../home/store/conversationStore'),
@@ -524,7 +525,8 @@ export async function stopRequests(): Promise<void> {
import('../home/store/groupStore'),
import('../home/store/channelStore'),
import('../home/store/groupRequestStore'),
import('../home/store/faceStore')
import('../home/store/faceStore'),
import('../home/store/rtcStore')
])
useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear()
@@ -533,5 +535,7 @@ export async function stopRequests(): Promise<void> {
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().clear()
useFaceStoreWithOut().clear()
useRtcStore().reset()
useRtcStore().clearGroupCallCache()
closeDbConnection()
}

View File

@@ -700,10 +700,12 @@ export type GroupNotificationPayload = {
mutedUserId?: number
muteEndTime?: string
newAvatar?: string
newJoinApproval?: boolean
newName?: string
newNotice?: string
newOwnerUserId?: number
oldAvatar?: string
oldJoinApproval?: boolean
oldName?: string
oldNotice?: string
operatorUserId?: number