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 @@
-
+
{{ textContent }}
@@ -84,7 +84,15 @@
{{ groupNotificationText }}
-
+
+
+ {{ friendChatTipText }}
+
+
+
{{ fallbackText }}
@@ -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) {