feat(im):同步已读上报补偿与群通话探测优化

- web-antd、web-antdv-next、web-ele 同步会话 readMessageId 逻辑
- readActive 与 WebSocket 自动已读改为基于服务端已上报读位置判断是否跳过接口
- read 接口成功后同步 readMessageId,失败时保留本端已读体验并允许后续重新进入补上报
- 拉取服务端 read 进度时同步更新会话 readMessageId,同时保持本地读位置单调合并
- 三端同步 activeCallLoaded / activeCallExpired 群通话探测状态
- 首登与重连时失效群通话探测缓存,本地无通话且探测过期时懒加载 getActiveCall
- 群通话写入或移除时标记探测已加载,并避免通话探测状态写入 IndexedDB
- 为 IndexedDB DO 类型补充存储结构注释,并修复 conversationStore lint 写法
pull/367/head
YunaiV 2026-06-19 11:05:19 -07:00
parent c0ead15bc3
commit 09b97f1e04
27 changed files with 464 additions and 148 deletions

View File

@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user'
@ -21,6 +22,7 @@ const props = defineProps<{
}>()
const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false)
@ -40,24 +42,48 @@ const pillText = computed(() => {
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/
watch(
() => [props.groupId, activeCall.value?.room] as const,
() =>
[
props.groupId,
activeCall.value?.room,
groupStore.isGroupActiveCallExpired(props.groupId)
] as const,
async ([groupId, room], oldValues) => {
if (!groupId || !activeCall.value) {
if (!groupId) {
return
}
//
if (!activeCall.value) {
if (!groupStore.isGroupActiveCallExpired(groupId)) {
return
}
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
return
}
//
const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded)
!activeCallExpired &&
(rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded))
) {
return
}
// store
// store
try {
const data = await getActiveCall(groupId)
if (data) {

View File

@ -290,6 +290,7 @@ export const useMessagePuller = () => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态

View File

@ -240,12 +240,12 @@ export const useMessageSender = () => {
}
}
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type,
conversation.targetId,
maxMessageId
)
if (readCovered) {
if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId)
return
}
@ -275,6 +275,11 @@ export const useMessageSender = () => {
} else {
await apiReadChannelMessages(conversation.targetId, maxMessageId)
}
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) {
console.error(
'[IM] 标记已读失败',

View File

@ -61,6 +61,7 @@ onMounted(async () => {
const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired()
childRouteReady.value = true
// 1.4 unhandled-list

View File

@ -27,7 +27,19 @@ import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
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) {
@ -75,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
lastReceiptStatus: conversation.lastReceiptStatus,
lastSelfSend: conversation.lastSelfSend,
lastSenderDisplayName: conversation.lastSenderDisplayName,
readMessageId: conversation.readMessageId,
deleted: conversation.deleted,
top: conversation.top,
silent: conversation.silent,
@ -86,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
}
/** IndexedDB 记录转会话 */
function fromConversationDO(conversation: LegacyConversationDO): Conversation {
function fromConversationDO(conversation: ConversationDO): Conversation {
const {
clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest
} = conversation
return rest
@ -207,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', {
const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
}
const migratedReads: ConversationRead[] = []
for (const conversation of conversations as LegacyConversationDO[]) {
if (!conversation.readMessageId) {
continue
}
const key = getClientConversationId(conversation.type, conversation.targetId)
if (nextConversationReads[key]) {
continue
}
const record = {
conversationType: conversation.type,
targetId: conversation.targetId,
messageId: conversation.readMessageId
}
nextConversationReads[key] = record
migratedReads.push(record)
}
const nextConversations = (conversations as LegacyConversationDO[]).map((conversation) =>
fromConversationDO(conversation)
)
const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((error) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', error)
)
}
if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
}
@ -344,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && record.messageId >= messageId
},
/** 判断服务端已读位置是否覆盖消息编号 */
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const conversation = this.getConversation(type, targetId)
return (conversation?.readMessageId || 0) >= messageId
},
/** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -397,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', {
}
const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0)
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && record.messageId > (conversation.readMessageId || 0)) {
conversation.readMessageId = record.messageId
changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) {
const next = {
conversationType: record.conversationType,
@ -408,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', {
changedReads.set(clientConversationId, next)
}
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation)
} else if (conversation) {
@ -690,7 +691,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 标记会话已读 */
markConversationRead(type: number, targetId: number, messageId?: number) {
markConversationRead(type: number, targetId: number, messageId?: number): void {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
@ -717,12 +718,36 @@ export const useConversationStore = defineStore('imConversationStore', {
await this.saveConversationRecord(conversation, tx)
await this.saveConversationReadRecord(record, tx)
})
.catch((error) => console.warn('[IM conversationStore] 会话已读写入失败', error))
.catch((error) =>
console.warn(
'[IM conversationStore] 会话已读写入失败',
{
conversationType: type,
targetId,
messageId,
conversationKey: key
},
error
)
)
return
}
this.saveConversation(conversation)
},
/** 标记会话已上报服务端读位置 */
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
if (!messageId) {
return
}
const conversation = this.getConversation(type, targetId)
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
return
}
conversation.readMessageId = messageId
this.saveConversation(conversation)
},
// ==================== 最近转发 ====================
/** 推送最近转发会话 */

