From f7cda1fc4eb4b58e00a523abae07fcf97c379197 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 21 May 2026 01:13:29 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BF=AE=E4=B8=80?= =?UTF-8?q?=E7=BB=84=E7=BB=86=E8=8A=82=EF=BC=9A=E4=BC=9A=E8=AF=9D=20silent?= =?UTF-8?q?=20=E8=B7=9F=E9=9A=8F=E6=96=B0=E6=B6=88=E6=81=AF=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E3=80=81=E5=90=88=E5=B9=B6=E6=9C=AB=E5=B0=BE=E5=88=B7?= =?UTF-8?q?=E6=91=98=E8=A6=81=20+=20=E7=BE=A4=20@=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E3=80=81=E5=BD=95=E9=9F=B3=201s=20=E4=B8=8B=E9=99=90=E3=80=81?= =?UTF-8?q?=E9=82=80=E8=AF=B7=20reload=20=E9=80=8F=20friendIds=E3=80=81pul?= =?UTF-8?q?l=20=E6=B8=B8=E6=A0=87=E5=8F=96=E6=9C=80=E5=A4=A7=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/home/composables/useMessagePuller.ts | 22 ++++-- .../conversation/ConversationGroupSide.vue | 4 +- .../components/input/VoiceRecorder.vue | 7 +- src/views/im/home/store/conversationStore.ts | 76 +++++++++++++------ src/views/im/home/store/websocketStore.ts | 6 +- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 3f045963a..04519da9e 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -118,7 +118,8 @@ export const useMessagePuller = () => { type: ImConversationType.PRIVATE, 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, targetId: 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) => { // 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度 let minId = startMinId || 0 @@ -208,11 +210,17 @@ export const useMessagePuller = () => { } } - // 游标推进到本批最后一条 id,下一轮从此处续翻 - const lastId = list[list.length - 1].id - if (lastId != null) { - minId = lastId + // 游标推进到本批最大 id,与后端返回顺序无关;无有效 id 直接 break 避免死翻同一批 + const validIds = list.map((message) => message.id).filter((id): id is number => id != null) + if (validIds.length === 0) { + break } + const nextMinId = Math.max(...validIds) + // 游标没前进就停:当前后端契约是 id > minId,理论不会出现;防御后端契约变更或边界数据死翻 + if (nextMinId <= minId) { + break + } + minId = nextMinId } } diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index eaee7cd2b..34b633320 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -371,7 +371,7 @@ - + @@ -432,7 +432,7 @@ const props = withDefaults( const emit = defineEmits<{ 'update:modelValue': [value: boolean] - reload: [] // 邀请 / 移除 / 修改群资料后,父组件重新拉群数据 + reload: [friendIds?: number[]] // 邀请 / 移除 / 修改群资料后,父组件重新拉群数据;邀请新成员场景透出 friendIds 让上层可选精准刷新 'open-history': [] // 点击 "查找聊天内容" 行 → 父组件打开 MessageHistory 弹窗 }>() diff --git a/src/views/im/home/pages/conversation/components/input/VoiceRecorder.vue b/src/views/im/home/pages/conversation/components/input/VoiceRecorder.vue index 98963a1f4..b776b0976 100644 --- a/src/views/im/home/pages/conversation/components/input/VoiceRecorder.vue +++ b/src/views/im/home/pages/conversation/components/input/VoiceRecorder.vue @@ -173,8 +173,13 @@ async function startRecord() { }, 1000) } -/** 停止录制:进入 preview 阶段,由用户决定重录或发送 */ +/** 停止录制:进入 preview 阶段,由用户决定重录或发送;< 1s 视为误触,直接 warning + 回 idle 不进 preview */ function stopRecord() { + if (duration.value < 1) { + message.warning('录音时间太短') + resetAll() + return + } if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop() } diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index a5851fd8a..c1631e9fb 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -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 回填)合并到本地消息 * @@ -369,12 +394,13 @@ export const useConversationStore = defineStore('imConversationStore', { } }, - /** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用) */ + /** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用);调用方传 silent 时按 friend / group store 的值落地,未传保持默认 false */ createEmptyConversation( type: number, targetId: number, name: string, - avatar: string + avatar: string, + silent: boolean = false ): Conversation { return { targetId, @@ -387,7 +413,7 @@ export const useConversationStore = defineStore('imConversationStore', { messages: [], deleted: false, top: false, - silent: false, + silent, atMe: false, atAll: false } @@ -461,7 +487,13 @@ export const useConversationStore = defineStore('imConversationStore', { * 4. 收尾:更新游标 + 持久化 */ 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 ) { // 0. 群广播事件旁路:按 type 局部更新 groupStore 的 role / ownerUserId / 成员列表等状态 @@ -477,19 +509,26 @@ export const useConversationStore = defineStore('imConversationStore', { } // 1.1 查找或自动创建会话;命中软删会话需要复活(场景:A 退群后被重新拉入、用户主动删了对话又收到新消息) + // silent 跟随调用方:新建写入;复活 / 已有会话仅在调用方明确传值时覆盖,避免本地 silent 与 friend / group store 漂移 let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId) if (!conversation) { conversation = this.createEmptyConversation( conversationInfo.type, conversationInfo.targetId, conversationInfo.name, - conversationInfo.avatar + conversationInfo.avatar, + conversationInfo.silent ) this.conversations.unshift(conversation) } else if (conversation.deleted) { conversation.deleted = false conversation.name = conversationInfo.name || conversation.name 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。命中则覆盖更新并返回 @@ -507,7 +546,11 @@ export const useConversationStore = defineStore('imConversationStore', { // 覆盖更新:与 ackMessage 走同一份 applyServerMessageUpdate; // WebSocket / pull 比 REST ack 先到的场景下,blob revoke 和 uploadProgress / _localFile 清理在这里完成 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.saveConversations(conversation) return @@ -529,21 +572,7 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.lastSenderDisplayName = senderDisplayName // 2.2 群聊 @ 标记(仅对方消息 + 未读态有效) - if ( - !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 - } - } + syncConversationAtFlags(conversation, messageInfo) // 2.3 未读数:非当前会话 + 非自己发送 + 普通消息 + 非已读 => +1 const isActive = @@ -632,8 +661,9 @@ export const useConversationStore = defineStore('imConversationStore', { let changed = false for (const key in patch) { if ( - Object.prototype.hasOwnProperty.call(patch, key) - && (patch as Record)[key] !== (message as unknown as Record)[key] + Object.prototype.hasOwnProperty.call(patch, key) && + (patch as Record)[key] !== + (message as unknown as Record)[key] ) { changed = true break diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 600b9d368..0effe9d4a 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -449,7 +449,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { type: ImConversationType.PRIVATE, targetId: peerId, name: peerDisplayName || String(peerId), - avatar: friend?.avatar || '' + avatar: friend?.avatar || '', + silent: friend?.silent }, message ) @@ -561,7 +562,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { type: ImConversationType.GROUP, targetId: websocketMessage.groupId, name: group?.name || String(websocketMessage.groupId), - avatar: group?.avatar || '' + avatar: group?.avatar || '', + silent: group?.silent }, message )