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

pull/881/head
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
* - 引用:已落库(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

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)) {
// 非当前会话且未免打扰:响一下提示音(带节流,详见 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()
}
}

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