feat(im): 重构普通消息类型,和 openim 的消息编号对齐

im
YunaiV 2026-05-05 21:56:05 +08:00
parent 055d4bab27
commit a9f54fdee1
9 changed files with 117 additions and 81 deletions

View File

@ -127,7 +127,7 @@ const lastSenderDisplayName = computed(() => {
)
})
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(TIP_TEXT / RECALL / 草稿态不带前缀) */
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(FRIEND_* / GROUP_* / RECALL / 草稿态不带前缀) */
const showSendName = computed(() => {
if (draft.value) {
return false

View File

@ -143,16 +143,16 @@
v-for="message in currentList"
:key="message.id || message.clientMessageId"
>
<!-- TIP_TEXT 系统提示"你们已成为好友"居中灰色不挂头像 / sender
<!-- 好友会话事件FRIEND_ADD / FRIEND_DELETE居中灰色不挂头像 / sender
跟主聊天面板里 MessageItem 的渲染语义对齐 -->
<div
v-if="message.type === ImMessageType.TIP_TEXT"
v-if="isFriendChatTip(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
>
{{ resolveTipText(message.content) }}
{{ resolveFriendNotificationText(message) }}
</div>
<!-- 群广播事件文案 TIP_TEXT 同样的居中灰色样式 -->
<!-- 群广播事件文案好友事件同灰色样式 -->
<div
v-else-if="isGroupNotification(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
@ -312,14 +312,19 @@ import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname,
resolveFriendNotificationText,
resolveGroupNotificationText
} from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
import { ImConversationType, ImMessageType, isGroupNotification } from '@/views/im/utils/constants'
import {
ImConversationType,
ImMessageType,
isFriendChatTip,
isGroupNotification
} from '@/views/im/utils/constants'
import {
parseMessage,
resolveTipText,
getFileIconInfo,
type TextMessage,
type ImageMessage,
@ -664,11 +669,12 @@ function audioOf(message: Message): AudioMessage | null {
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
if (isFriendChatTip(message.type)) {
return resolveFriendNotificationText(message)
}
switch (message.type) {
case ImMessageType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? ''
case ImMessageType.TIP_TEXT:
return resolveTipText(message.content)
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.FILE:

View File

@ -7,15 +7,15 @@
{{ formatTipTime(message.sendTime) }}
</div>
<!-- 系统提示文案TIP_TEXT=21 -->
<!-- 好友会话事件FRIEND_ADD / FRIEND_DELETE跟群广播事件同灰色样式文案固定 -->
<div
v-if="isTipText"
v-if="isFriendChatTipMessage"
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
>
{{ tipText }}
{{ friendChatTipText }}
</div>
<!-- 群广播事件 TIP_TEXT 同样的居中灰色样式文案按 type 拼装 -->
<!-- 群广播事件好友事件同灰色样式文案按 type 拼装 -->
<div
v-else-if="isGroupNotificationMessage"
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
@ -227,6 +227,7 @@ import {
ImConversationType,
ImGroupMemberRole,
TIME_TIP_GAP_MS,
isFriendChatTip,
isGroupNotification,
isNormalMessage
} from '@/views/im/utils/constants'
@ -236,7 +237,6 @@ import {
buildQuoteFromMessage,
getQuoteFromMessage,
parseMessage,
resolveTipText,
getFileIconInfo,
type TextMessage,
type ImageMessage,
@ -256,10 +256,12 @@ import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname,
resolveFriendNotificationText,
resolveGroupNotificationText
} from '@/views/im/utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue'
import ReplyPreview from './ReplyPreview.vue'
@ -292,6 +294,7 @@ const friendStore = useFriendStore()
const draftStore = useDraftStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
const muteOverlay = useMuteOverlay()
// confirm message props.message vue/no-dupe-keys
const { confirm: confirmDialog, success: successMessage } = useMessage()
@ -300,8 +303,8 @@ const { confirm: confirmDialog, success: successMessage } = useMessage()
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => 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<TextMessage>(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
* - 引用已落库id0+ 未撤回的消息可引用引用块写入 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

View File

@ -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)) {
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTipTIP_TEXT 等系统提示不响
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTipFRIEND_* 等系统事件不响
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()
}
}

View File

@ -1,5 +1,5 @@
<template>
<!-- 文本 / 系统提示文本直接显示纯文本 -->
<!-- 文本直接显示纯文本 -->
<span v-if="isText" class="whitespace-pre-wrap break-all">{{ textContent }}</span>
<!-- 图片缩略图 + 点击放大 -->
@ -84,7 +84,15 @@
{{ groupNotificationText }}
</span>
<!-- 系统事件类FRIEND_*content 通常是结构化 JSON回退原始预览 -->
<!-- 好友会话事件FRIEND_ADD / FRIEND_DELETE固定中文文案 -->
<span
v-else-if="isFriendChatTipType"
class="text-12px text-[var(--el-text-color-secondary)]"
>
{{ friendChatTipText }}
</span>
<!-- 其它系统事件 / 未知类型content 通常是结构化 JSON回退原始预览 -->
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
</template>
@ -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<TextMessage>(props.content || '')?.content ?? ''
)
const imagePayload = computed(() =>
isImage.value ? parseMessage<ImageMessage>(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))

View File

@ -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,

View File

@ -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<TextMessage>(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)
}

View File

@ -8,9 +8,6 @@ import type { Message } from '../home/types'
// cn.iocoder.yudao.module.im.service.websocket.dto.message.* 下的 DTO。
// 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage<T>
// 序列化直接 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<TextMessage>(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 {

View File

@ -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 / 女 20 / null / undefined 一律不展示,对齐微信留白 */
export function getGenderIcon(sex?: number): string {
if (sex === 1) {