feat(im): 修一组细节:会话 silent 跟随新消息同步、合并末尾刷摘要 + 群 @ 标记、录音 1s 下限、邀请 reload 透 friendIds、pull 游标取最大 id

im
YunaiV 2026-05-21 01:13:29 +08:00
parent b63492199a
commit f7cda1fc4e
5 changed files with 80 additions and 35 deletions

View File

@ -118,7 +118,8 @@ export const useMessagePuller = () => {
type: ImConversationType.PRIVATE, type: ImConversationType.PRIVATE,
targetId, targetId,
name: friend ? getFriendDisplayName(friend) : String(targetId), // 会话列表 / 顶部标题展示:好友备注 > 真实昵称 name: friend ? getFriendDisplayName(friend) : String(targetId), // 会话列表 / 顶部标题展示:好友备注 > 真实昵称
avatar: friend?.avatar || '' avatar: friend?.avatar || '',
silent: friend?.silent
} }
} }
@ -129,11 +130,12 @@ export const useMessagePuller = () => {
type: ImConversationType.GROUP, type: ImConversationType.GROUP,
targetId: message.groupId, targetId: message.groupId,
name: group?.name || String(message.groupId), name: group?.name || String(message.groupId),
avatar: group?.avatar || '' avatar: group?.avatar || '',
silent: group?.silent
} }
} }
/** 循环拉取指定会话类型的消息:以列表最后一条 id 作为下次 minId直到接口返回空列表 */ /** 循环拉取指定会话类型的消息:以本批最大 id 作为下次 minId直到接口返回空列表或游标不再前进 */
const pullByType = async (conversationType: number, startMinId: number) => { const pullByType = async (conversationType: number, startMinId: number) => {
// 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度 // 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度
let minId = startMinId || 0 let minId = startMinId || 0
@ -208,11 +210,17 @@ export const useMessagePuller = () => {
} }
} }
// 游标推进到本批最后一条 id下一轮从此处续翻 // 游标推进到本批最大 id与后端返回顺序无关无有效 id 直接 break 避免死翻同一批
const lastId = list[list.length - 1].id const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
if (lastId != null) { if (validIds.length === 0) {
minId = lastId break
} }
const nextMinId = Math.max(...validIds)
// 游标没前进就停:当前后端契约是 id > minId理论不会出现防御后端契约变更或边界数据死翻
if (nextMinId <= minId) {
break
}
minId = nextMinId
} }
} }

View File

@ -371,7 +371,7 @@
<!-- ==================== 子对话框 ==================== --> <!-- ==================== 子对话框 ==================== -->
<!-- 邀请新成员 / 选成员移除 --> <!-- 邀请新成员 / 选成员移除 -->
<GroupMemberAddDialog ref="inviteDialogRef" @reload="$emit('reload')" /> <GroupMemberAddDialog ref="inviteDialogRef" @reload="(ids) => $emit('reload', ids)" />
<GroupMemberRemoveDialog ref="removeDialogRef" @reload="$emit('reload')" /> <GroupMemberRemoveDialog ref="removeDialogRef" @reload="$emit('reload')" />
<!-- 群主操作管理员设置一个弹窗合并增 / 提交时 diff+ 群主管理权转让 --> <!-- 群主操作管理员设置一个弹窗合并增 / 提交时 diff+ 群主管理权转让 -->
@ -432,7 +432,7 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
reload: [] // 邀请 / 移除 / 修改群资料后父组件重新拉群数据 reload: [friendIds?: number[]] // 邀请 / 移除 / 修改群资料后父组件重新拉群数据邀请新成员场景透出 friendIds 让上层可选精准刷新
'open-history': [] // "" MessageHistory 'open-history': [] // "" MessageHistory
}>() }>()

View File

