From a9f54fdee1fcf9f24e1956dc1a5cdae01047bb2c Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 5 May 2026 21:56:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E9=87=8D=E6=9E=84=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E6=B6=88=E6=81=AF=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=92=8C?= =?UTF-8?q?=20openim=20=E7=9A=84=E6=B6=88=E6=81=AF=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conversation/ConversationItem.vue | 2 +- .../components/message/MessageHistory.vue | 22 ++++++---- .../components/message/MessageItem.vue | 29 ++++++++----- src/views/im/home/store/websocketStore.ts | 20 ++++++--- .../manager/message/MessageContentPreview.vue | 43 ++++++++++++++----- src/views/im/utils/constants.ts | 24 +++++++---- src/views/im/utils/conversation.ts | 16 ++++--- src/views/im/utils/message.ts | 30 +------------ src/views/im/utils/user.ts | 12 ++++++ 9 files changed, 117 insertions(+), 81 deletions(-) diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue index b2bdf7bec..98af78c78 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue @@ -127,7 +127,7 @@ const lastSenderDisplayName = computed(() => { ) }) -/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(TIP_TEXT / RECALL / 草稿态不带前缀) */ +/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(FRIEND_* / GROUP_* / RECALL / 草稿态不带前缀) */ const showSendName = computed(() => { if (draft.value) { return false diff --git a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue index 20b4fc3b8..c165dfa4c 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue @@ -143,16 +143,16 @@ v-for="message in currentList" :key="message.id || message.clientMessageId" > -
- {{ resolveTipText(message.content) }} + {{ resolveFriendNotificationText(message) }}
- +
(message.content)?.content ?? '' - case ImMessageType.TIP_TEXT: - return resolveTipText(message.content) case ImMessageType.IMAGE: return '[图片]' case ImMessageType.FILE: 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 d1ec2ea48..59c752a7d 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -7,15 +7,15 @@ {{ formatTipTime(message.sendTime) }}
- +
- {{ tipText }} + {{ friendChatTipText }}
- +
props.message.type === ImMessageType.RECALL) -/** 系统提示文案 */ -const isTipText = computed(() => props.message.type === ImMessageType.TIP_TEXT) +/** 是否会话内好友事件气泡(FRIEND_ADD / FRIEND_DELETE) */ +const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type)) /** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染 */ const shouldShowTimeTip = computed(() => { @@ -384,8 +387,8 @@ function formatTipTime(timestamp: number): string { /** 文本内容 */ const textContent = computed(() => parseMessage(props.message.content)?.content ?? '') -/** TIP_TEXT 文案:与 conversationStore.resolveLastContent / MessageHistory.renderContent 共用 helper,避免兼容性逻辑分裂 */ -const tipText = computed(() => resolveTipText(props.message.content)) +/** 好友会话事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示,文案固定 */ +const friendChatTipText = computed(() => resolveFriendNotificationText(props.message)) /** 群广播事件 */ const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type)) @@ -561,10 +564,10 @@ const RECALL_WINDOW_MS = 2 * 60 * 1000 * - 引用:已落库(id≠0)+ 未撤回的消息可引用,引用块写入 draftStore.reply * - 撤回 / 删除:互斥;自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」(推服务器),其它显示「删除」(仅本地清) * - * TIP_TEXT 态不弹菜单 + * 好友事件气泡态不弹菜单 */ async function handleContextMenu(e: MouseEvent) { - if (isTipText.value) { + if (isFriendChatTipMessage.value) { return } @@ -761,6 +764,10 @@ async function handleResend() { if (!conversation) { return } + // 禁言 / 封禁时拦截:避免重试绕过 MessageInput 的 muteOverlay 又走一次 sendRaw 让后端拒 + if (muteOverlay.value) { + return + } conversationStore.removeMessage(conversation.type, conversation.targetId, { id: props.message.id, clientMessageId: props.message.clientMessageId diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 42f050c9d..2258d3256 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -7,6 +7,7 @@ import { ImWebSocketMessageType, ImMessageType, ImConversationType, + isFriendChatTip, isFriendNotification, isNormalMessage } from '../../utils/constants' @@ -77,9 +78,9 @@ const convertGroupMessage = ( * 2. 帧分发:dispatchFrame → dispatchPrivateFrame / dispatchGroupFrame,按 ImMessageType 再分流 * 3. 缓冲:初始化加载期(conversationStore.loading=true)暂存消息,等 pull 完成后由 useMessagePuller 调 flushBuffer 回放 * 4. 事件处理(按类型分发到对应 handle*,联动 conversation / friend / group store): - * - 普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT):入库 + 当前会话自动已读 / 提示音 + * - 普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO):入库 + 当前会话自动已读 / 提示音 * - 已读 / 回执(READ / RECEIPT):多端已读同步、对方读后回执 - * - 好友变更(FRIEND_ADD / DELETE / UPDATE):同步 friendStore + 级联刷新私聊会话 + * - 好友变更(FRIEND_*):同步 friendStore + 级联刷新私聊会话;FRIEND_ADD / FRIEND_DELETE 额外插入会话气泡 * - 群个人信号(GROUP_MEMBER_SETTING_UPDATE):同步 groupStore + 级联刷新群聊会话 * - 群广播事件(GROUP_*):走 handleGroupMessage + applyGroupNotification 旁路(含 DISSOLVE / QUIT / KICK 自判清群) */ @@ -224,8 +225,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { default: if (isFriendNotification(websocketMessage.type)) { this.handleFriendNotification(websocketMessage) + // FRIEND_ADD / FRIEND_DELETE 同时作为会话事件气泡插入消息列表(becomeFriends 入库 + // 帧 + silent / delete 单边推送帧统一走入库去重路径,前端按 type 渲染灰色提示) + if (isFriendChatTip(websocketMessage.type)) { + this.handlePrivateMessage(websocketMessage) + } } else { - // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息 + // TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息 this.handlePrivateMessage(websocketMessage) } } @@ -253,7 +259,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { this.handleGroupMemberSettingUpdate(websocketMessage) break default: - // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT + GROUP_* 群广播事件 + // TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 群广播事件 this.handleGroupMessage(websocketMessage) } } catch (e) { @@ -263,7 +269,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { }, /** - * 私聊普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT)入库 + 自动已读 + * 私聊普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)入库 + 自动已读 * * 流程: * 1. 离线加载期缓冲(避开与 pull 回填的竞态) @@ -334,7 +340,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { console.warn('[IM WS] 自动已读上报失败', e) }) } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { - // 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);TIP_TEXT 等系统提示不响 + // 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响 playAudioTip() } } @@ -440,7 +446,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { console.warn('[IM WS] 自动已读上报失败', e) }) } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { - // GROUP_* 群广播事件 / TIP_TEXT 等系统提示不响提示音 + // GROUP_* 群广播事件等系统消息不响提示音 playAudioTip() } } diff --git a/src/views/im/manager/message/MessageContentPreview.vue b/src/views/im/manager/message/MessageContentPreview.vue index ebcd650d6..4ee21d44d 100644 --- a/src/views/im/manager/message/MessageContentPreview.vue +++ b/src/views/im/manager/message/MessageContentPreview.vue @@ -1,5 +1,5 @@ @@ -93,16 +101,23 @@ import { computed } from 'vue' import Icon from '@/components/Icon/src/Icon.vue' import { formatFileSize } from '@/utils/file' import { formatSeconds } from '@/utils/formatTime' -import { ImMessageType, isGroupNotification } from '@/views/im/utils/constants' +import { + ImMessageType, + isFriendChatTip, + isGroupNotification +} from '@/views/im/utils/constants' import { parseMessage, - resolveTipText, type ImageMessage, type FileMessage, type AudioMessage, - type VideoMessage + type VideoMessage, + type TextMessage } from '@/views/im/utils/message' -import { resolveGroupNotificationText } from '@/views/im/utils/user' +import { + resolveFriendNotificationText, + resolveGroupNotificationText +} from '@/views/im/utils/user' defineOptions({ name: 'ImMessageContentPreview' }) @@ -116,16 +131,16 @@ const props = defineProps<{ }>() /** 各类型判定 */ -const isText = computed( - () => props.type === ImMessageType.TEXT || props.type === ImMessageType.TIP_TEXT -) +const isText = computed(() => props.type === ImMessageType.TEXT) const isImage = computed(() => props.type === ImMessageType.IMAGE) const isFile = computed(() => props.type === ImMessageType.FILE) const isVoice = computed(() => props.type === ImMessageType.VOICE) const isVideo = computed(() => props.type === ImMessageType.VIDEO) -/** 文本内容:兼容 JSON 包裹和裸字符串两种形态 */ -const textContent = computed(() => resolveTipText(props.content || '')) +/** 文本内容:从 TextMessage payload 取 .content */ +const textContent = computed( + () => parseMessage(props.content || '')?.content ?? '' +) const imagePayload = computed(() => isImage.value ? parseMessage(props.content || '') : null @@ -197,6 +212,12 @@ const fallbackText = computed(() => { return raw }) +/** 是否好友会话事件气泡(FRIEND_ADD / FRIEND_DELETE) */ +const isFriendChatTipType = computed(() => isFriendChatTip(props.type ?? -1)) + +/** 好友会话事件文案:固定文案,不依赖 payload */ +const friendChatTipText = computed(() => resolveFriendNotificationText({ type: props.type })) + /** 是否群广播事件 */ const isGroupNotificationType = computed(() => isGroupNotification(props.type ?? -1)) diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index ab1ffd373..334eee47c 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -1,14 +1,15 @@ /** IM 消息类型枚举(对齐后端 ImMessageTypeEnum) */ export const ImMessageType = { - TEXT: 0, // 文本 - IMAGE: 1, // 图片 - FILE: 2, // 文件 - VOICE: 3, // 语音 - VIDEO: 4, // 视频 - RECALL: 10, // 撤回 - READ: 11, // 已读 - RECEIPT: 12, // 回执 - TIP_TEXT: 21, // 提示文本(撤回提示等) + // ========== 用户聊天消息(101-105 直接复用 OpenIM 段位编号) ========== + TEXT: 101, // 文本(对应 OpenIM Text=101) + IMAGE: 102, // 图片(对应 OpenIM Picture=102) + VOICE: 103, // 语音(对应 OpenIM Sound=103) + VIDEO: 104, // 视频(对应 OpenIM Video=104) + FILE: 105, // 文件(对应 OpenIM File=105) + // ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ========== + RECALL: 2101, // 撤回(对应 OpenIM RevokeNotification=2101) + RECEIPT: 2200, // 回执(对应 OpenIM HasReadReceipt=2200) + READ: 2201, // 已读(多端同步,OpenIM 无对应;自有扩展) // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ========== FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意 FRIEND_REQUEST_REJECTED: 1202, // 好友申请被拒绝 @@ -62,6 +63,11 @@ export function isFriendNotification(type: number): boolean { return type >= ImMessageType.FRIEND_REQUEST_APPROVED && type <= ImMessageType.FRIEND_UPDATE } +/** 判断是否「会话内的好友事件气泡」:FRIEND_ADD / FRIEND_DELETE 直接渲染成灰色提示,与群事件同处理 */ +export function isFriendChatTip(type: number): boolean { + return type === ImMessageType.FRIEND_ADD || type === ImMessageType.FRIEND_DELETE +} + /** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */ const ImMessageTypeNormals: number[] = [ ImMessageType.TEXT, diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts index 1d239f5de..32a9712ea 100644 --- a/src/views/im/utils/conversation.ts +++ b/src/views/im/utils/conversation.ts @@ -6,9 +6,13 @@ // 2. fallbackName 由调用方传入(典型来源:Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底 // ==================================================================== -import { ImMessageType, isGroupNotification } from './constants' -import { parseMessage, resolveTipText, type TextMessage } from './message' -import { getSenderDisplayName, resolveGroupNotificationText } from './user' +import { ImMessageType, isFriendChatTip, isGroupNotification } from './constants' +import { parseMessage, type TextMessage } from './message' +import { + getSenderDisplayName, + resolveFriendNotificationText, + resolveGroupNotificationText +} from './user' import type { Message } from '../home/types' /** 会话主键:`type-targetId` 拼成稳定字符串,给 v-for :key、active 比对、map key 等场景共用 */ @@ -62,10 +66,10 @@ export function resolveConversationLastContent( ) case ImMessageType.TEXT: return parseMessage(message.content)?.content ?? '' - case ImMessageType.TIP_TEXT: - // TIP_TEXT 后端常发裸字符串(私聊好友建立 / 解除等),不能按 TextMessage JSON 解析,否则摘要变空 - return resolveTipText(message.content) default: + if (isFriendChatTip(message.type)) { + return resolveFriendNotificationText(message) + } if (isGroupNotification(message.type)) { return resolveGroupNotificationText(message) } diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index 4eec0f253..cdd22f439 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -8,9 +8,6 @@ import type { Message } from '../home/types' // cn.iocoder.yudao.module.im.service.websocket.dto.message.* 下的 DTO。 // 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage, // 序列化直接 JSON.stringify(payload)。 -// -// 例外:TIP_TEXT(私聊好友建立 / 解除等系统提示)后端会直接发裸字符串, -// 展示侧需走 resolveTipText 兼容裸字符串 + 老接口可能的 {"content":"..."} 两种形态。 // ==================================================================== // ==================== 客户端 ID ==================== @@ -146,34 +143,11 @@ export const getQuoteFromMessage = (content: string): QuoteMessage | null => { return parsed?.quote ?? null } -// ==================== TIP_TEXT ==================== - -/** - * 解析 TIP_TEXT(系统提示)文案 - * - * 后端:私聊好友建立 / 解除等系统提示直接发裸字符串;老接口可能包成 {"content": "..."}。 - * 解析得到 .content 就用,否则当裸文案返回,避免出现空行。 - * - * MessageItem / conversationStore.resolveLastContent / MessageHistory.renderContent 三处共用, - * 修一处兼容性问题不会漏到另两处 - */ -export const resolveTipText = (content: string): string => { - const raw = content || '' - if (!raw) { - return '' - } - const parsed = parseMessage(raw) - if (parsed && typeof parsed.content === 'string') { - return parsed.content - } - return raw -} - // ==================== 撤回 ==================== /** - * 从后端下发的撤回 TIP_TEXT content 中解析出被撤回的原消息 id - * content 形如 `{"messageId": 123}`,若不含 messageId 则返回 0(表示这条不是撤回 tip) + * 从后端下发的撤回 RecallMessage content 中解析出被撤回的原消息 id + * content 形如 `{"messageId": 123}`,若不含 messageId 则返回 0(表示这条不是撤回消息) */ export const parseRecallMessageId = (content: string): number => { try { diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts index 84775a559..0d535540e 100644 --- a/src/views/im/utils/user.ts +++ b/src/views/im/utils/user.ts @@ -258,6 +258,18 @@ export function resolveGroupNotificationText( } } +/** 会话内好友事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示气泡,文案固定不依赖 payload */ +export function resolveFriendNotificationText(message: { type?: number }): string { + switch (message.type) { + case ImMessageType.FRIEND_ADD: + return '你们已经是好友了,开始聊天吧' + case ImMessageType.FRIEND_DELETE: + return '你已删除好友' + default: + return '' + } +} + /** 性别图标:男 1 / 女 2,0 / null / undefined 一律不展示,对齐微信留白 */ export function getGenderIcon(sex?: number): string { if (sex === 1) {