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

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

View File

@ -101,15 +101,18 @@ const pillText = computed(() => {
watch(
() => [props.groupId, activeCall.value?.room] as const,
async ([groupId, room], oldValues) => {
if (!groupId) {
if (!groupId || !activeCall.value) {
return
}
// / room room >= 2
//
const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room
const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
if (!groupChanged && !roomChanged && hydrated) {
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded)
) {
return
}
@ -117,7 +120,7 @@ watch(
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data)
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}

View File

@ -6,6 +6,7 @@ import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
import { useGroupStore } from '../store/groupStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
import { useRtcStore } from '../store/rtcStore'
import {
pullPrivateMessageList as apiPullPrivateMessageList,
getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
@ -58,6 +59,7 @@ export const useMessagePuller = () => {
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const rtcStore = useRtcStore()
const currentUserId = getCurrentUserId()
/** 判断请求是否被主动取消 */
@ -290,7 +292,12 @@ export const useMessagePuller = () => {
* cache groupId
*/
const pullStateEvents = async (): Promise<void> => {
// 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache()
groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态
const results = await Promise.allSettled([
friendStore.pullFriends(),
friendStore.pullFriendRequests(),
@ -408,6 +415,7 @@ export const useMessagePuller = () => {
if (!isCurrentPull()) {
return
}
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
if (maxReadId) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,

View File

@ -55,7 +55,7 @@ interface SendExtOptions {
* 1. / conversation.type
* 2. insertMessage SENDING ackMessage NORMAL FAILED
* 3. WebSocket RECALL websocketStore 退
* 4.
* 4.
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
@ -221,7 +221,7 @@ export const useMessageSender = () => {
/**
* /
* 1.
* 1.
* 2. id
*/
const readActive = async () => {
@ -237,15 +237,24 @@ export const useMessageSender = () => {
0
)
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered(
conversation.type,
conversation.targetId,
maxMessageId
)
if (readCovered) {
conversationStore.markConversationRead(conversation.type, conversation.targetId)
return
}
const isPrivate = conversation.type === ImConversationType.PRIVATE
const isGroup = conversation.type === ImConversationType.GROUP
const isChannel = conversation.type === ImConversationType.CHANNEL
// 本地标记已读未读数清零UI 立刻响应)
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
if (!maxMessageId) {
return
}
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
const isPrivate = conversation.type === ImConversationType.PRIVATE
const isGroup = conversation.type === ImConversationType.GROUP
const isChannel = conversation.type === ImConversationType.CHANNEL
// 接口调用:按会话类型分发,并按对应已读开关控制
if (!isPrivate && !isGroup && !isChannel) {
return
}
@ -287,9 +296,21 @@ export const useMessageSender = () => {
if (!MESSAGE_PRIVATE_READ_ENABLED) {
return
}
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
if (cachedMaxReadId !== undefined) {
if (cachedMaxReadId > 0) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,
privateReadMaxId: cachedMaxReadId
})
}
return
}
try {
// 拉取对方已读到的最大消息 id
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
if (!maxReadId) {
return
}

View File

@ -41,6 +41,7 @@ import { useGroupStore } from './store/groupStore'
import { useGroupRequestStore } from './store/groupRequestStore'
import { useFaceStore } from './store/faceStore'
import { useChannelStore } from './store/channelStore'
import { useRtcStore } from './store/rtcStore'
import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
@ -65,6 +66,7 @@ const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const faceStore = useFaceStore()
const channelStore = useChannelStore()
const rtcStore = useRtcStore()
const { pullOnce, cancelPull } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
const voicePlayer = useVoicePlayer()
@ -173,10 +175,12 @@ function onBeforeUnload() {
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
/** 离开 IM 主壳:取消 pull、断开 WebSocket、清理 RTC、保存草稿、停止语音、解绑 unload并结束当前 IM session */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()
rtcStore.reset()
rtcStore.clearGroupCallCache()
conversationStore.flushConversationDraftSave()
faceStore.clear()
// audio

View File

@ -459,7 +459,7 @@ async function ensureGroupData(groupId: number) {
})
const group = groupStore.getGroup(groupId)
if (!group?.membersLoaded || group.membersExpired) {
groupStore.fetchGroupMemberList(groupId, true).catch((error) => {
groupStore.fetchGroupMemberList(groupId).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error)
})
}
@ -471,7 +471,7 @@ function reloadGroupData() {
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
}
groupStore.fetchGroupInfo(conversation.targetId)
groupStore.fetchGroupInfo(conversation.targetId, true)
groupStore.fetchGroupMemberList(conversation.targetId, true)
}

View File

@ -30,6 +30,20 @@ const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
/** 创建会话读位置记录 */
function createConversationRead(
type: number,
targetId: number,
messageId: number
): ConversationRead {
return {
conversationType: type,
targetId,
messageId,
updateTime: Date.now()
}
}
/** 会话转 IndexedDB 记录 */
function toConversationDO(conversation: Conversation): ConversationDO {
const draft = conversation.draft
@ -302,6 +316,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && message.id <= record.messageId
},
/** 判断会话读位置是否覆盖消息编号 */
isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const record = this.getConversationRead(type, targetId)
return !!record && record.messageId >= messageId
},
/** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -652,7 +675,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 标记会话已读 */
markConversationRead(type: number, targetId: number, messageId?: number) {
markConversationRead(type: number, targetId: number, messageId?: number): void {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
@ -672,19 +695,25 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.atMe = false
conversation.atAll = false
if (readMessageIdAdvanced) {
const record = {
conversationType: type,
targetId,
messageId,
updateTime: Date.now()
}
const record = createConversationRead(type, targetId, messageId)
this.conversationReads[key] = record
void getDb()
.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
await this.saveConversationRecord(conversation, tx)
await this.saveConversationReadRecord(record, tx)
})
.catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e))
.catch((e) =>
console.warn(
'[IM conversationStore] 会话已读写入失败',
{
conversationType: type,
targetId,
messageId,
conversationKey: key
},
e
)
)
return
}
this.saveConversation(conversation)

View File

@ -51,6 +51,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const {
infoLoaded: _infoLoaded,
members: _members,
membersLoaded: _membersLoaded,
membersExpired: _membersExpired,
@ -232,10 +233,11 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id)
if (!existing) {
return group
return { ...group, infoLoaded: true }
}
return {
...group,
infoLoaded: true,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded,
@ -272,6 +274,13 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 失效全部群详情缓存 */
markAllGroupInfoExpired() {
for (const group of this.groups) {
group.infoLoaded = false
}
},
/** 失效全部群成员缓存 */
markAllGroupMembersExpired() {
this.groupMembersExpired = true
@ -291,13 +300,17 @@ export const useGroupStore = defineStore('imGroupStore', {
},
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert常用于 GROUP_UPDATE 推送后或手动 reload */
async fetchGroupInfo(groupId: number) {
async fetchGroupInfo(groupId: number, force = false) {
const cached = this.getGroup(groupId)
if (cached?.infoLoaded && !force) {
return
}
try {
const data = await apiGetGroup(groupId)
if (!data) {
return
}
this.upsertGroup(convertGroup(data))
this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
} catch (e) {
console.warn('[IM groupStore] fetchGroupInfo 失败', e)
}
@ -709,7 +722,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (selfIsOperator && this.getGroup(groupId)) {
return
}
await this.fetchGroupInfo(groupId)
await this.fetchGroupInfo(groupId, true)
},
/** 群名变更:按 newName 局部更新本地群名 */
@ -724,12 +737,15 @@ export const useGroupStore = defineStore('imGroupStore', {
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
},
/** 群信息变更NAME / NOTICE 之外字段,当前承载头像变更) */
/** 群信息变更:同步头像、进群审批 */
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
const fields: Partial<Group> = {}
if (payload.newAvatar) {
fields.avatar = payload.newAvatar
}
if (payload.newJoinApproval != null) {
fields.joinApproval = payload.newJoinApproval
}
if (Object.keys(fields).length > 0) {
this.updateGroupFields(groupId, fields)
}
@ -739,7 +755,7 @@ export const useGroupStore = defineStore('imGroupStore', {
async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups否则 fetchGroupMemberList 的 guard 会兜空
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
await this.fetchGroupInfo(groupId)
await this.fetchGroupInfo(groupId, true)
}
this.markGroupMembersExpired(groupId)
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
@ -750,7 +766,7 @@ export const useGroupStore = defineStore('imGroupStore', {
const selfUserId = getCurrentUserId()
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups否则 fetchGroupMemberList 的 guard 会兜空
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
await this.fetchGroupInfo(groupId)
await this.fetchGroupInfo(groupId, true)
}
this.markGroupMembersExpired(groupId)
this.fetchGroupMemberList(groupId, true).catch(() => undefined)

View File

@ -241,6 +241,7 @@ export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record<string, Message[]>,
loadedConversationKeys: [] as string[],
privateReadMaxIds: {} as Partial<Record<number, number>>,
privateMessageMaxId: 0,
groupMessageMaxId: 0,
channelMessageMaxId: 0
@ -265,6 +266,7 @@ export const useMessageStore = defineStore('imMessageStore', {
})
this.messagesByConversation = {}
this.loadedConversationKeys = []
this.privateReadMaxIds = {}
this.privateMessageMaxId = 0
this.groupMessageMaxId = 0
this.channelMessageMaxId = 0
@ -304,6 +306,30 @@ export const useMessageStore = defineStore('imMessageStore', {
}
},
/** 获取私聊对方已读位置缓存 */
getPrivateReadMaxId(peerId: number): number | undefined {
return this.privateReadMaxIds[peerId]
},
/** 更新私聊对方已读位置缓存 */
updatePrivateReadMaxId(peerId: number, maxReadId?: number | null): number {
if (!peerId) {
return 0
}
const nextMaxReadId = maxReadId || 0
const current = this.getPrivateReadMaxId(peerId)
if (current !== undefined && nextMaxReadId <= current) {
return current
}
this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
return nextMaxReadId
},
/** 清空私聊对方已读位置缓存 */
clearPrivateReadMaxIdCache(): void {
this.privateReadMaxIds = {}
},
/** 标记会话近期使用 */
touchConversationMessageCache(clientConversationId: string) {
this.loadedConversationKeys = [
@ -790,6 +816,7 @@ export const useMessageStore = defineStore('imMessageStore', {
const changed: Message[] = []
// 1. 私聊回执批量更新自己发送的消息
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
messages.forEach((message) => {
if (
message.selfSend &&

View File

@ -14,6 +14,10 @@ import { getCurrentUserId } from '@/utils/auth'
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
type GroupActiveCallCache = ImRtcGroupCallRespVO & {
participantsLoaded?: boolean // 是否已拉取完整参与者列表
}
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
status: ImRtcParticipantStatusValue
@ -118,7 +122,7 @@ export const useRtcStore = defineStore('imRtc', () => {
}
/** 群活跃通话索引groupId -> 群通话摘要;用于群聊顶部胶囊条 */
const groupActiveCalls = ref<Map<number, ImRtcGroupCallRespVO>>(new Map())
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
/**
* 退 / pending
@ -249,20 +253,51 @@ export const useRtcStore = defineStore('imRtc', () => {
* LiveKit ParticipantConnected / Disconnected
* joinedUserIds / inviteeIds / getActiveCall
*/
function setGroupCall(payload: ImRtcGroupCallRespVO) {
function setGroupCall(payload: ImRtcGroupCallRespVO, participantsLoaded?: boolean) {
if (!payload?.groupId) {
return
}
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId)
if (existing && isSameGroupCall(existing, payload)) {
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
if (
existing &&
isSameGroupCall(existing, payload) &&
!!existing.participantsLoaded === nextParticipantsLoaded
) {
return
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.set(payload.groupId, payload)
newGroupActiveCalls.set(payload.groupId, {
...payload,
participantsLoaded: nextParticipantsLoaded
})
groupActiveCalls.value = newGroupActiveCalls
}
/** 清空指定群的通话缓存 */
function clearGroupCallCache(groupId?: number) {
if (!groupId) {
groupActiveCalls.value = new Map()
return
}
const next = new Map(groupActiveCalls.value)
next.delete(groupId)
groupActiveCalls.value = next
}
/** 判断群通话是否已补齐 */
function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
const call = groupActiveCalls.value.get(groupId)
return (
!!groupId &&
!!room &&
!!call &&
call.room === room &&
!!call.participantsLoaded
)
}
/** 两条群通话摘要内容相等room / mediaType / inviterId / 两个 userId 数组逐项相等) */
function isSameGroupCall(a: ImRtcGroupCallRespVO, b: ImRtcGroupCallRespVO): boolean {
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
@ -274,12 +309,10 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) {
if (!groupId || !groupActiveCalls.value.has(groupId)) {
if (!groupId) {
return
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.delete(groupId)
groupActiveCalls.value = newGroupActiveCalls
clearGroupCallCache(groupId)
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
@ -360,6 +393,10 @@ export const useRtcStore = defineStore('imRtc', () => {
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
return
}
if (nextJoined.length === 0 && nextInvitee.length === 0) {
removeGroupCall(groupId)
return
}
setGroupCall({
...existing,
joinedUserIds: nextJoined,
@ -385,6 +422,8 @@ export const useRtcStore = defineStore('imRtc', () => {
setGroupCall,
removeGroupCall,
getGroupCall,
isGroupCallParticipantsLoaded,
clearGroupCallCache,
applyParticipantConnected,
applyParticipantDisconnected,
applyParticipantRejected,

View File

@ -17,6 +17,7 @@ import {
} from '../../utils/constants'
import {
getPrivateMessagePeerId,
parseRtcCallPayload,
playAudioTip,
resolveCallEndReasonText
} from '../../utils/message'
@ -424,14 +425,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
)
if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
conversationStore.markConversationRead(
const readCovered = conversationStore.isReadPositionCovered(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 频道自动已读上报失败', e)
})
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id
)
if (!readCovered) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.catch((e) => {
console.warn(
'[IM WS] 频道自动已读上报失败',
{
conversationType: ImConversationType.CHANNEL,
channelId: websocketMessage.channelId,
messageId: websocketMessage.id,
messageType: websocketMessage.type
},
e
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音
playAudioTip()
@ -515,7 +533,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
this.handleGroupMemberNicknameUpdate(websocketMessage)
break
case ImContentType.RTC_CALL_START:
// 入库 + 渲染聊天 tip胶囊条状态走 1602/1603本帧不动 rtcStore避免与首次填充竞争
// 入库 + 渲染聊天 tip同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
this.handleRtcCallStart(websocketMessage)
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
break
case ImContentType.RTC_CALL_END:
@ -611,15 +630,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
conversationStore.markConversationRead(
const readCovered = conversationStore.isReadPositionCovered(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED) {
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
})
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
apiReadPrivateMessages(peerId, websocketMessage.id)
.catch((e) => {
console.warn(
'[IM WS] 私聊自动已读上报失败',
{
conversationType: ImConversationType.PRIVATE,
peerId,
messageId: websocketMessage.id,
messageType: websocketMessage.type,
senderId: websocketMessage.senderId
},
e
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTipFRIEND_* 等系统事件不响
@ -713,7 +748,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底)
const group = groupStore.getGroup(websocketMessage.groupId)
if (!group) {
groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
groupStore.fetchGroupInfo(websocketMessage.groupId, true).catch(() => undefined)
}
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`
@ -750,15 +785,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
conversationStore.markConversationRead(
const readCovered = conversationStore.isReadPositionCovered(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
})
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.catch((e) => {
console.warn(
'[IM WS] 群聊自动已读上报失败',
{
conversationType: ImConversationType.GROUP,
groupId: websocketMessage.groupId,
messageId: websocketMessage.id,
messageType: websocketMessage.type,
senderId: websocketMessage.senderId
},
e
)
})
}
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
// GROUP_* 群广播事件等系统消息不响提示音
@ -1063,6 +1114,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}
},
/** RTC_CALL_START 通话开始 */
handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
const payload = parseRtcCallPayload(websocketMessage.content)
if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
console.warn('[IM WS] RTC_CALL_START payload 不合法', {
groupId: websocketMessage.groupId,
messageId: websocketMessage.id,
contentLength: websocketMessage.content?.length ?? 0
})
return
}
useRtcStore().setGroupCall({
room: payload.room,
groupId: websocketMessage.groupId,
mediaType: payload.mediaType,
inviterId: payload.inviterUserId,
joinedUserIds: [payload.inviterUserId],
inviteeIds: []
})
},
/**
* RTC_CALL_END + payload conversationType
* <p>

View File

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

View File

@ -1,25 +1,30 @@
<template>
<el-row :gutter="16">
<el-col v-for="card in cards" :key="card.title" :xl="6" :lg="6" :md="12" :sm="24" :xs="24">
<el-card shadow="never" class="!rounded-8px mb-16px">
<div class="flex items-center">
<div
class="w-48px h-48px rounded-8px flex items-center justify-center mr-12px flex-shrink-0"
:style="{ backgroundColor: card.color }"
>
<Icon :icon="card.icon" :size="24" color="#fff" />
</div>
<div class="flex-1 min-w-0">
<div class="text-13px text-[var(--el-text-color-secondary)] mb-4px">{{ card.title }}</div>
<div class="text-22px font-600 text-[var(--el-text-color-primary)] leading-none">
<CountTo :start-val="0" :end-val="card.value" :duration="1500" />
<span v-if="card.suffix" class="text-12px text-[var(--el-text-color-placeholder)] ml-6px font-normal">{{ card.suffix }}</span>
<el-card shadow="never" class="kpi-card !rounded-8px mb-16px">
<el-skeleton :loading="loading" :rows="2" animated>
<div class="flex items-center">
<div class="kpi-card__icon mr-14px" :style="{ background: card.gradient }">
<Icon :icon="card.icon" :size="24" color="#fff" />
</div>
<div class="text-12px text-[var(--el-text-color-placeholder)] mt-6px">{{ card.metaLabel }}
<span :class="card.metaClass">{{ card.metaValue }}</span>
<div class="min-w-0 flex-1">
<div class="text-13px text-[var(--el-text-color-secondary)]">{{ card.title }}</div>
<div class="mt-6px text-24px font-600 leading-none text-[var(--el-text-color-primary)]">
<CountTo :start-val="0" :end-val="card.value" :duration="1500" />
<span
v-if="card.suffix"
class="ml-6px text-12px font-normal text-[var(--el-text-color-placeholder)]"
>
{{ card.suffix }}
</span>
</div>
<div class="mt-8px flex items-center text-12px text-[var(--el-text-color-placeholder)]">
<span>{{ card.metaLabel }}</span>
<span class="ml-6px font-500" :class="card.metaClass">{{ card.metaValue }}</span>
</div>
</div>
</div>
</div>
</el-skeleton>
</el-card>
</el-col>
</el-row>
@ -30,7 +35,13 @@ import type { ImStatisticsOverviewVO } from '@/api/im/manager/statistics'
defineOptions({ name: 'ImStatisticsOverviewCards' })
const props = defineProps<{ overview: ImStatisticsOverviewVO }>()
const props = withDefaults(
defineProps<{ overview?: ImStatisticsOverviewVO; loading?: boolean }>(),
{ loading: false }
)
// loading overview
const o = computed(() => props.overview ?? ({} as ImStatisticsOverviewVO))
const calcRatio = (today: number, yesterday: number): { label: string; cls: string } => {
if (!yesterday) return { label: '无昨日数据', cls: 'text-gray-400' }
@ -43,44 +54,44 @@ const calcRatio = (today: number, yesterday: number): { label: string; cls: stri
}
const cards = computed(() => {
const o = props.overview
const totalMsgToday = (o.privateMessageToday ?? 0) + (o.groupMessageToday ?? 0)
const totalMsgYesterday = (o.privateMessageYesterday ?? 0) + (o.groupMessageYesterday ?? 0)
const v = o.value
const totalMsgToday = (v.privateMessageToday ?? 0) + (v.groupMessageToday ?? 0)
const totalMsgYesterday = (v.privateMessageYesterday ?? 0) + (v.groupMessageYesterday ?? 0)
const msgRatio = calcRatio(totalMsgToday, totalMsgYesterday)
return [
{
title: '总用户',
value: o.totalUser ?? 0,
value: v.totalUser ?? 0,
icon: 'ep:user',
color: '#409EFF',
gradient: 'linear-gradient(135deg, #5b9cff 0%, #409eff 100%)',
metaLabel: '今日新增',
metaValue: `+${o.newUserToday ?? 0}`,
metaValue: `+${v.newUserToday ?? 0}`,
metaClass: 'text-green-500'
},
{
title: '总群组',
value: o.totalGroup ?? 0,
value: v.totalGroup ?? 0,
icon: 'ep:chat-dot-round',
color: '#67C23A',
gradient: 'linear-gradient(135deg, #5bd6a0 0%, #67c23a 100%)',
metaLabel: '今日新增',
metaValue: `+${o.newGroupToday ?? 0}`,
metaValue: `+${v.newGroupToday ?? 0}`,
metaClass: 'text-green-500'
},
{
title: '日活用户',
value: o.activeUserDaily ?? 0,
value: v.activeUserDaily ?? 0,
icon: 'ep:timer',
color: '#E6A23C',
metaLabel: '周/月活',
metaValue: `${o.activeUserWeekly ?? 0} / ${o.activeUserMonthly ?? 0}`,
gradient: 'linear-gradient(135deg, #ffc46b 0%, #e6a23c 100%)',
metaLabel: '周 / 月活',
metaValue: `${v.activeUserWeekly ?? 0} / ${v.activeUserMonthly ?? 0}`,
metaClass: 'text-gray-500'
},
{
title: '今日消息',
value: totalMsgToday,
suffix: ` (P ${o.privateMessageToday}/G ${o.groupMessageToday})`,
suffix: `(私 ${v.privateMessageToday ?? 0} / 群 ${v.groupMessageToday ?? 0})`,
icon: 'ep:message',
color: '#909399',
gradient: 'linear-gradient(135deg, #b794f6 0%, #805ad5 100%)',
metaLabel: '环比昨日',
metaValue: msgRatio.label,
metaClass: msgRatio.cls
@ -88,3 +99,26 @@ const cards = computed(() => {
]
})
</script>
<style lang="scss" scoped>
.kpi-card {
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: var(--el-box-shadow-light);
}
&__icon {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
}
}
</style>

View File

@ -1,9 +1,15 @@
<template>
<div class="p-16px">
<!-- 概览卡片 -->
<OverviewCards v-if="overview" :overview="overview" />
<div class="im-statistics">
<!-- 页头 -->
<div class="im-statistics__hero">
<div class="im-statistics__hero-title">IM 数据看板</div>
<div class="im-statistics__hero-desc">用户群组与消息的整体运营概览</div>
</div>
<!-- 趋势 -->
<!-- 概览卡片 -->
<OverviewCards :overview="overview" :loading="overviewLoading" />
<!-- 趋势消息 + 用户 -->
<el-row :gutter="16">
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24">
<MessageTrendChart />
@ -13,7 +19,7 @@
</el-col>
</el-row>
<!-- 分布 -->
<!-- 分布内容类型 + 群规模 + TOP 发送者 -->
<el-row :gutter="16">
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
<MessageTypeChart />
@ -41,8 +47,38 @@ defineOptions({ name: 'ImStatistics' })
// /
const overview = ref<StatisticsApi.ImStatisticsOverviewVO>()
const overviewLoading = ref(false)
onMounted(async () => {
overview.value = await StatisticsApi.getStatisticsOverview()
overviewLoading.value = true
try {
overview.value = await StatisticsApi.getStatisticsOverview()
} finally {
overviewLoading.value = false
}
})
</script>
<style lang="scss" scoped>
.im-statistics {
padding: 16px;
&__hero {
margin-bottom: 16px;
padding: 4px 2px;
}
&__hero-title {
font-size: 18px;
font-weight: 600;
line-height: 1.3;
color: var(--el-text-color-primary);
}
&__hero-desc {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@ -677,6 +677,8 @@ export type GroupNotificationPayload = {
newNotice?: string
oldAvatar?: string
newAvatar?: string
oldJoinApproval?: boolean
newJoinApproval?: boolean
displayUserName?: string
messageId?: number
// 禁言事件