View File

@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
activeCallExpired: _activeCallExpired,
activeCallLoaded: _activeCallLoaded,
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id)
if (!existing) {
return { ...group, infoLoaded: true }
return { ...group, activeCallExpired: true, infoLoaded: true }
}
return {
...group,
infoLoaded: true,
activeCallExpired: existing.activeCallExpired,
activeCallLoaded: existing.activeCallLoaded,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded,
@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
group.activeCallLoaded = true
group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
const group = this.getGroup(groupId)
return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) {
const group = this.getGroup(groupId)

View File

@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) {
return
}
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId)
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId) {
return
}
clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */

View File

@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
)
if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (!readCovered) {
if (!readReported) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',

View File

@ -110,6 +110,7 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员
readMessageId?: number // 已上报到服务端的最大已读消息编号
draft?: {
html: string // 输入框 HTML
plain: string // 输入框纯文本
@ -147,6 +148,7 @@ export interface Message {
// ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}`
}
@ -158,16 +160,19 @@ export interface ConversationRead {
updateTime?: number // 更新时间
}
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}`
}
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId
}
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> {
key: string
value: T
@ -195,12 +200,18 @@ export interface Group {
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
/** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
// 群成员实体(前端内部结构)
export interface GroupMember {

View File

@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user'
@ -21,6 +22,7 @@ const props = defineProps<{
}>()
const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false)
@ -40,24 +42,48 @@ const pillText = computed(() => {
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/
watch(
() => [props.groupId, activeCall.value?.room] as const,
() =>
[
props.groupId,
activeCall.value?.room,
groupStore.isGroupActiveCallExpired(props.groupId)
] as const,
async ([groupId, room], oldValues) => {
if (!groupId || !activeCall.value) {
if (!groupId) {
return
}
//
if (!activeCall.value) {
if (!groupStore.isGroupActiveCallExpired(groupId)) {
return
}
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
return
}
//
const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded)
!activeCallExpired &&
(rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded))
) {
return
}
// store
// store
try {
const data = await getActiveCall(groupId)
if (data) {

View File

@ -290,6 +290,7 @@ export const useMessagePuller = () => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态

View File

@ -240,12 +240,12 @@ export const useMessageSender = () => {
}
}
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type,
conversation.targetId,
maxMessageId
)
if (readCovered) {
if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId)
return
}
@ -275,6 +275,11 @@ export const useMessageSender = () => {
} else {
await apiReadChannelMessages(conversation.targetId, maxMessageId)
}
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) {
console.error(
'[IM] 标记已读失败',

View File

@ -61,6 +61,7 @@ onMounted(async () => {
const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired()
childRouteReady.value = true
// 1.4 unhandled-list

View File

@ -27,8 +27,6 @@ import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
/** 创建会话读位置记录 */
function createConversationRead(
type: number,
@ -89,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
lastReceiptStatus: conversation.lastReceiptStatus,
lastSelfSend: conversation.lastSelfSend,
lastSenderDisplayName: conversation.lastSenderDisplayName,
readMessageId: conversation.readMessageId,
deleted: conversation.deleted,
top: conversation.top,
silent: conversation.silent,
@ -100,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
}
/** IndexedDB 记录转会话 */
function fromConversationDO(conversation: LegacyConversationDO): Conversation {
function fromConversationDO(conversation: ConversationDO): Conversation {
const {
clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest
} = conversation
return rest
@ -221,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', {
const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
}
const migratedReads: ConversationRead[] = []
for (const conversation of conversations as LegacyConversationDO[]) {
if (!conversation.readMessageId) {
continue
}
const key = getClientConversationId(conversation.type, conversation.targetId)
if (nextConversationReads[key]) {
continue
}
const record = {
conversationType: conversation.type,
targetId: conversation.targetId,
messageId: conversation.readMessageId
}
nextConversationReads[key] = record
migratedReads.push(record)
}
const nextConversations = (conversations as LegacyConversationDO[]).map((conversation) =>
fromConversationDO(conversation)
)
const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((error) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', error)
)
}
if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
}
@ -358,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && record.messageId >= messageId
},
/** 判断服务端已读位置是否覆盖消息编号 */
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const conversation = this.getConversation(type, targetId)
return (conversation?.readMessageId || 0) >= messageId
},
/** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -411,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', {
}
const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0)
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && record.messageId > (conversation.readMessageId || 0)) {
conversation.readMessageId = record.messageId
changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) {
const next = {
conversationType: record.conversationType,
@ -422,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', {
changedReads.set(clientConversationId, next)
}
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation)
} else if (conversation) {
@ -748,6 +735,19 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversation(conversation)
},
/** 标记会话已上报服务端读位置 */
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
if (!messageId) {
return
}
const conversation = this.getConversation(type, targetId)
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
return
}
conversation.readMessageId = messageId
this.saveConversation(conversation)
},
// ==================== 最近转发 ====================
/** 推送最近转发会话 */

View File

@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
activeCallExpired: _activeCallExpired,
activeCallLoaded: _activeCallLoaded,
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id)
if (!existing) {
return { ...group, infoLoaded: true }
return { ...group, activeCallExpired: true, infoLoaded: true }
}
return {
...group,
infoLoaded: true,
activeCallExpired: existing.activeCallExpired,
activeCallLoaded: existing.activeCallLoaded,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded,
@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
group.activeCallLoaded = true
group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
const group = this.getGroup(groupId)
return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) {
const group = this.getGroup(groupId)

View File

@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) {
return
}
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId)
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId) {
return
}
clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */

View File

@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
)
if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (!readCovered) {
if (!readReported) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',

View File

@ -110,6 +110,7 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员
readMessageId?: number // 已上报到服务端的最大已读消息编号
draft?: {
html: string // 输入框 HTML
plain: string // 输入框纯文本
@ -147,6 +148,7 @@ export interface Message {
// ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}`
}
@ -158,16 +160,19 @@ export interface ConversationRead {
updateTime?: number // 更新时间
}
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}`
}
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId
}
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> {
key: string
value: T
@ -195,12 +200,18 @@ export interface Group {
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
/** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
// 群成员实体(前端内部结构)
export interface GroupMember {

View File

@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user'
@ -21,6 +22,7 @@ const props = defineProps<{
}>()
const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false)
@ -40,24 +42,48 @@ const pillText = computed(() => {
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/
watch(
() => [props.groupId, activeCall.value?.room] as const,
() =>
[
props.groupId,
activeCall.value?.room,
groupStore.isGroupActiveCallExpired(props.groupId)
] as const,
async ([groupId, room], oldValues) => {
if (!groupId || !activeCall.value) {
if (!groupId) {
return
}
//
if (!activeCall.value) {
if (!groupStore.isGroupActiveCallExpired(groupId)) {
return
}
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
return
}
//
const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded)
!activeCallExpired &&
(rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded))
) {
return
}
// store
// store
try {
const data = await getActiveCall(groupId)
if (data) {

View File

@ -290,6 +290,7 @@ export const useMessagePuller = () => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态

View File

@ -240,12 +240,12 @@ export const useMessageSender = () => {
}
}
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type,
conversation.targetId,
maxMessageId
)
if (readCovered) {
if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId)
return
}
@ -275,6 +275,11 @@ export const useMessageSender = () => {
} else {
await apiReadChannelMessages(conversation.targetId, maxMessageId)
}
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) {
console.error(
'[IM] 标记已读失败',

View File

@ -61,6 +61,7 @@ onMounted(async () => {
const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired()
childRouteReady.value = true
// 1.4 unhandled-list

View File

@ -27,8 +27,6 @@ import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
/** 创建会话读位置记录 */
function createConversationRead(
type: number,
@ -89,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
lastReceiptStatus: conversation.lastReceiptStatus,
lastSelfSend: conversation.lastSelfSend,
lastSenderDisplayName: conversation.lastSenderDisplayName,
readMessageId: conversation.readMessageId,
deleted: conversation.deleted,
top: conversation.top,
silent: conversation.silent,
@ -100,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
}
/** IndexedDB 记录转会话 */
function fromConversationDO(conversation: LegacyConversationDO): Conversation {
function fromConversationDO(conversation: ConversationDO): Conversation {
const {
clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest
} = conversation
return rest
@ -221,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', {
const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
}
const migratedReads: ConversationRead[] = []
for (const conversation of conversations as LegacyConversationDO[]) {
if (!conversation.readMessageId) {
continue
}
const key = getClientConversationId(conversation.type, conversation.targetId)
if (nextConversationReads[key]) {
continue
}
const record = {
conversationType: conversation.type,
targetId: conversation.targetId,
messageId: conversation.readMessageId
}
nextConversationReads[key] = record
migratedReads.push(record)
}
const nextConversations = (conversations as LegacyConversationDO[]).map((conversation) =>
fromConversationDO(conversation)
)
const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((error) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', error)
)
}
if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
}
@ -358,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && record.messageId >= messageId
},
/** 判断服务端已读位置是否覆盖消息编号 */
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const conversation = this.getConversation(type, targetId)
return (conversation?.readMessageId || 0) >= messageId
},
/** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -411,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', {
}
const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0)
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && record.messageId > (conversation.readMessageId || 0)) {
conversation.readMessageId = record.messageId
changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) {
const next = {
conversationType: record.conversationType,
@ -422,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', {
changedReads.set(clientConversationId, next)
}
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation)
} else if (conversation) {
@ -748,6 +735,19 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversation(conversation)
},
/** 标记会话已上报服务端读位置 */
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
if (!messageId) {
return
}
const conversation = this.getConversation(type, targetId)
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
return
}
conversation.readMessageId = messageId
this.saveConversation(conversation)
},
// ==================== 最近转发 ====================
/** 推送最近转发会话 */

View File

@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
activeCallExpired: _activeCallExpired,
activeCallLoaded: _activeCallLoaded,
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id)
if (!existing) {
return { ...group, infoLoaded: true }
return { ...group, activeCallExpired: true, infoLoaded: true }
}
return {
...group,
infoLoaded: true,
activeCallExpired: existing.activeCallExpired,
activeCallLoaded: existing.activeCallLoaded,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded,
@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
group.activeCallLoaded = true
group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
const group = this.getGroup(groupId)
return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) {
const group = this.getGroup(groupId)

View File

@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) {
return
}
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId)
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId) {
return
}
clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */

View File

@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
)
if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (!readCovered) {
if (!readReported) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
const readCovered = conversationStore.isReadPositionCovered(
const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
)
.catch((error) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',

View File

@ -110,6 +110,7 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员
readMessageId?: number // 已上报到服务端的最大已读消息编号
draft?: {
html: string // 输入框 HTML
plain: string // 输入框纯文本
@ -147,6 +148,7 @@ export interface Message {
// ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}`
}
@ -158,16 +160,19 @@ export interface ConversationRead {
updateTime?: number // 更新时间
}
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}`
}
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId
}
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> {
key: string
value: T
@ -195,12 +200,18 @@ export interface Group {
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'>
/** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
// 群成员实体(前端内部结构)
export interface GroupMember {