@ -173,8 +173,13 @@ async function startRecord() {
}, 1000) }, 1000)
} }
/** 停止录制:进入 preview 阶段,由用户决定重录或发送 */ /** 停止录制:进入 preview 阶段,由用户决定重录或发送< 1s 视为误触,直接 warning + 回 idle 不进 preview */
function stopRecord() { function stopRecord() {
if (duration.value < 1) {
message.warning('录音时间太短')
resetAll()
return
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') { if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop() mediaRecorder.stop()
} }

View File

@ -97,6 +97,31 @@ function recomputeConversationLast(conversation: Conversation): void {
} }
} }
/**
* @ / / / @
*
* pull atUserIds WS
* conversation.atMe / atAll
*/
function syncConversationAtFlags(conversation: Conversation, message: Message): void {
if (
message.selfSend ||
conversation.type !== ImConversationType.GROUP ||
!message.atUserIds ||
message.atUserIds.length === 0 ||
message.status === ImMessageStatus.READ
) {
return
}
const currentUserId = getCurrentUserId()
if (currentUserId && message.atUserIds.includes(currentUserId)) {
conversation.atMe = true
}
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
conversation.atAll = true
}
}
/** /**
* REST ack / WS / pull * REST ack / WS / pull
* *
@ -369,12 +394,13 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
}, },
/** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用) */ /** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用);调用方传 silent 时按 friend / group store 的值落地,未传保持默认 false */
createEmptyConversation( createEmptyConversation(
type: number, type: number,
targetId: number, targetId: number,
name: string, name: string,
avatar: string avatar: string,
silent: boolean = false
): Conversation { ): Conversation {
return { return {
targetId, targetId,
@ -387,7 +413,7 @@ export const useConversationStore = defineStore('imConversationStore', {
messages: [], messages: [],
deleted: false, deleted: false,
top: false, top: false,
silent: false, silent,
atMe: false, atMe: false,
atAll: false atAll: false
} }
@ -461,7 +487,13 @@ export const useConversationStore = defineStore('imConversationStore', {
* 4. + * 4. +
*/ */
insertMessage( insertMessage(
conversationInfo: { type: number; targetId: number; name: string; avatar: string }, conversationInfo: {
type: number
targetId: number
name: string
avatar: string
silent?: boolean // 调用方按 friend / group store 当前 silent 传入;未传不动会话已有值
},
messageInfo: Message messageInfo: Message
) { ) {
// 0. 群广播事件旁路:按 type 局部更新 groupStore 的 role / ownerUserId / 成员列表等状态 // 0. 群广播事件旁路:按 type 局部更新 groupStore 的 role / ownerUserId / 成员列表等状态
@ -477,19 +509,26 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
// 1.1 查找或自动创建会话命中软删会话需要复活场景A 退群后被重新拉入、用户主动删了对话又收到新消息) // 1.1 查找或自动创建会话命中软删会话需要复活场景A 退群后被重新拉入、用户主动删了对话又收到新消息)
// silent 跟随调用方:新建写入;复活 / 已有会话仅在调用方明确传值时覆盖,避免本地 silent 与 friend / group store 漂移
let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId) let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId)
if (!conversation) { if (!conversation) {
conversation = this.createEmptyConversation( conversation = this.createEmptyConversation(
conversationInfo.type, conversationInfo.type,
conversationInfo.targetId, conversationInfo.targetId,
conversationInfo.name, conversationInfo.name,
conversationInfo.avatar conversationInfo.avatar,
conversationInfo.silent
) )
this.conversations.unshift(conversation) this.conversations.unshift(conversation)
} else if (conversation.deleted) { } else if (conversation.deleted) {
conversation.deleted = false conversation.deleted = false
conversation.name = conversationInfo.name || conversation.name conversation.name = conversationInfo.name || conversation.name
conversation.avatar = conversationInfo.avatar || conversation.avatar conversation.avatar = conversationInfo.avatar || conversation.avatar
if (conversationInfo.silent !== undefined) {
conversation.silent = conversationInfo.silent
}
} else if (conversationInfo.silent !== undefined) {
conversation.silent = conversationInfo.silent
} }
// 1.2 去重合并:优先按 id其次按 clientMessageId。命中则覆盖更新并返回 // 1.2 去重合并:优先按 id其次按 clientMessageId。命中则覆盖更新并返回
@ -507,7 +546,11 @@ export const useConversationStore = defineStore('imConversationStore', {
// 覆盖更新:与 ackMessage 走同一份 applyServerMessageUpdate // 覆盖更新:与 ackMessage 走同一份 applyServerMessageUpdate
// WebSocket / pull 比 REST ack 先到的场景下blob revoke 和 uploadProgress / _localFile 清理在这里完成 // WebSocket / pull 比 REST ack 先到的场景下blob revoke 和 uploadProgress / _localFile 清理在这里完成
applyServerMessageUpdate(conversation.messages[existingIndex], messageInfo) applyServerMessageUpdate(conversation.messages[existingIndex], messageInfo)
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime // 仅合并到末尾那条时刷会话摘要 + 群 @ 标记;中间位置合并不动会话级 last*,避免后到的早消息把排序时间倒着拉回去
if (existingIndex === conversation.messages.length - 1) {
recomputeConversationLast(conversation)
syncConversationAtFlags(conversation, messageInfo)
}
this.updateMaxId(conversationInfo.type, messageInfo.id) this.updateMaxId(conversationInfo.type, messageInfo.id)
this.saveConversations(conversation) this.saveConversations(conversation)
return return
@ -529,21 +572,7 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.lastSenderDisplayName = senderDisplayName conversation.lastSenderDisplayName = senderDisplayName
// 2.2 群聊 @ 标记(仅对方消息 + 未读态有效) // 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
if ( syncConversationAtFlags(conversation, messageInfo)
!messageInfo.selfSend &&
conversation.type === ImConversationType.GROUP &&
messageInfo.atUserIds &&
messageInfo.atUserIds.length > 0 &&
messageInfo.status !== ImMessageStatus.READ
) {
const currentUserId = getCurrentUserId()
if (currentUserId && messageInfo.atUserIds.includes(currentUserId)) {
conversation.atMe = true
}
if (messageInfo.atUserIds.includes(IM_AT_ALL_USER_ID)) {
conversation.atAll = true
}
}
// 2.3 未读数:非当前会话 + 非自己发送 + 普通消息 + 非已读 => +1 // 2.3 未读数:非当前会话 + 非自己发送 + 普通消息 + 非已读 => +1
const isActive = const isActive =
@ -632,8 +661,9 @@ export const useConversationStore = defineStore('imConversationStore', {
let changed = false let changed = false
for (const key in patch) { for (const key in patch) {
if ( if (
Object.prototype.hasOwnProperty.call(patch, key) Object.prototype.hasOwnProperty.call(patch, key) &&
&& (patch as Record<string, unknown>)[key] !== (message as unknown as Record<string, unknown>)[key] (patch as Record<string, unknown>)[key] !==
(message as unknown as Record<string, unknown>)[key]
) { ) {
changed = true changed = true
break break

View File

@ -449,7 +449,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
type: ImConversationType.PRIVATE, type: ImConversationType.PRIVATE,
targetId: peerId, targetId: peerId,
name: peerDisplayName || String(peerId), name: peerDisplayName || String(peerId),
avatar: friend?.avatar || '' avatar: friend?.avatar || '',
silent: friend?.silent
}, },
message message
) )
@ -561,7 +562,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
type: ImConversationType.GROUP, type: ImConversationType.GROUP,
targetId: websocketMessage.groupId, targetId: websocketMessage.groupId,
name: group?.name || String(websocketMessage.groupId), name: group?.name || String(websocketMessage.groupId),
avatar: group?.avatar || '' avatar: group?.avatar || '',
silent: group?.silent
}, },
message message
) )