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
parent
c0ead15bc3
commit
09b97f1e04
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ export const useMessagePuller = () => {
|
|||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupActiveCallsExpired()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
|
|
|
|||
|
|
@ -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] 标记已读失败',
|
||||
|
|
|
|||
|
|
@ -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(服务端直接过滤未处理,语义精准、启动轻);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
||||
// ==================== 最近转发 ====================
|
||||
|
||||
/** 推送最近转发会话 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 查询 */
|
||||
|
|
|
|||
|
|
@ -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] 群聊自动已读上报失败',
|
||||
|
|
|
|||
|
|
@ -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 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ export const useMessagePuller = () => {
|
|||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupActiveCallsExpired()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
|
|
|
|||
|
|
@ -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] 标记已读失败',
|
||||
|
|
|
|||
|
|
@ -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(服务端直接过滤未处理,语义精准、启动轻);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
||||
// ==================== 最近转发 ====================
|
||||
|
||||
/** 推送最近转发会话 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 查询 */
|
||||
|
|
|
|||
|
|
@ -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] 群聊自动已读上报失败',
|
||||
|
|
|
|||
|
|
@ -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 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ export const useMessagePuller = () => {
|
|||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupActiveCallsExpired()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
|
|
|
|||
|
|
@ -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] 标记已读失败',
|
||||
|
|
|
|||
|
|
@ -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(服务端直接过滤未处理,语义精准、启动轻);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
||||
// ==================== 最近转发 ====================
|
||||
|
||||
/** 推送最近转发会话 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 查询 */
|
||||
|
|
|
|||
|
|
@ -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] 群聊自动已读上报失败',
|
||||
|
|
|
|||
|
|
@ -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 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue