From 2935d7d1128fc3c4a767b4e4b8ae106e09c5aaee Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 9 May 2026 01:07:18 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E6=8B=86=E5=87=BA?= =?UTF-8?q?=E7=A7=81=E8=81=8A=20/=20=E7=BE=A4=E8=81=8A=E5=B7=B2=E8=AF=BB?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E5=85=A8=E5=B1=80=E5=BC=80=E5=85=B3=EF=BC=8C?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E5=90=8E=E7=A6=81=E7=94=A8=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E6=89=80=E6=9C=89=20UI=20=E5=85=A5=E5=8F=A3=EF=BC=88?= =?UTF-8?q?=E5=90=AB=E7=BE=A4=E5=9B=9E=E6=89=A7=EF=BC=89=20ImProperties.me?= =?UTF-8?q?ssage=20=E6=96=B0=E5=A2=9E=20privateReadEnabled=20/=20groupRead?= =?UTF-8?q?Enabled=EF=BC=8C=E5=89=8D=E7=AB=AF=20config.ts=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=95=9C=E5=83=8F=E3=80=82=E5=85=B3=E9=97=AD=E5=90=8E?= =?UTF-8?q?=EF=BC=9A=20-=20=E5=90=8E=E7=AB=AF=EF=BC=9Aread=20=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E6=8E=A5=E5=8F=A3=EF=BC=88read=20/=20getMaxReadMessag?= =?UTF-8?q?eId=20/=20getGroupReadUserIds=EF=BC=89=E6=8A=9B=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=BC=82=E5=B8=B8=EF=BC=9BsendGroupMessage=20?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=20NO=5FRECEIPT=20=E5=BF=BD=E7=95=A5=20receip?= =?UTF-8?q?t=3Dtrue=EF=BC=9Bpull=20=E7=BE=A4=E6=B6=88=E6=81=AF=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20Redis=20=E5=B7=B2=E8=AF=BB=E6=B8=B8=E6=A0=87?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E4=B8=8E=20readCount=20=E8=A1=A5=E9=BD=90=20?= =?UTF-8?q?-=20=E5=89=8D=E7=AB=AF=EF=BC=9A=E6=B0=94=E6=B3=A1=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E6=A0=87=E7=AD=BE=20/=20=E7=BE=A4=E5=9B=9E=E6=89=A7?= =?UTF-8?q?=20popover=20/=20=E3=80=8C=E5=8F=91=E9=80=81=E5=9B=9E=E6=89=A7?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E3=80=8D=E4=B8=8B=E6=8B=89=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=20/=20admin=20=E5=88=97=E8=A1=A8=E3=80=8C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E3=80=8D=E3=80=8C=E5=9B=9E=E6=89=A7=E3=80=8D=E5=88=97=E4=B8=8E?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=AF=B9=E5=BA=94=E5=AD=97=E6=AE=B5=E6=8C=89?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E9=9A=90=E8=97=8F=EF=BC=9B=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=20/=20=E5=86=B7=E5=90=AF=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=AF=B9=E6=96=B9=E5=B7=B2=E8=AF=BB=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=20/=20WS=20READ=20&=20RECEIPT=20handler=20=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=8C=89=E5=BC=80=E5=85=B3=E7=9F=AD=E8=B7=AF=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=89=93=E5=88=B0=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20-=20=E5=8D=95=E6=B5=8B=EF=BC=9A=E8=A1=A5?= =?UTF-8?q?=20@Spy=20ImProperties=20=E4=BF=AE=E5=A4=8D=E5=8E=9F=E6=9C=AC?= =?UTF-8?q?=E5=B0=B1=E5=9C=A8=E7=9A=84=20NPE=EF=BC=8C=E5=8A=A0=20disabled?= =?UTF-8?q?=20=E5=88=86=E6=94=AF=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/home/composables/useMessagePuller.ts | 12 +++++- .../im/home/composables/useMessageSender.ts | 11 ++++- .../components/input/MessageInput.vue | 7 ++-- .../components/message/MessageItem.vue | 17 ++++++-- src/views/im/home/store/websocketStore.ts | 41 +++++++++++++------ .../message/group/GroupMessageDetail.vue | 3 +- src/views/im/manager/message/group/index.vue | 17 +++++++- .../message/private/PrivateMessageDetail.vue | 3 +- .../im/manager/message/private/index.vue | 9 +++- src/views/im/utils/config.ts | 16 ++++++++ 10 files changed, 110 insertions(+), 26 deletions(-) diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index a114e249b..ca51b5c3c 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -19,7 +19,11 @@ import { isFriendChatTip, isFriendNotification } from '../../utils/constants' -import { MESSAGE_PRIVATE_PULL_SIZE, MESSAGE_GROUP_PULL_SIZE } from '../../utils/config' +import { + MESSAGE_PRIVATE_PULL_SIZE, + MESSAGE_GROUP_PULL_SIZE, + MESSAGE_PRIVATE_READ_ENABLED +} from '../../utils/config' import { useUserStore } from '@/store/modules/user' import type { Message } from '../types' @@ -220,8 +224,12 @@ export const useMessagePuller = () => { // 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」 // 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发 + // 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志 const active = conversationStore.activeConversation - if (active && active.type === ImConversationType.PRIVATE) { + if ( + MESSAGE_PRIVATE_READ_ENABLED + && active && active.type === ImConversationType.PRIVATE + ) { try { const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId) if (maxReadId) { diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 3349d5472..74a6e9ffe 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -18,6 +18,7 @@ import { type TextMessage } from '../../utils/message' import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants' +import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config' import type { Conversation, Message } from '../types' import { useUserStore } from '@/store/modules/user' @@ -229,8 +230,12 @@ export const useMessageSender = () => { if (!maxMessageId) { return } - // 接口调用:私聊 / 群聊接口签名一致,按会话类型分发;失败仅记录日志,不回退本地已读状态 + // 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态 const isPrivate = conversation.type === ImConversationType.PRIVATE + const readEnabled = isPrivate ? MESSAGE_PRIVATE_READ_ENABLED : MESSAGE_GROUP_READ_ENABLED + if (!readEnabled) { + return + } try { await (isPrivate ? apiReadPrivateMessages(conversation.targetId, maxMessageId) @@ -255,6 +260,10 @@ export const useMessageSender = () => { if (!peerId) { return } + // 私聊已读关闭:跳过对方已读位置同步,避免无谓接口调用 + if (!MESSAGE_PRIVATE_READ_ENABLED) { + return + } try { // 拉取对方已读到的最大消息 id const maxReadId = await apiGetPrivateMaxReadMessageId(peerId) diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 8cb1fa077..f023e153c 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -108,9 +108,9 @@ - + - + 发 送 @@ -175,6 +175,7 @@ import { useMediaUploader } from '@/views/im/home/composables/useMediaUploader' import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay' import { getConversationKey } from '@/views/im/utils/conversation' import { ImConversationType, ImMessageType } from '@/views/im/utils/constants' +import { MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' import { serializeMessage, type FaceMessage, diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index cac5b70ba..239afaaae 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -168,7 +168,12 @@ import { isMediaMessageType, isNormalMessage } from '@/views/im/utils/constants' -import { MESSAGE_TIME_TIP_GAP_MS, MESSAGE_RECALL_WINDOW_MS } from '@/views/im/utils/config' +import { + MESSAGE_TIME_TIP_GAP_MS, + MESSAGE_RECALL_WINDOW_MS, + MESSAGE_PRIVATE_READ_ENABLED, + MESSAGE_GROUP_READ_ENABLED +} from '@/views/im/utils/config' import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/im/group' import { removeGroupMember } from '@/api/im/group/member' import { @@ -399,8 +404,11 @@ const senderDisplayName = computed(() => { ) }) -/** 私聊「已读 / 未读」态(仅对自己发送的私聊消息展示) */ +/** 私聊「已读 / 未读」态(仅对自己发送的私聊消息展示;私聊已读全局关闭时不再展示) */ const privateReadLabel = computed(() => { + if (!MESSAGE_PRIVATE_READ_ENABLED) { + return '' + } if (!props.message.selfSend) { return '' } @@ -416,8 +424,11 @@ const privateReadLabel = computed(() => { return '' }) -/**是否需要显示群回执 popover:自己发的群消息且后端开启了回执(NO_RECEIPT 表示发送时未要求回执,不渲染) */ +/**是否需要显示群回执 popover:自己发的群消息且后端开启了回执(NO_RECEIPT 表示发送时未要求回执,不渲染;群已读全局关闭时统一不展示) */ const showGroupReadStatus = computed(() => { + if (!MESSAGE_GROUP_READ_ENABLED) { + return false + } if (!props.message.selfSend) { return false } diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 88dfd9b30..534beb627 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -13,6 +13,7 @@ import { isNormalMessage } from '../../utils/constants' import { playAudioTip } from '../../utils/message' +import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config' import { useConversationStore } from './conversationStore' import { useFriendStore, type FriendNotificationPayload } from './friendStore' import { getFriendDisplayName } from '../../utils/user' @@ -356,12 +357,14 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.type === ImConversationType.PRIVATE && conversationStore.activeConversation?.targetId === peerId if (isActive) { - // 聊天窗口打开 = 实际看到了:本端清未读 + 上报后端,让对方 UI 立刻切到"已读" + // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) conversationStore.markActiveAsRead() - apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => { - console.warn('[IM WS] 自动已读上报失败', e) - }) + if (MESSAGE_PRIVATE_READ_ENABLED) { + apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => { + console.warn('[IM WS] 自动已读上报失败', e) + }) + } } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响 playAudioTip() @@ -369,8 +372,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { } }, - /** 私聊 READ 事件:自己的其它终端在对方会话里标为已读,本端同步清零未读 */ + /** 私聊 READ 事件:自己的其它终端在对方会话里标为已读,本端同步清零未读;私聊已读关闭时兜底忽略 */ handlePrivateRead(websocketMessage: ImPrivateMessageDTO) { + if (!MESSAGE_PRIVATE_READ_ENABLED) { + return + } const conversationStore = useConversationStore() const conversation = conversationStore.getConversation( ImConversationType.PRIVATE, @@ -385,9 +391,12 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { /** * 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 * 后端将 maxReadId 编码在 DTO 的 id 字段(见 ImPrivateMessageDTO.ofReceipt), - * 这里据此卡边界,避免把"回执在路上时刚发的消息"误标为已读。 + * 这里据此卡边界,避免把"回执在路上时刚发的消息"误标为已读;私聊已读关闭时兜底忽略 */ handlePrivateReceipt(websocketMessage: ImPrivateMessageDTO) { + if (!MESSAGE_PRIVATE_READ_ENABLED) { + return + } if (!websocketMessage.id) { return } @@ -463,11 +472,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.type === ImConversationType.GROUP && conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { - // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId) + // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 conversationStore.markActiveAsRead() - apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => { - console.warn('[IM WS] 自动已读上报失败', e) - }) + if (MESSAGE_GROUP_READ_ENABLED) { + apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => { + console.warn('[IM WS] 自动已读上报失败', e) + }) + } } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { // GROUP_* 群广播事件等系统消息不响提示音 playAudioTip() @@ -477,8 +488,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // ==================== 群聊已读 / 回执 ==================== - /** 群聊 READ:自己其它终端在某群里标为已读,本端同步清零该群未读 */ + /** 群聊 READ:自己其它终端在某群里标为已读,本端同步清零该群未读;群已读关闭时兜底忽略 */ handleGroupRead(websocketMessage: ImGroupMessageDTO) { + if (!MESSAGE_GROUP_READ_ENABLED) { + return + } const conversationStore = useConversationStore() const conversation = conversationStore.getConversation( ImConversationType.GROUP, @@ -490,8 +504,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.saveConversations() }, - /** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */ + /** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus;群已读关闭时兜底忽略 */ handleGroupReceipt(websocketMessage: ImGroupMessageDTO) { + if (!MESSAGE_GROUP_READ_ENABLED) { + return + } const conversationStore = useConversationStore() conversationStore.applyReadReceipt({ conversationType: ImConversationType.GROUP, diff --git a/src/views/im/manager/message/group/GroupMessageDetail.vue b/src/views/im/manager/message/group/GroupMessageDetail.vue index 4b1350305..f925d7565 100644 --- a/src/views/im/manager/message/group/GroupMessageDetail.vue +++ b/src/views/im/manager/message/group/GroupMessageDetail.vue @@ -12,7 +12,7 @@ - + @@ -50,6 +50,7 @@ import { formatDate } from '@/utils/formatTime' import { DICT_TYPE } from '@/utils/dict' import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants' import { formatJson } from '@/views/im/utils/message' +import { MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' import * as ManagerGroupMessageApi from '@/api/im/manager/message/group' import MessageContentPreview from '../MessageContentPreview.vue' diff --git a/src/views/im/manager/message/group/index.vue b/src/views/im/manager/message/group/index.vue index bb17b3a0f..069ef46e6 100644 --- a/src/views/im/manager/message/group/index.vue +++ b/src/views/im/manager/message/group/index.vue @@ -112,12 +112,24 @@ - - + - + @@ -152,6 +164,7 @@ import { dateFormatter } from '@/utils/formatTime' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants' +import { MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' import * as ManagerGroupMessageApi from '@/api/im/manager/message/group' import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue' import GroupSelect from '@/views/im/manager/group/components/GroupSelect.vue' diff --git a/src/views/im/manager/message/private/PrivateMessageDetail.vue b/src/views/im/manager/message/private/PrivateMessageDetail.vue index c2683f9b2..8ce0926d2 100644 --- a/src/views/im/manager/message/private/PrivateMessageDetail.vue +++ b/src/views/im/manager/message/private/PrivateMessageDetail.vue @@ -14,7 +14,7 @@ - + @@ -38,6 +38,7 @@ import { formatDate } from '@/utils/formatTime' import { DICT_TYPE } from '@/utils/dict' import { formatJson } from '@/views/im/utils/message' +import { MESSAGE_PRIVATE_READ_ENABLED } from '@/views/im/utils/config' import * as ManagerPrivateMessageApi from '@/api/im/manager/message/private' import MessageContentPreview from '../MessageContentPreview.vue' diff --git a/src/views/im/manager/message/private/index.vue b/src/views/im/manager/message/private/index.vue index 8660d40c4..3733139d2 100644 --- a/src/views/im/manager/message/private/index.vue +++ b/src/views/im/manager/message/private/index.vue @@ -94,7 +94,13 @@ /> - + @@ -134,6 +140,7 @@