refactor: 扁平化 IM WebSocket 通知推送 API

- 将 WebSocket 推送入口统一为 userId/userIds + conversationType + contentType + payload
- 移除业务侧 ImNotificationWebSocketDTO 构造和无会话专用发送入口
- 收敛私聊、群聊、频道、好友、加群申请、RTC 通知调用路径
- 精简 ImNotificationWebSocketDTO,仅保留统一外壳字段
- 保留群消息 payload 的 receiptStatus、readCount、receiverUserIds
- 更新相关单元测试,覆盖群消息通知 payload 字段
pull/884/MERGE
YunaiV 2026-06-16 11:38:56 +08:00
parent 2685bc357f
commit 4879c4705f
30 changed files with 452 additions and 365 deletions

View File

@ -20,7 +20,7 @@ export interface ImStatisticsTrendVO {
} }
export interface ImStatisticsMessageTypeVO { export interface ImStatisticsMessageTypeVO {
type: number // 参见 ImMessageTypeEnum 枚举类,由前端按 DICT_TYPE.IM_MESSAGE_TYPE 翻译 type: number // 参见 ImContentTypeEnum 枚举类,由前端按 DICT_TYPE.IM_CONTENT_TYPE 翻译
value: number value: number
} }
@ -50,7 +50,7 @@ export const getUserTrend = (days: number): Promise<ImStatisticsTrendVO> => {
return request.get<ImStatisticsTrendVO>({ url: '/im/manager/statistics/user-trend', params: { days } }) return request.get<ImStatisticsTrendVO>({ url: '/im/manager/statistics/user-trend', params: { days } })
} }
// 获得消息类型分布(最近 30 天) // 获得内容类型分布(最近 30 天)
export const getMessageTypeDistribution = (): Promise<ImStatisticsMessageTypeVO[]> => { export const getMessageTypeDistribution = (): Promise<ImStatisticsMessageTypeVO[]> => {
return request.get<ImStatisticsMessageTypeVO[]>({ return request.get<ImStatisticsMessageTypeVO[]>({
url: '/im/manager/statistics/message-type-distribution' url: '/im/manager/statistics/message-type-distribution'

View File

@ -6,7 +6,7 @@ export interface ImGroupMessageRespVO {
clientMessageId: string // 客户端消息编号 clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号 senderId: number // 发送人编号
groupId: number // 群编号 groupId: number // 群编号
type: number // 消息类型 type: number // 内容类型
content: string // 消息内容JSON 格式) content: string // 消息内容JSON 格式)
status: number // 消息状态 status: number // 消息状态
sendTime: string // 发送时间 sendTime: string // 发送时间
@ -20,7 +20,7 @@ export interface ImGroupMessageRespVO {
export interface ImGroupMessageSendReqVO { export interface ImGroupMessageSendReqVO {
clientMessageId: string // 客户端消息编号 clientMessageId: string // 客户端消息编号
groupId: number // 群编号 groupId: number // 群编号
type: number // 消息类型 type: number // 内容类型
content: string // 消息内容JSON 格式) content: string // 消息内容JSON 格式)
atUserIds?: number[] // @ 目标用户编号列表 atUserIds?: number[] // @ 目标用户编号列表
receipt?: boolean // 是否需要回执 receipt?: boolean // 是否需要回执

View File

@ -6,7 +6,7 @@ export interface ImPrivateMessageRespVO {
clientMessageId: string // 客户端消息编号 clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号 senderId: number // 发送人编号
receiverId: number // 接收人编号 receiverId: number // 接收人编号
type: number // 消息类型 type: number // 内容类型
content: string // 消息内容JSON 格式) content: string // 消息内容JSON 格式)
status: number // 消息状态(正常 / 已撤回) status: number // 消息状态(正常 / 已撤回)
receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成),对齐 ImMessageReceiptStatus receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成),对齐 ImMessageReceiptStatus
@ -17,7 +17,7 @@ export interface ImPrivateMessageRespVO {
export interface ImPrivateMessageSendReqVO { export interface ImPrivateMessageSendReqVO {
clientMessageId: string // 客户端消息编号 clientMessageId: string // 客户端消息编号
receiverId: number // 接收人编号 receiverId: number // 接收人编号
type: number // 消息类型 type: number // 内容类型
content: string // 消息内容JSON 格式) content: string // 消息内容JSON 格式)
receipt?: boolean // 是否需要回执;不传后端默认 true普通私聊用户消息 receipt?: boolean // 是否需要回执;不传后端默认 true普通私聊用户消息
} }

View File

@ -328,7 +328,7 @@ export enum DICT_TYPE {
MES_WM_PACKAGE_STATUS = 'mes_wm_package_status', // MES 装箱单状态 MES_WM_PACKAGE_STATUS = 'mes_wm_package_status', // MES 装箱单状态
// ========== IM - 即时通讯模块 ========== // ========== IM - 即时通讯模块 ==========
IM_MESSAGE_TYPE = 'im_message_type', // IM 消息类型 IM_CONTENT_TYPE = 'im_content_type', // IM 内容类型
IM_MESSAGE_STATUS = 'im_message_status', // IM 消息状态0=正常 / 2=已撤回(私聊 / 群聊共用) IM_MESSAGE_STATUS = 'im_message_status', // IM 消息状态0=正常 / 2=已撤回(私聊 / 群聊共用)
IM_MESSAGE_RECEIPT_STATUS = 'im_message_receipt_status', // IM 消息回执状态0=不需要 / 1=待完成 / 2=已完成 IM_MESSAGE_RECEIPT_STATUS = 'im_message_receipt_status', // IM 消息回执状态0=不需要 / 1=待完成 / 2=已完成
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态 IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态

View File

@ -115,7 +115,7 @@ import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import { useMessageSender } from '../../composables/useMessageSender' import { useMessageSender } from '../../composables/useMessageSender'
import { ImConversationType, ImMessageType, isGroupConversation } from '../../../utils/constants' import { ImConversationType, ImContentType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation' import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group' import { buildDefaultGroupName } from '../../../utils/group'
import { serializeMessage, type CardTarget } from '../../../utils/message' import { serializeMessage, type CardTarget } from '../../../utils/message'
@ -221,7 +221,7 @@ async function handleSend() {
sending.value = true sending.value = true
try { try {
const tasks = targets.map(async (conversation) => { const tasks = targets.map(async (conversation) => {
const cardOk = await sendRaw(ImMessageType.CARD, cardContent, { conversation }) const cardOk = await sendRaw(ImContentType.CARD, cardContent, { conversation })
if (!cardOk) { if (!cardOk) {
return { conversation, ok: false } return { conversation, ok: false }
} }
@ -289,7 +289,7 @@ async function handleCreateGroupAndSend() {
lastContent: '', lastContent: '',
lastSendTime: 0 lastSendTime: 0
} }
const cardOk = await sendRaw(ImMessageType.CARD, serializeMessage({ ...card }), { const cardOk = await sendRaw(ImContentType.CARD, serializeMessage({ ...card }), {
conversation: newConversation conversation: newConversation
}) })
if (!cardOk) { if (!cardOk) {

View File

@ -7,7 +7,7 @@ import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore' import { useMessageStore } from '../store/messageStore'
import { useMessageSender } from './useMessageSender' import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay' import { useMuteOverlay } from './useMuteOverlay'
import { ImMessageStatus, ImMessageType } from '../../utils/constants' import { ImMessageStatus, ImContentType } from '../../utils/constants'
import { import {
MESSAGE_FILE_MAX_MB, MESSAGE_FILE_MAX_MB,
MESSAGE_IMAGE_MAX_MB, MESSAGE_IMAGE_MAX_MB,
@ -56,17 +56,17 @@ export interface MediaTypeHandler {
/** 媒体类型注册表image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */ /** 媒体类型注册表image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */
export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = { export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
[ImMessageType.IMAGE]: { [ImContentType.IMAGE]: {
kind: '图片', kind: '图片',
build: (_file, url) => ({ url }) as ImageMessage, build: (_file, url) => ({ url }) as ImageMessage,
extractResendContext: () => ({}) extractResendContext: () => ({})
}, },
[ImMessageType.FILE]: { [ImContentType.FILE]: {
kind: '文件', kind: '文件',
build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage, build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
extractResendContext: () => ({}) extractResendContext: () => ({})
}, },
[ImMessageType.VOICE]: { [ImContentType.VOICE]: {
kind: '语音', kind: '语音',
build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage, build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
extractResendContext: (oldContent) => { extractResendContext: (oldContent) => {
@ -74,7 +74,7 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
return { voiceDuration: old?.duration ?? 0 } return { voiceDuration: old?.duration ?? 0 }
} }
}, },
[ImMessageType.VIDEO]: { [ImContentType.VIDEO]: {
kind: '视频', kind: '视频',
build: (file, url, context) => build: (file, url, context) =>
({ ({
@ -101,7 +101,7 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
/** 单次媒体上传的入参image / file / voice 走 uploadAndSendMediavideo 走低层 helper 自行组装) */ /** 单次媒体上传的入参image / file / voice 走 uploadAndSendMediavideo 走低层 helper 自行组装) */
export interface UploadAndSendMediaOptions { export interface UploadAndSendMediaOptions {
file: File file: File
/** 对齐 ImMessageTypemediaTypeHandlers 必须有对应项 */ /** 对齐 ImContentTypemediaTypeHandlers 必须有对应项 */
type: number type: number
/** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */ /** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
context?: MediaTypeContext context?: MediaTypeContext
@ -124,23 +124,23 @@ export interface UploadAndSendMediaOptions {
* *
* FAILEDMessageItem _localFile * FAILEDMessageItem _localFile
*/ */
/** 按消息类型映射体积上限MB未识别类型返回 0 表示不限 */ /** 按内容类型映射体积上限MB未识别类型返回 0 表示不限 */
function resolveMediaMaxMb(type: number): number { function resolveMediaMaxMb(type: number): number {
switch (type) { switch (type) {
case ImMessageType.IMAGE: case ImContentType.IMAGE:
return MESSAGE_IMAGE_MAX_MB return MESSAGE_IMAGE_MAX_MB
case ImMessageType.VIDEO: case ImContentType.VIDEO:
return MESSAGE_VIDEO_MAX_MB return MESSAGE_VIDEO_MAX_MB
case ImMessageType.VOICE: case ImContentType.VOICE:
return MESSAGE_VOICE_MAX_MB return MESSAGE_VOICE_MAX_MB
case ImMessageType.FILE: case ImContentType.FILE:
return MESSAGE_FILE_MAX_MB return MESSAGE_FILE_MAX_MB
default: default:
return 0 return 0
} }
} }
/** 校验媒体文件大小是否超过消息类型上限;超限触发 warn 并返回 false调用方不应进入占位 / 上传链路 */ /** 校验媒体文件大小是否超过内容类型上限;超限触发 warn 并返回 false调用方不应进入占位 / 上传链路 */
export function ensureMediaSizeWithinLimit( export function ensureMediaSizeWithinLimit(
file: File, file: File,
type: number, type: number,

View File

@ -23,7 +23,7 @@ import { pullMyConversationReadList as apiPullMyConversationReadList } from '@/a
import { import {
ImConversationType, ImConversationType,
ImMessageStatus, ImMessageStatus,
ImMessageType, ImContentType,
isFriendChatTip, isFriendChatTip,
isFriendNotification isFriendNotification
} from '../../utils/constants' } from '../../utils/constants'
@ -208,7 +208,7 @@ export const useMessagePuller = () => {
if (isPrivate) { if (isPrivate) {
const message = raw as ImPrivateMessageRespVO const message = raw as ImPrivateMessageRespVO
// 特殊:撤回消息的处理 // 特殊:撤回消息的处理
if (message.type === ImMessageType.RECALL) { if (message.type === ImContentType.RECALL) {
pulledMessages.push({ pulledMessages.push({
kind: 'recall', kind: 'recall',
conversationType: ImConversationType.PRIVATE, conversationType: ImConversationType.PRIVATE,
@ -235,7 +235,7 @@ export const useMessagePuller = () => {
} else { } else {
const message = raw as ImGroupMessageRespVO const message = raw as ImGroupMessageRespVO
// 特殊:撤回消息的处理 // 特殊:撤回消息的处理
if (message.type === ImMessageType.RECALL) { if (message.type === ImContentType.RECALL) {
pulledMessages.push({ pulledMessages.push({
kind: 'recall', kind: 'recall',
conversationType: ImConversationType.GROUP, conversationType: ImConversationType.GROUP,

View File

@ -19,7 +19,7 @@ import {
type QuoteMessage, type QuoteMessage,
type TextMessage type TextMessage
} from '../../utils/message' } from '../../utils/message'
import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants' import { ImContentType, ImMessageStatus, ImConversationType } from '../../utils/constants'
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config' import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
import { getClientConversationId } from '../../utils/db' import { getClientConversationId } from '../../utils/db'
import type { Conversation, Message } from '../types' import type { Conversation, Message } from '../types'
@ -193,7 +193,7 @@ export const useMessageSender = () => {
return false return false
} }
const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote) const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
return sendRaw(ImMessageType.TEXT, serializeMessage(payload), options) return sendRaw(ImContentType.TEXT, serializeMessage(payload), options)
} }
/** /**

View File

@ -107,7 +107,7 @@ import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
import { useGroupRequestStore } from '../../../../store/groupRequestStore' import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useImUiStore } from '../../../../store/uiStore' import { useImUiStore } from '../../../../store/uiStore'
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants' import { ImConversationType, ImContentType, isNormalMessage } from '../../../../../utils/constants'
import { getSenderDisplayName } from '@/views/im/utils/user' import { getSenderDisplayName } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation' import { buildRecallTip } from '@/views/im/utils/conversation'
import type { Conversation } from '../../../../types' import type { Conversation } from '../../../../types'
@ -183,7 +183,7 @@ const lastContentDisplay = computed(() => {
return draft.value.plain return draft.value.plain
} }
if ( if (
props.conversation.lastMessageType === ImMessageType.RECALL && props.conversation.lastMessageType === ImContentType.RECALL &&
props.conversation.lastSenderId != null props.conversation.lastSenderId != null
) { ) {
return buildRecallTip( return buildRecallTip(

View File

@ -2,7 +2,7 @@
<!-- <!--
表情面板 tabemoji / 个人表情 / N 个系统表情包 表情面板 tabemoji / 个人表情 / N 个系统表情包
- 对齐微信 PC底部 tab 栏切换面板内容emoji 保持 Unicode仍由 TEXT 通道发送 - 对齐微信 PC底部 tab 栏切换面板内容emoji 保持 Unicode仍由 TEXT 通道发送
- 个人表情 / 系统表情走 FACE 消息类型通过 select-face 事件由调用方走 sendRaw 发送 - 个人表情 / 系统表情走 FACE 内容类型通过 select-face 事件由调用方走 sendRaw 发送
- mode='emoji' 时只显示 emoji tab + 隐藏底部 tab 给留言 / 评论这类只发文本的场景用 - mode='emoji' 时只显示 emoji tab + 隐藏底部 tab 给留言 / 评论这类只发文本的场景用
- 定位由调用方决定通常浮在表情按钮上方 - 定位由调用方决定通常浮在表情按钮上方
--> -->

View File

@ -178,7 +178,7 @@ import {
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay' import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
import { isOpenableUrl } from '@/utils/url' import { isOpenableUrl } from '@/utils/url'
import { getConversationKey } from '@/views/im/utils/conversation' import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImGroupMemberRole, ImMessageType } from '@/views/im/utils/constants' import { ImConversationType, ImGroupMemberRole, ImContentType } from '@/views/im/utils/constants'
import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config'
import { import {
serializeMessage, serializeMessage,
@ -616,7 +616,7 @@ async function onSelectFace(face: { url: string; width: number; height: number;
{ url: face.url, width: face.width, height: face.height, name: face.name }, { url: face.url, width: face.width, height: face.height, name: face.name },
replyQuote replyQuote
) )
await sendRaw(ImMessageType.FACE, serializeMessage(payload), { conversation }) await sendRaw(ImContentType.FACE, serializeMessage(payload), { conversation })
} }
// ==================== @ ==================== // ==================== @ ====================
@ -862,7 +862,7 @@ async function uploadAndSendImage(file: File) {
} }
await uploadAndSendMedia({ await uploadAndSendMedia({
file, file,
type: ImMessageType.IMAGE, type: ImContentType.IMAGE,
quote: context.quote, quote: context.quote,
conversation: context.conversation conversation: context.conversation
}) })
@ -882,7 +882,7 @@ async function uploadAndSendFile(file: File) {
} }
await uploadAndSendMedia({ await uploadAndSendMedia({
file, file,
type: ImMessageType.FILE, type: ImContentType.FILE,
quote: context.quote, quote: context.quote,
conversation: context.conversation conversation: context.conversation
}) })
@ -924,7 +924,7 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) {
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type }) const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
await uploadAndSendMedia({ await uploadAndSendMedia({
file, file,
type: ImMessageType.VOICE, type: ImContentType.VOICE,
quote: context.quote, quote: context.quote,
conversation: context.conversation, conversation: context.conversation,
context: { voiceDuration: payload.duration } context: { voiceDuration: payload.duration }
@ -1034,7 +1034,7 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
* 4. 视频链路耗时长上传期间用户切会话则放弃发送避免落到错误会话里切走再切回来不算变化key 仍相等 * 4. 视频链路耗时长上传期间用户切会话则放弃发送避免落到错误会话里切走再切回来不算变化key 仍相等
*/ */
async function uploadAndSendVideo(file: File) { async function uploadAndSendVideo(file: File) {
if (!ensureMediaSizeWithinLimit(file, ImMessageType.VIDEO, message.warning)) { if (!ensureMediaSizeWithinLimit(file, ImContentType.VIDEO, message.warning)) {
return return
} }
const context = prepareMediaUpload() const context = prepareMediaUpload()
@ -1049,12 +1049,12 @@ async function uploadAndSendVideo(file: File) {
// <video poster> video blob 退 // <video poster> video blob 退
// cover probe URL commit patch_localFile file // cover probe URL commit patch_localFile file
// payload mediaTypeHandlers[VIDEO].build commit // payload mediaTypeHandlers[VIDEO].build commit
const videoHandler = requireMediaHandler(ImMessageType.VIDEO) const videoHandler = requireMediaHandler(ImContentType.VIDEO)
const buildPlaceholderContent = (blobUrl: string): string => const buildPlaceholderContent = (blobUrl: string): string =>
serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote)) serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote))
const { clientMessageId } = insertMediaPlaceholder({ const { clientMessageId } = insertMediaPlaceholder({
file, file,
type: ImMessageType.VIDEO, type: ImContentType.VIDEO,
conversation, conversation,
buildContent: buildPlaceholderContent buildContent: buildPlaceholderContent
}) })
@ -1123,7 +1123,7 @@ async function uploadAndSendVideo(file: File) {
} }
// 3.3 + muteOverlay useMediaUploader.uploadAndSendMedia // 3.3 + muteOverlay useMediaUploader.uploadAndSendMedia
if ( if (
!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId) !verifyMediaUploadStillAllowed(conversation, startKey, ImContentType.VIDEO, clientMessageId)
) { ) {
return return
} }
@ -1139,7 +1139,7 @@ async function uploadAndSendVideo(file: File) {
) )
) )
await commitMediaPlaceholder({ await commitMediaPlaceholder({
type: ImMessageType.VIDEO, type: ImContentType.VIDEO,
conversation, conversation,
clientMessageId, clientMessageId,
realContent realContent

View File

@ -157,7 +157,7 @@
v-else v-else
class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-light)]" class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-light)]"
> >
[不支持的消息类型] [不支持的内容类型]
</div> </div>
</template> </template>
@ -167,7 +167,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { formatFileSize } from '@/utils/file' import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime' import { formatSeconds } from '@/utils/formatTime'
import { ImMessageType } from '@/views/im/utils/constants' import { ImContentType } from '@/views/im/utils/constants'
import { MESSAGE_MERGE_PREVIEW_LINES } from '@/views/im/utils/config' import { MESSAGE_MERGE_PREVIEW_LINES } from '@/views/im/utils/config'
import { import {
parseMessage, parseMessage,
@ -193,7 +193,7 @@ import MaterialBubble from './MaterialBubble.vue'
defineOptions({ name: 'ImMessageBubble' }) defineOptions({ name: 'ImMessageBubble' })
const props = defineProps<{ const props = defineProps<{
/** 消息类型,对齐 ImMessageType */ /** 内容类型,对齐 ImContentType */
type: number type: number
/** 消息 contentJSON 字符串) */ /** 消息 contentJSON 字符串) */
content: string content: string
@ -213,15 +213,15 @@ const emit = defineEmits<{
}>() }>()
/** 各 type 判定 */ /** 各 type 判定 */
const isText = computed(() => props.type === ImMessageType.TEXT) const isText = computed(() => props.type === ImContentType.TEXT)
const isImage = computed(() => props.type === ImMessageType.IMAGE) const isImage = computed(() => props.type === ImContentType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE) const isFile = computed(() => props.type === ImContentType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE) const isVoice = computed(() => props.type === ImContentType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO) const isVideo = computed(() => props.type === ImContentType.VIDEO)
const isFace = computed(() => props.type === ImMessageType.FACE) const isFace = computed(() => props.type === ImContentType.FACE)
const isCard = computed(() => props.type === ImMessageType.CARD) const isCard = computed(() => props.type === ImContentType.CARD)
const isMerge = computed(() => props.type === ImMessageType.MERGE) const isMerge = computed(() => props.type === ImContentType.MERGE)
const isMaterial = computed(() => props.type === ImMessageType.MATERIAL) const isMaterial = computed(() => props.type === ImContentType.MATERIAL)
/** 媒体上传中uploadProgress 非 null 即视为上传中 */ /** 媒体上传中uploadProgress 非 null 即视为上传中 */
const isUploading = computed(() => props.uploadProgress != null) const isUploading = computed(() => props.uploadProgress != null)

View File

@ -194,7 +194,7 @@
<div class="mt-1.5"> <div class="mt-1.5">
<!-- 撤回单独走灰色 tipsender 名段可点击 --> <!-- 撤回单独走灰色 tipsender 名段可点击 -->
<div <div
v-if="message.type === ImMessageType.RECALL" v-if="message.type === ImContentType.RECALL"
class="text-sm italic text-[var(--el-text-color-secondary)]" class="text-sm italic text-[var(--el-text-color-secondary)]"
> >
<TipSegments :segments="recallTipSegmentsOf(message)" /> <TipSegments :segments="recallTipSegmentsOf(message)" />
@ -274,7 +274,7 @@ import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer' import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
import { import {
ImConversationType, ImConversationType,
ImMessageType, ImContentType,
isFriendChatTip, isFriendChatTip,
isGroupNotification isGroupNotification
} from '@/views/im/utils/constants' } from '@/views/im/utils/constants'
@ -506,11 +506,11 @@ function matchesActiveFilter(message: Message): boolean {
} }
switch (activeFilter.value.kind) { switch (activeFilter.value.kind) {
case 'file': case 'file':
return message.type === ImMessageType.FILE return message.type === ImContentType.FILE
case 'image': case 'image':
return message.type === ImMessageType.IMAGE return message.type === ImContentType.IMAGE
case 'voice': case 'voice':
return message.type === ImMessageType.VOICE return message.type === ImContentType.VOICE
case 'date': case 'date':
return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day
case 'member': case 'member':
@ -670,27 +670,27 @@ function textSnippetOf(message: Message): string {
return resolveFriendNotificationText(message) return resolveFriendNotificationText(message)
} }
switch (message.type) { switch (message.type) {
case ImMessageType.TEXT: case ImContentType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? '' return parseMessage<TextMessage>(message.content)?.content ?? ''
case ImMessageType.IMAGE: case ImContentType.IMAGE:
return '[图片]' return '[图片]'
case ImMessageType.FILE: case ImContentType.FILE:
return parseMessage<FileMessage>(message.content)?.name ?? '[文件]' return parseMessage<FileMessage>(message.content)?.name ?? '[文件]'
case ImMessageType.VOICE: case ImContentType.VOICE:
return '[语音]' return '[语音]'
case ImMessageType.VIDEO: case ImContentType.VIDEO:
return '[视频]' return '[视频]'
case ImMessageType.CARD: { case ImContentType.CARD: {
const card = parseMessage<CardMessage>(message.content) const card = parseMessage<CardMessage>(message.content)
return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}` return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}`
} }
case ImMessageType.FACE: case ImContentType.FACE:
return buildFacePreviewText(parseMessage<FaceMessage>(message.content)) return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
case ImMessageType.MERGE: { case ImContentType.MERGE: {
const merge = parseMessage<MergeMessage>(message.content) const merge = parseMessage<MergeMessage>(message.content)
return merge?.title ?? '[聊天记录]' return merge?.title ?? '[聊天记录]'
} }
case ImMessageType.RECALL: case ImContentType.RECALL:
return recallTipOf(message) return recallTipOf(message)
default: default:
return '' return ''

View File

@ -200,7 +200,7 @@ import { ElMessageBox } from 'element-plus'
import { import {
ImForwardMode, ImForwardMode,
ImMessageType, ImContentType,
ImMessageStatus, ImMessageStatus,
ImMessageReceiptStatus, ImMessageReceiptStatus,
ImConversationType, ImConversationType,
@ -304,7 +304,7 @@ const { confirm: confirmDialog, success: successMessage } = useMessage()
// legacy:true HTTP navigator.clipboard execCommand // legacy:true HTTP navigator.clipboard execCommand
const { copy: copyToClipboard } = useClipboard({ legacy: true }) const { copy: copyToClipboard } = useClipboard({ legacy: true })
// ==================== ==================== // ==================== ====================
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染;频道素材每条都显示 */ /** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染;频道素材每条都显示 */
const shouldShowTimeTip = computed(() => { const shouldShowTimeTip = computed(() => {
@ -321,15 +321,15 @@ const shouldShowTimeTip = computed(() => {
}) })
/** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble */ /** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble */
const isVoice = computed(() => props.message.type === ImMessageType.VOICE) const isVoice = computed(() => props.message.type === ImContentType.VOICE)
const isMerge = computed(() => props.message.type === ImMessageType.MERGE) const isMerge = computed(() => props.message.type === ImContentType.MERGE)
/** /**
* 频道素材在频道会话内仿微信公众号样式居中 + 无头像 * 频道素材在频道会话内仿微信公众号样式居中 + 无头像
* 私聊 / 群聊里被转发过来的素材按 selfSend 走标准气泡布局自己右对方左带头像 * 私聊 / 群聊里被转发过来的素材按 selfSend 走标准气泡布局自己右对方左带头像
*/ */
const isMaterial = computed( const isMaterial = computed(
() => () =>
props.message.type === ImMessageType.MATERIAL && props.message.type === ImContentType.MATERIAL &&
conversationStore.activeConversation?.type === ImConversationType.CHANNEL conversationStore.activeConversation?.type === ImConversationType.CHANNEL
) )
@ -342,7 +342,7 @@ const isChannelConversation = computed(
// tip + // tip +
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */ /** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL) const isRecall = computed(() => props.message.type === ImContentType.RECALL)
/** 撤回提示 segments依赖 activeConversation 实时算 sender 名 */ /** 撤回提示 segments依赖 activeConversation 实时算 sender 名 */
const recallTipSegments = computed(() => { const recallTipSegments = computed(() => {
@ -373,7 +373,7 @@ const groupNotificationSegments = computed(() =>
/** 私聊 RTC_CALL_END 走「准气泡」(左右分布 + 电话图标 + 文案);非私聊场景为 null */ /** 私聊 RTC_CALL_END 走「准气泡」(左右分布 + 电话图标 + 文案);非私聊场景为 null */
const rtcCallEndPrivatePayload = computed(() => { const rtcCallEndPrivatePayload = computed(() => {
if (props.message.type !== ImMessageType.RTC_CALL_END) { if (props.message.type !== ImContentType.RTC_CALL_END) {
return null return null
} }
const payload = parseRtcCallPayload(props.message.content) const payload = parseRtcCallPayload(props.message.content)
@ -388,11 +388,15 @@ const rtcCallPrivateBubbleText = computed(() =>
resolveRtcCallPrivateBubbleText(rtcCallEndPrivatePayload.value) resolveRtcCallPrivateBubbleText(rtcCallEndPrivatePayload.value)
) )
/** 是否会话内通话事件居中 tip仅群聊场景START 总是群聊END 私聊走气泡分支,群聊走 tip */ /** 是否会话内通话事件居中 tip */
const isRtcCallTipMessage = computed(() => { const isRtcCallTipMessage = computed(() => {
if (!isRtcCallTip(props.message.type)) { if (!isRtcCallTip(props.message.type)) {
return false return false
} }
const payload = parseRtcCallPayload(props.message.content)
if (payload?.conversationType !== ImConversationType.GROUP) {
return false
}
return !isRtcCallPrivateBubbleMessage.value return !isRtcCallPrivateBubbleMessage.value
}) })
@ -428,7 +432,7 @@ function handleMergeOpen(content: string) {
/** 文本气泡 @ mention 候选名字:仅群消息有效,按 atUserIds 反查群成员真实昵称;非 TEXT 不走 store 读,让 getMentionCandidates 直接返回稳定空数组 */ /** 文本气泡 @ mention 候选名字:仅群消息有效,按 atUserIds 反查群成员真实昵称;非 TEXT 不走 store 读,让 getMentionCandidates 直接返回稳定空数组 */
const textMentions = computed<MentionCandidate[]>(() => { const textMentions = computed<MentionCandidate[]>(() => {
if (props.message.type !== ImMessageType.TEXT) { if (props.message.type !== ImContentType.TEXT) {
return getMentionCandidates(undefined, null) return getMentionCandidates(undefined, null)
} }
return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation) return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation)
@ -656,7 +660,7 @@ async function handleContextMenu(e: MouseEvent) {
const items: MenuItem[] = [] const items: MenuItem[] = []
// //
if (props.message.type === ImMessageType.TEXT) { if (props.message.type === ImContentType.TEXT) {
items.push({ items.push({
key: MENU_KEYS.COPY, key: MENU_KEYS.COPY,
name: '复制', name: '复制',

View File

@ -106,7 +106,7 @@ import { formatFileSize } from '@/utils/file'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore' import { useMessageStore } from '../../../../store/messageStore'
import { getSenderDisplayName } from '@/views/im/utils/user' import { getSenderDisplayName } from '@/views/im/utils/user'
import { ImMessageType } from '@/views/im/utils/constants' import { ImContentType } from '@/views/im/utils/constants'
import { getClientConversationId } from '@/views/im/utils/db' import { getClientConversationId } from '@/views/im/utils/db'
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue' import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
import { import {
@ -165,7 +165,7 @@ const liveMessage = computed(() => {
}) })
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */ /** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
const isRecalled = computed(() => liveMessage.value?.type === ImMessageType.RECALL) const isRecalled = computed(() => liveMessage.value?.type === ImContentType.RECALL)
/** 渲染时实时算,与气泡上方显示名走同一套规则,避免备注变更后引用块陈旧 */ /** 渲染时实时算,与气泡上方显示名走同一套规则,避免备注变更后引用块陈旧 */
const senderName = computed(() => { const senderName = computed(() => {
@ -189,12 +189,12 @@ type AnyQuotePayload = Partial<
> >
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content)) const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
const isText = computed(() => props.quote.type === ImMessageType.TEXT) const isText = computed(() => props.quote.type === ImContentType.TEXT)
const isFile = computed(() => props.quote.type === ImMessageType.FILE) const isFile = computed(() => props.quote.type === ImContentType.FILE)
const isVoice = computed(() => props.quote.type === ImMessageType.VOICE) const isVoice = computed(() => props.quote.type === ImContentType.VOICE)
const isCard = computed(() => props.quote.type === ImMessageType.CARD) const isCard = computed(() => props.quote.type === ImContentType.CARD)
const isFace = computed(() => props.quote.type === ImMessageType.FACE) const isFace = computed(() => props.quote.type === ImContentType.FACE)
const isMaterial = computed(() => props.quote.type === ImMessageType.MATERIAL) const isMaterial = computed(() => props.quote.type === ImContentType.MATERIAL)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */ /** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => { const textPreview = computed(() => {
@ -211,13 +211,13 @@ const thumbnailUrl = computed<string | undefined>(() => {
return undefined return undefined
} }
const { type } = props.quote const { type } = props.quote
if (type === ImMessageType.IMAGE) { if (type === ImContentType.IMAGE) {
return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url
} }
if (type === ImMessageType.VIDEO || type === ImMessageType.MATERIAL) { if (type === ImContentType.VIDEO || type === ImContentType.MATERIAL) {
return parsedPayload.value?.coverUrl return parsedPayload.value?.coverUrl
} }
if (type === ImMessageType.FACE) { if (type === ImContentType.FACE) {
return parsedPayload.value?.url return parsedPayload.value?.url
} }
return undefined return undefined

View File

@ -160,7 +160,7 @@ import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMul
import { import {
ImConversationType, ImConversationType,
ImForwardMode, ImForwardMode,
ImMessageType, ImContentType,
type ImForwardModeValue type ImForwardModeValue
} from '@/views/im/utils/constants' } from '@/views/im/utils/constants'
import { MESSAGE_MERGE_PREVIEW_LINES } from '@/views/im/utils/config' import { MESSAGE_MERGE_PREVIEW_LINES } from '@/views/im/utils/config'
@ -300,7 +300,7 @@ async function forwardToTarget(target: Conversation): Promise<boolean> {
if (!content) { if (!content) {
return false return false
} }
return sendRaw(ImMessageType.MERGE, content, { conversation: target }) return sendRaw(ImContentType.MERGE, content, { conversation: target })
} }
for (const payload of cleanedSinglePayloads.value) { for (const payload of cleanedSinglePayloads.value) {
const ok = await sendRaw(payload.type, payload.content, { conversation: target }) const ok = await sendRaw(payload.type, payload.content, { conversation: target })

View File

@ -18,7 +18,7 @@ import {
ImConversationType, ImConversationType,
ImGroupMemberRole, ImGroupMemberRole,
ImMessageStatus, ImMessageStatus,
ImMessageType ImContentType
} from '../../utils/constants' } from '../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants' import { CommonStatusEnum } from '@/utils/constants'
import { getDb } from '../../utils/db' import { getDb } from '../../utils/db'
@ -603,37 +603,37 @@ export const useGroupStore = defineStore('imGroupStore', {
return return
} }
switch (type) { switch (type) {
case ImMessageType.GROUP_CREATE: case ImContentType.GROUP_CREATE:
this.applyGroupCreateNotification(groupId, payload) this.applyGroupCreateNotification(groupId, payload)
break break
case ImMessageType.GROUP_NAME_UPDATE: case ImContentType.GROUP_NAME_UPDATE:
this.applyGroupNameUpdateNotification(groupId, payload) this.applyGroupNameUpdateNotification(groupId, payload)
break break
case ImMessageType.GROUP_NOTICE_UPDATE: case ImContentType.GROUP_NOTICE_UPDATE:
this.applyGroupNoticeUpdateNotification(groupId, payload) this.applyGroupNoticeUpdateNotification(groupId, payload)
break break
case ImMessageType.GROUP_INFO_UPDATE: case ImContentType.GROUP_INFO_UPDATE:
this.applyGroupInfoUpdateNotification(groupId, payload) this.applyGroupInfoUpdateNotification(groupId, payload)
break break
case ImMessageType.GROUP_DISSOLVE: case ImContentType.GROUP_DISSOLVE:
this.removeGroup(groupId) this.removeGroup(groupId)
break break
case ImMessageType.GROUP_MEMBER_INVITE: case ImContentType.GROUP_MEMBER_INVITE:
this.applyGroupMemberInviteNotification(groupId, payload) this.applyGroupMemberInviteNotification(groupId, payload)
break break
case ImMessageType.GROUP_MEMBER_ENTER: case ImContentType.GROUP_MEMBER_ENTER:
this.applyGroupMemberEnterNotification(groupId, payload) this.applyGroupMemberEnterNotification(groupId, payload)
break break
case ImMessageType.GROUP_MEMBER_QUIT: case ImContentType.GROUP_MEMBER_QUIT:
this.applyGroupMemberQuitNotification(groupId, payload) this.applyGroupMemberQuitNotification(groupId, payload)
break break
case ImMessageType.GROUP_MEMBER_KICK: case ImContentType.GROUP_MEMBER_KICK:
this.applyGroupMemberKickNotification(groupId, payload) this.applyGroupMemberKickNotification(groupId, payload)
break break
case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE: case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE:
this.applyGroupMemberNicknameUpdateNotification(groupId, payload) this.applyGroupMemberNicknameUpdateNotification(groupId, payload)
break break
case ImMessageType.GROUP_ADMIN_ADD: case ImContentType.GROUP_ADMIN_ADD:
this.updateGroupMemberRoleList( this.updateGroupMemberRoleList(
groupId, groupId,
payload.memberUserIds || [], payload.memberUserIds || [],
@ -645,7 +645,7 @@ export const useGroupStore = defineStore('imGroupStore', {
refreshUnhandledGroupRequests() refreshUnhandledGroupRequests()
} }
break break
case ImMessageType.GROUP_ADMIN_REMOVE: case ImContentType.GROUP_ADMIN_REMOVE:
this.updateGroupMemberRoleList( this.updateGroupMemberRoleList(
groupId, groupId,
payload.memberUserIds || [], payload.memberUserIds || [],
@ -656,28 +656,28 @@ export const useGroupStore = defineStore('imGroupStore', {
refreshUnhandledGroupRequests() refreshUnhandledGroupRequests()
} }
break break
case ImMessageType.GROUP_OWNER_TRANSFER: case ImContentType.GROUP_OWNER_TRANSFER:
this.applyGroupOwnerTransferNotification(groupId, payload) this.applyGroupOwnerTransferNotification(groupId, payload)
break break
case ImMessageType.GROUP_MESSAGE_PIN: case ImContentType.GROUP_MESSAGE_PIN:
this.applyGroupMessagePinNotification(groupId, payload) this.applyGroupMessagePinNotification(groupId, payload)
break break
case ImMessageType.GROUP_MESSAGE_UNPIN: case ImContentType.GROUP_MESSAGE_UNPIN:
this.applyGroupMessageUnpinNotification(groupId, payload) this.applyGroupMessageUnpinNotification(groupId, payload)
break break
case ImMessageType.GROUP_MEMBER_MUTED: case ImContentType.GROUP_MEMBER_MUTED:
this.applyGroupMemberMutedNotification(groupId, payload) this.applyGroupMemberMutedNotification(groupId, payload)
break break
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED: case ImContentType.GROUP_MEMBER_CANCEL_MUTED:
this.applyGroupMemberCancelMutedNotification(groupId, payload) this.applyGroupMemberCancelMutedNotification(groupId, payload)
break break
case ImMessageType.GROUP_MUTED: case ImContentType.GROUP_MUTED:
this.updateGroupFields(groupId, { mutedAll: true }) this.updateGroupFields(groupId, { mutedAll: true })
break break
case ImMessageType.GROUP_CANCEL_MUTED: case ImContentType.GROUP_CANCEL_MUTED:
this.updateGroupFields(groupId, { mutedAll: false }) this.updateGroupFields(groupId, { mutedAll: false })
break break
case ImMessageType.GROUP_BANNED: case ImContentType.GROUP_BANNED:
this.updateGroupFields(groupId, { banned: !!payload.banned }) this.updateGroupFields(groupId, { banned: !!payload.banned })
break break
} }

View File

@ -6,7 +6,7 @@ import {
ImConversationType, ImConversationType,
ImMessageReceiptStatus, ImMessageReceiptStatus,
ImMessageStatus, ImMessageStatus,
ImMessageType, ImContentType,
isGroupNotification, isGroupNotification,
isNormalMessage isNormalMessage
} from '../../utils/constants' } from '../../utils/constants'
@ -436,7 +436,7 @@ export const useMessageStore = defineStore('imMessageStore', {
return null return null
} }
// 2. 更新消息和会话摘要 // 2. 更新消息和会话摘要
message.type = ImMessageType.RECALL message.type = ImContentType.RECALL
message.status = ImMessageStatus.RECALL message.status = ImMessageStatus.RECALL
message.content = '' message.content = ''
if (messages[messages.length - 1]?.id === messageId) { if (messages[messages.length - 1]?.id === messageId) {

View File

@ -6,7 +6,7 @@ import {
ImWebSocketMessageType, ImWebSocketMessageType,
ImMessageStatus, ImMessageStatus,
ImMessageReceiptStatus, ImMessageReceiptStatus,
ImMessageType, ImContentType,
ImConversationType, ImConversationType,
ImRtcCallMediaType, ImRtcCallMediaType,
ImRtcParticipantStatus, ImRtcParticipantStatus,
@ -47,15 +47,19 @@ import type { ImChannelMessageRespVO } from '@/api/im/message/channel'
import { buildChannelConversationStub } from '../../utils/channel' import { buildChannelConversationStub } from '../../utils/channel'
import type { import type {
WebSocketFrame, WebSocketFrame,
ImPrivateMessageDTO, ImNotificationWebSocketDTO,
ImGroupMessageDTO, ImNoConversationNotification,
ImPrivateMessageNotification,
ImGroupMessageNotification,
ImMessageReadNotification,
ImMessageReceiptNotification,
Message, Message,
Group Group
} from '../types' } from '../types'
/** FRIEND_DELETE 帧 payload 是否带 clear=trueclear 语义是清会话本身,跳过气泡渲染 */ /** FRIEND_DELETE 帧 payload 是否带 clear=trueclear 语义是清会话本身,跳过气泡渲染 */
const isFriendDeleteWithClear = (frame: ImPrivateMessageDTO): boolean => { const isFriendDeleteWithClear = (frame: ImPrivateMessageNotification): boolean => {
if (frame.type !== ImMessageType.FRIEND_DELETE) { if (frame.type !== ImContentType.FRIEND_DELETE) {
return false return false
} }
try { try {
@ -66,6 +70,16 @@ const isFriendDeleteWithClear = (frame: ImPrivateMessageDTO): boolean => {
} }
} }
/** 从私聊消息帧解析好友通知 payload */
const parseFriendNotificationPayload = (
frame: ImPrivateMessageNotification
): FriendNotificationPayload => JSON.parse(frame.content || '{}') as FriendNotificationPayload
/** 私聊消息帧是否可推断好友对端 */
const isPrivateMessageNotification = (
frame: ImPrivateMessageNotification | ImNoConversationNotification
): frame is ImPrivateMessageNotification => 'senderId' in frame && 'receiverId' in frame
const RTC_LIVEKIT_PROTOCOLS = new Set(['ws:', 'wss:', 'http:', 'https:']) const RTC_LIVEKIT_PROTOCOLS = new Set(['ws:', 'wss:', 'http:', 'https:'])
const RTC_MEDIA_TYPES = new Set<number>(Object.values(ImRtcCallMediaType)) const RTC_MEDIA_TYPES = new Set<number>(Object.values(ImRtcCallMediaType))
@ -105,7 +119,7 @@ function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean {
* utils/user / * utils/user /
*/ */
const convertPrivateMessage = ( const convertPrivateMessage = (
websocketMessage: ImPrivateMessageDTO, websocketMessage: ImPrivateMessageNotification,
currentUserId: number currentUserId: number
): Message => ({ ): Message => ({
id: websocketMessage.id, id: websocketMessage.id,
@ -126,7 +140,7 @@ const convertPrivateMessage = (
* receiptStatus / readCount UI * receiptStatus / readCount UI
*/ */
const convertGroupMessage = ( const convertGroupMessage = (
websocketMessage: ImGroupMessageDTO, websocketMessage: ImGroupMessageNotification,
currentUserId: number currentUserId: number
): Message => ({ ): Message => ({
id: websocketMessage.id, id: websocketMessage.id,
@ -150,7 +164,7 @@ const convertGroupMessage = (
* IM conversationStore / friendStore / groupStore * IM conversationStore / friendStore / groupStore
* *
* 1. / / / * 1. / / /
* 2. dispatchFrame dispatchPrivateFrame / dispatchGroupFrame ImMessageType * 2. dispatchFrame dispatchPrivateFrame / dispatchGroupFrame
* 3. conversationStore.loading=true pull useMessagePuller flushBuffer * 3. conversationStore.loading=true pull useMessagePuller flushBuffer
* 4. handle* conversation / friend / group store * 4. handle* conversation / friend / group store
* - TEXT / IMAGE / FILE / VOICE / VIDEO + / * - TEXT / IMAGE / FILE / VOICE / VIDEO + /
@ -168,8 +182,14 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
reconnectAttempts: 0, reconnectAttempts: 0,
heartbeatTimer: null as ReturnType<typeof setInterval> | null, heartbeatTimer: null as ReturnType<typeof setInterval> | null,
messageBuffer: [] as Array< messageBuffer: [] as Array<
| { conversationType: typeof ImConversationType.PRIVATE; payload: ImPrivateMessageDTO } | {
| { conversationType: typeof ImConversationType.GROUP; payload: ImGroupMessageDTO } conversationType: typeof ImConversationType.PRIVATE
payload: ImPrivateMessageNotification
}
| {
conversationType: typeof ImConversationType.GROUP
payload: ImGroupMessageNotification
}
| { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageRespVO } | { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageRespVO }
> // 初始化加载期内先把普通消息丢进缓冲区pull 完成后再一次性回放 > // 初始化加载期内先把普通消息丢进缓冲区pull 完成后再一次性回放
}), }),
@ -276,26 +296,60 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}, },
/** /**
* type / / * IM
* dispatchXxxFrame payload.typeImMessageType
*/ */
dispatchFrame(frame: WebSocketFrame) { dispatchFrame(frame: WebSocketFrame) {
const content = this.safeParse(frame.content) if (frame.type !== ImWebSocketMessageType.NOTIFICATION) {
if (!content) { console.debug('[IM WS] 未识别事件', frame)
return return
} }
switch (frame.type) {
case ImWebSocketMessageType.PRIVATE_MESSAGE: const notification = this.safeParse(frame.content) as ImNotificationWebSocketDTO | null
this.dispatchPrivateFrame(content as ImPrivateMessageDTO) if (!notification?.payload || !notification.contentType) {
return
}
const payload = {
...notification.payload,
type: notification.contentType
}
switch (notification.conversationType) {
case ImConversationType.PRIVATE:
this.dispatchPrivateFrame(payload as ImPrivateMessageNotification)
break break
case ImWebSocketMessageType.GROUP_MESSAGE: case ImConversationType.GROUP:
this.dispatchGroupFrame(content as ImGroupMessageDTO) this.dispatchGroupFrame(payload as ImGroupMessageNotification)
break break
case ImWebSocketMessageType.CHANNEL_MESSAGE: case ImConversationType.CHANNEL:
this.dispatchChannelFrame(content as ImChannelMessageRespVO) this.dispatchChannelFrame(payload as ImChannelMessageRespVO)
break
case ImConversationType.NONE:
this.dispatchNoConversationFrame(payload as ImNoConversationNotification)
break break
default: default:
console.debug('[IM WS] 未识别事件', frame) console.debug('[IM WS] 未识别通知', notification)
}
},
/**
*
*/
dispatchNoConversationFrame(websocketMessage: ImNoConversationNotification) {
if (isFriendNotification(websocketMessage.type)) {
this.handleFriendNotification(websocketMessage)
return
}
if (isGroupRequestNotification(websocketMessage.type)) {
this.handleGroupRequestNotification(websocketMessage)
return
}
switch (websocketMessage.type) {
case ImContentType.RTC_CALL:
case ImContentType.RTC_PARTICIPANT_CONNECTED:
case ImContentType.RTC_PARTICIPANT_DISCONNECTED:
this.handleRtcSignaling(websocketMessage)
break
default:
console.debug('[IM WS] 未识别无会话通知', websocketMessage)
} }
}, },
@ -303,7 +357,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* payload.type READ * payload.type READ
*/ */
dispatchChannelFrame(websocketMessage: ImChannelMessageRespVO) { dispatchChannelFrame(websocketMessage: ImChannelMessageRespVO) {
if (websocketMessage.type === ImMessageType.READ) { if (websocketMessage.type === ImContentType.READ) {
this.handleChannelRead(websocketMessage) this.handleChannelRead(websocketMessage)
return return
} }
@ -351,22 +405,28 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.type === ImConversationType.CHANNEL && conversationStore.activeConversation?.type === ImConversationType.CHANNEL &&
conversationStore.activeConversation?.targetId === websocketMessage.channelId conversationStore.activeConversation?.targetId === websocketMessage.channelId
// 频道单向订阅receiptStatus 表达「我是否已读这条」:会话打开即已读 DONE否则 PENDING与 pull 口径一致) // 频道单向订阅receiptStatus 表达「我是否已读这条」:会话打开即已读 DONE否则 PENDING与 pull 口径一致)
const persistPromise = messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), { const persistPromise = messageStore.insertMessage(
id: websocketMessage.id, buildChannelConversationStub(websocketMessage.channelId),
clientMessageId: '', {
type: websocketMessage.type, id: websocketMessage.id,
content: websocketMessage.content, clientMessageId: '',
status: ImMessageStatus.NORMAL, type: websocketMessage.type,
receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING, content: websocketMessage.content,
sendTime: sendTimeMs, status: ImMessageStatus.NORMAL,
senderId: 0, receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING,
targetId: websocketMessage.channelId, sendTime: sendTimeMs,
selfSend: false, senderId: 0,
materialId: websocketMessage.materialId targetId: websocketMessage.channelId,
}) selfSend: false,
materialId: websocketMessage.materialId
}
)
if (isActive) { if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
conversationStore.markConversationRead(ImConversationType.CHANNEL, websocketMessage.channelId) conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId
)
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => { apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 频道自动已读上报失败', e) console.warn('[IM WS] 频道自动已读上报失败', e)
}) })
@ -396,44 +456,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// ==================== 普通消息 ==================== // ==================== 普通消息 ====================
/** /**
* payload.typeImMessageType / / / * payload.typeImContentType / / /
* *
* ImPrivateMessageDTO ofRead / ofReceipt / ofFriendNotification / ofSend * contentType
*/ */
dispatchPrivateFrame(websocketMessage: ImPrivateMessageDTO) { dispatchPrivateFrame(websocketMessage: ImPrivateMessageNotification) {
try { try {
switch (websocketMessage.type) { switch (websocketMessage.type) {
case ImMessageType.READ: case ImContentType.READ:
this.handlePrivateRead(websocketMessage) this.handlePrivateRead(websocketMessage as ImMessageReadNotification)
break break
case ImMessageType.RECEIPT: case ImContentType.RECEIPT:
this.handlePrivateReceipt(websocketMessage) this.handlePrivateReceipt(websocketMessage as ImMessageReceiptNotification)
break break
case ImMessageType.RTC_CALL: case ImContentType.RTC_CALL_END:
case ImMessageType.RTC_PARTICIPANT_CONNECTED:
case ImMessageType.RTC_PARTICIPANT_DISCONNECTED:
this.handleRtcSignaling(websocketMessage)
break
case ImMessageType.RTC_CALL_END:
// 入库 + 关闭通话窗 + 渲染聊天 tip私聊场景 // 入库 + 关闭通话窗 + 渲染聊天 tip私聊场景
this.handleRtcCallEnd(websocketMessage) this.handleRtcCallEnd(websocketMessage)
ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage)) ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
break break
default: default:
if (isFriendNotification(websocketMessage.type)) { if (isFriendChatTip(websocketMessage.type)) {
this.handleFriendNotification(websocketMessage) this.handleFriendNotification(websocketMessage)
// FRIEND_ADD / FRIEND_DELETE同时作为会话事件气泡插入消息列表
// becomeFriends 入库帧 + silent / delete 单边推送帧统一走入库去重路径,前端按 type 渲染灰色提示);
// FRIEND_DELETE 的 clear=true 语义是清会话本身,跳过气泡避免在已清会话里写入虚拟消息 // FRIEND_DELETE 的 clear=true 语义是清会话本身,跳过气泡避免在已清会话里写入虚拟消息
if ( if (!isFriendDeleteWithClear(websocketMessage)) {
isFriendChatTip(websocketMessage.type) &&
!isFriendDeleteWithClear(websocketMessage)
) {
ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage)) ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
} }
} else if (isGroupRequestNotification(websocketMessage.type)) {
// 加群申请通知1503 / 1505 / 1506走私聊通道与好友通知同段位但分开 dispatcher
this.handleGroupRequestNotification(websocketMessage)
} else { } else {
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息 // TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage)) ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
@ -446,27 +493,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}, },
/** /**
* payload.typeImMessageType / / / * payload.typeImContentType / / /
* *
* 1530 GROUP_MEMBER_SETTING_UPDATE + 1501-1520 广 handleGroupMessage + applyGroupNotification * 1530 GROUP_MEMBER_SETTING_UPDATE + 1501-1520 广 handleGroupMessage + applyGroupNotification
*/ */
dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) { dispatchGroupFrame(websocketMessage: ImGroupMessageNotification) {
try { try {
switch (websocketMessage.type) { switch (websocketMessage.type) {
case ImMessageType.READ: case ImContentType.READ:
this.handleGroupRead(websocketMessage) this.handleGroupRead(websocketMessage as ImMessageReadNotification)
break break
case ImMessageType.RECEIPT: case ImContentType.RECEIPT:
this.handleGroupReceipt(websocketMessage) this.handleGroupReceipt(websocketMessage as ImMessageReceiptNotification)
break break
case ImMessageType.GROUP_MEMBER_SETTING_UPDATE: case ImContentType.GROUP_MEMBER_SETTING_UPDATE:
this.handleGroupMemberSettingUpdate(websocketMessage) this.handleGroupMemberSettingUpdate(websocketMessage)
break break
case ImMessageType.RTC_CALL_START: case ImContentType.RTC_CALL_START:
// 入库 + 渲染聊天 tip胶囊条状态走 1602/1603本帧不动 rtcStore避免与首次填充竞争 // 入库 + 渲染聊天 tip胶囊条状态走 1602/1603本帧不动 rtcStore避免与首次填充竞争
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage)) ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
break break
case ImMessageType.RTC_CALL_END: case ImContentType.RTC_CALL_END:
// 入库 + 移除胶囊条 + 关闭通话窗(如果当前在该群通话内) // 入库 + 移除胶囊条 + 关闭通话窗(如果当前在该群通话内)
this.handleRtcCallEnd(websocketMessage) this.handleRtcCallEnd(websocketMessage)
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage)) ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
@ -491,7 +538,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* 4. Message * 4. Message
* 5. * 5.
*/ */
handlePrivateMessage(websocketMessage: ImPrivateMessageDTO): Promise<void> { handlePrivateMessage(websocketMessage: ImPrivateMessageNotification): Promise<void> {
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const currentUserId = getCurrentUserId() const currentUserId = getCurrentUserId()
@ -527,9 +574,9 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 会话标题永远跟「对端」走(不管谁发的消息);这里只算一次给 insertMessage 用 // 会话标题永远跟「对端」走(不管谁发的消息);这里只算一次给 insertMessage 用
const peerDisplayName = friend ? getFriendDisplayName(friend) : '' const peerDisplayName = friend ? getFriendDisplayName(friend) : ''
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage // 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImContentTypeEnum.RECALL → RecallMessage
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态),不让它作为新消息进列表 // 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态),不让它作为新消息进列表
if (websocketMessage.type === ImMessageType.RECALL) { if (websocketMessage.type === ImContentType.RECALL) {
return useMessageStore().recallMessage( return useMessageStore().recallMessage(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
@ -574,10 +621,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}, },
/** 私聊 READ 事件:自己的其它终端在对方会话里标为已读,本端同步清零未读;私聊已读关闭时兜底忽略 */ /** 私聊 READ 事件:自己的其它终端在对方会话里标为已读,本端同步清零未读;私聊已读关闭时兜底忽略 */
handlePrivateRead(websocketMessage: ImPrivateMessageDTO) { handlePrivateRead(websocketMessage: ImMessageReadNotification) {
if (!MESSAGE_PRIVATE_READ_ENABLED) { if (!MESSAGE_PRIVATE_READ_ENABLED) {
return return
} }
if (!websocketMessage.id || !websocketMessage.receiverId) {
return
}
void useMessageStore() void useMessageStore()
.applyConversationReadList([ .applyConversationReadList([
{ {
@ -592,16 +642,19 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/** /**
* RECEIPT * RECEIPT
* maxReadId DTO id ImPrivateMessageDTO.ofReceipt * maxReadId id
* "回执在路上时刚发的消息" * "回执在路上时刚发的消息"
*/ */
handlePrivateReceipt(websocketMessage: ImPrivateMessageDTO) { handlePrivateReceipt(websocketMessage: ImMessageReceiptNotification) {
if (!MESSAGE_PRIVATE_READ_ENABLED) { if (!MESSAGE_PRIVATE_READ_ENABLED) {
return return
} }
if (!websocketMessage.id) { if (!websocketMessage.id) {
return return
} }
if (!websocketMessage.senderId) {
return
}
useMessageStore().applyMessageReadReceipt({ useMessageStore().applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE, conversationType: ImConversationType.PRIVATE,
targetId: websocketMessage.senderId, targetId: websocketMessage.senderId,
@ -619,7 +672,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* 4. Message + at * 4. Message + at
* 5. lastMessageId * 5. lastMessageId
*/ */
handleGroupMessage(websocketMessage: ImGroupMessageDTO): Promise<void> { handleGroupMessage(websocketMessage: ImGroupMessageNotification): Promise<void> {
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const currentUserId = getCurrentUserId() const currentUserId = getCurrentUserId()
@ -656,7 +709,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}` // 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态) // 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态)
if (websocketMessage.type === ImMessageType.RECALL) { if (websocketMessage.type === ImContentType.RECALL) {
return useMessageStore().recallMessage( return useMessageStore().recallMessage(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
@ -688,10 +741,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) { if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零 // 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
conversationStore.markConversationRead( conversationStore.markConversationRead(ImConversationType.GROUP, websocketMessage.groupId)
ImConversationType.GROUP,
websocketMessage.groupId
)
if (MESSAGE_GROUP_READ_ENABLED) { if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e) console.warn('[IM WS] 自动已读上报失败', e)
@ -708,11 +758,14 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// ==================== 群聊已读 / 回执 ==================== // ==================== 群聊已读 / 回执 ====================
/** 群聊 READ自己其它终端在某群里标为已读本端同步清零该群未读 + @ 红字;群已读关闭时兜底忽略 */ /** 群聊 READ自己其它终端在某群里标为已读本端同步清零该群未读 + @ 红字;群已读关闭时兜底忽略 */
handleGroupRead(websocketMessage: ImGroupMessageDTO) { handleGroupRead(websocketMessage: ImMessageReadNotification) {
if (!MESSAGE_GROUP_READ_ENABLED) { if (!MESSAGE_GROUP_READ_ENABLED) {
return return
} }
const readMessageId = websocketMessage.readId || websocketMessage.id const readMessageId = websocketMessage.readId || websocketMessage.id
if (!readMessageId || !websocketMessage.groupId) {
return
}
void useMessageStore() void useMessageStore()
.applyConversationReadList([ .applyConversationReadList([
{ {
@ -726,10 +779,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}, },
/** 群聊 RECEIPT更新某条群消息的 readCount / receiptStatus群已读关闭时兜底忽略 */ /** 群聊 RECEIPT更新某条群消息的 readCount / receiptStatus群已读关闭时兜底忽略 */
handleGroupReceipt(websocketMessage: ImGroupMessageDTO) { handleGroupReceipt(websocketMessage: ImMessageReceiptNotification) {
if (!MESSAGE_GROUP_READ_ENABLED) { if (!MESSAGE_GROUP_READ_ENABLED) {
return return
} }
if (!websocketMessage.id || !websocketMessage.groupId) {
return
}
useMessageStore().applyMessageReadReceipt({ useMessageStore().applyMessageReadReceipt({
conversationType: ImConversationType.GROUP, conversationType: ImConversationType.GROUP,
targetId: websocketMessage.groupId, targetId: websocketMessage.groupId,
@ -739,59 +795,63 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}) })
}, },
// ==================== 好友通知1201-1210 段位,承载于私聊通道 ==================== // ==================== 好友通知1201-1210 段位 ====================
/** /**
* FRIEND_ADD / FRIEND_DELETE userId * FRIEND_ADD / FRIEND_DELETE userId
* becomeFriends payloadpayload.friendUserId toUserId sender / receiver * becomeFriends payloadpayload.friendUserId toUserId sender / receiver
*/ */
computeFriendPeerId(frame: ImPrivateMessageDTO): number { computeFriendPeerId(frame: ImPrivateMessageNotification): number {
const currentUserId = getCurrentUserId() const currentUserId = getCurrentUserId()
return getPrivateMessagePeerId(frame, currentUserId) return getPrivateMessagePeerId(frame, currentUserId)
}, },
/** /**
* content payload type friendStore dispatcher * type friendStore dispatcher
*
* ImPrivateMessageDTO.ofFriendNotification
* payload BaseFriendNotification FriendRequestNotification / FriendAddNotification
*/ */
handleFriendNotification(websocketMessage: ImPrivateMessageDTO) { handleFriendNotification(
// content 解析失败由外层 dispatchPrivateFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch websocketMessage: ImPrivateMessageNotification | ImNoConversationNotification
const payload = JSON.parse(websocketMessage.content || '{}') as FriendNotificationPayload ) {
const payload = isPrivateMessageNotification(websocketMessage)
? parseFriendNotificationPayload(websocketMessage)
: (websocketMessage as unknown as FriendNotificationPayload)
const friendStore = useFriendStore() const friendStore = useFriendStore()
switch (websocketMessage.type) { switch (websocketMessage.type) {
case ImMessageType.FRIEND_REQUEST_RECEIVED: case ImContentType.FRIEND_REQUEST_RECEIVED:
friendStore.applyFriendRequestReceivedNotification(payload) friendStore.applyFriendRequestReceivedNotification(payload)
break break
case ImMessageType.FRIEND_REQUEST_APPROVED: case ImContentType.FRIEND_REQUEST_APPROVED:
friendStore.applyFriendRequestApprovedNotification(payload) friendStore.applyFriendRequestApprovedNotification(payload)
break break
case ImMessageType.FRIEND_REQUEST_REJECTED: case ImContentType.FRIEND_REQUEST_REJECTED:
friendStore.applyFriendRequestRejectedNotification(payload) friendStore.applyFriendRequestRejectedNotification(payload)
break break
case ImMessageType.FRIEND_ADD: case ImContentType.FRIEND_ADD:
friendStore.applyFriendAddNotification( friendStore.applyFriendAddNotification(
payload, payload,
this.computeFriendPeerId(websocketMessage) isPrivateMessageNotification(websocketMessage)
? this.computeFriendPeerId(websocketMessage)
: payload.friendUserId
) )
break break
case ImMessageType.FRIEND_DELETE: case ImContentType.FRIEND_DELETE:
friendStore.applyFriendDeleteNotification( friendStore.applyFriendDeleteNotification(
payload, payload,
this.computeFriendPeerId(websocketMessage) isPrivateMessageNotification(websocketMessage)
? this.computeFriendPeerId(websocketMessage)
: payload.friendUserId
) )
break break
case ImMessageType.FRIEND_BLOCK: case ImContentType.FRIEND_BLOCK:
friendStore.applyFriendBlockNotification(payload) friendStore.applyFriendBlockNotification(payload)
break break
case ImMessageType.FRIEND_UNBLOCK: case ImContentType.FRIEND_UNBLOCK:
friendStore.applyFriendUnblockNotification(payload) friendStore.applyFriendUnblockNotification(payload)
break break
case ImMessageType.FRIEND_INFO_UPDATED: case ImContentType.FRIEND_INFO_UPDATED:
friendStore.applyFriendInfoUpdatedNotification(payload) friendStore.applyFriendInfoUpdatedNotification(payload)
break break
case ImMessageType.FRIEND_UPDATE: case ImContentType.FRIEND_UPDATE:
friendStore.applyFriendUpdateNotification(payload) friendStore.applyFriendUpdateNotification(payload)
break break
default: default:
@ -799,29 +859,23 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
} }
}, },
// ==================== 加群申请通知1503 / 1505 / 1506,承载于私聊通道 ==================== // ==================== 加群申请通知1503 / 1505 / 1506 ====================
/** /**
* groupRequestStore + Drawer * groupRequestStore + Drawer
*
* ImPrivateMessageDTO.ofGroupNotification
* - 1503admin push unhandledList
* - 1505 / 1506admin unhandledList 1509 / 1510
*/ */
handleGroupRequestNotification(websocketMessage: ImPrivateMessageDTO) { handleGroupRequestNotification(websocketMessage: ImNoConversationNotification) {
const payload = JSON.parse(websocketMessage.content || '{}') as { const payload = websocketMessage as { requestId?: number }
requestId?: number
}
if (!payload.requestId) { if (!payload.requestId) {
return return
} }
const groupRequestStore = useGroupRequestStore() const groupRequestStore = useGroupRequestStore()
switch (websocketMessage.type) { switch (websocketMessage.type) {
case ImMessageType.GROUP_REQUEST_RECEIVED: case ImContentType.GROUP_REQUEST_RECEIVED:
groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined) groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined)
break break
case ImMessageType.GROUP_REQUEST_APPROVED: case ImContentType.GROUP_REQUEST_APPROVED:
case ImMessageType.GROUP_REQUEST_REJECTED: case ImContentType.GROUP_REQUEST_REJECTED:
groupRequestStore.removeGroupRequestById(payload.requestId) groupRequestStore.removeGroupRequestById(payload.requestId)
break break
default: default:
@ -836,7 +890,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* *
* payload null fetchGroupMemberList * payload null fetchGroupMemberList
*/ */
handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) { handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageNotification) {
// content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch // content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch
const payload: { silent?: boolean; groupRemark?: string } = JSON.parse( const payload: { silent?: boolean; groupRemark?: string } = JSON.parse(
websocketMessage.content || '{}' websocketMessage.content || '{}'
@ -937,16 +991,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/** /**
* 1601 RTC_CALL status INVITING / JOINED / REJECTED / NO_ANSWER / LEFT+ 1602 / 1603 / * 1601 RTC_CALL status INVITING / JOINED / REJECTED / NO_ANSWER / LEFT+ 1602 / 1603 /
* <p> * <p>
* dispatcher JSON mirror handleFriendNotification * dispatchertype rtcStore
*/ */
handleRtcSignaling(websocketMessage: ImPrivateMessageDTO) { handleRtcSignaling(websocketMessage: ImNoConversationNotification) {
const rtcStore = useRtcStore() const rtcStore = useRtcStore()
switch (websocketMessage.type) { switch (websocketMessage.type) {
case ImMessageType.RTC_CALL: { case ImContentType.RTC_CALL: {
const payload = this.safeParse(websocketMessage.content) as ImRtcCallNotification | null const payload = websocketMessage as unknown as ImRtcCallNotification
if (!payload) {
return
}
switch (payload.status) { switch (payload.status) {
case ImRtcParticipantStatus.INVITING: case ImRtcParticipantStatus.INVITING:
if (!isValidRtcInvitePayload(payload)) { if (!isValidRtcInvitePayload(payload)) {
@ -974,19 +1025,15 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
} }
return return
} }
case ImMessageType.RTC_PARTICIPANT_CONNECTED: { case ImContentType.RTC_PARTICIPANT_CONNECTED: {
const payload = this.safeParse( const payload = websocketMessage as unknown as ImRtcParticipantConnectedNotification
websocketMessage.content
) as ImRtcParticipantConnectedNotification | null
if (payload?.room && payload.userId) { if (payload?.room && payload.userId) {
rtcStore.applyParticipantConnected(payload) rtcStore.applyParticipantConnected(payload)
} }
return return
} }
case ImMessageType.RTC_PARTICIPANT_DISCONNECTED: { case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
const payload = this.safeParse( const payload = websocketMessage as unknown as ImRtcParticipantDisconnectedNotification
websocketMessage.content
) as ImRtcParticipantDisconnectedNotification | null
if (payload?.room && payload.userId) { if (payload?.room && payload.userId) {
rtcStore.applyParticipantDisconnected(payload) rtcStore.applyParticipantDisconnected(payload)
} }
@ -1000,7 +1047,9 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* *
* *
*/ */
handleRtcCallEnd(websocketMessage: ImPrivateMessageDTO | ImGroupMessageDTO) { handleRtcCallEnd(
websocketMessage: ImPrivateMessageNotification | ImGroupMessageNotification
) {
const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null
if (!payload?.room) { if (!payload?.room) {
return return
@ -1008,7 +1057,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const rtcStore = useRtcStore() const rtcStore = useRtcStore()
const isGroup = payload.conversationType === ImConversationType.GROUP const isGroup = payload.conversationType === ImConversationType.GROUP
// 群通话:移除胶囊条(按外层 groupId 取,不依赖 payload // 群通话:移除胶囊条(按外层 groupId 取,不依赖 payload
const groupId = (websocketMessage as ImGroupMessageDTO).groupId const groupId = (websocketMessage as ImGroupMessageNotification).groupId
if (isGroup && groupId) { if (isGroup && groupId) {
rtcStore.removeGroupCall(groupId) rtcStore.removeGroupCall(groupId)
} }

View File

@ -6,26 +6,39 @@ export interface WebSocketFrame {
content: string // 帧内容JSON 字符串) content: string // 帧内容JSON 字符串)
} }
// 私聊消息 DTO对齐后端 ImPrivateMessageDTO // IM WebSocket 通知 DTO对齐后端 ImNotificationWebSocketDTO
export interface ImPrivateMessageDTO { export interface ImNotificationWebSocketDTO {
conversationType: number // 会话类型
contentType: number // 内容类型
payload: Record<string, any> // 负载数据
}
// 无会话在线通知(对齐后端 conversationType = NONE 的独立 payload
export interface ImNoConversationNotification {
type: number // 内容类型
[key: string]: any
}
// 私聊消息 DTO对齐后端 ImPrivateMessageNotification
export interface ImPrivateMessageNotification {
id: number // 消息编号 id: number // 消息编号
clientMessageId: string // 客户端消息编号 clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号 senderId: number // 发送人编号
receiverId: number // 接收人编号 receiverId: number // 接收人编号
type: number // 消息类型 type: number // 内容类型
content: string // 消息内容 content: string // 消息内容
status: number // 消息状态 status: number // 消息状态
receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成) receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成)
sendTime: string // 发送时间 sendTime: string // 发送时间
} }
// 群聊消息 DTO对齐后端 ImGroupMessageDTO // 群聊消息 DTO对齐后端 ImGroupMessageNotification
export interface ImGroupMessageDTO { export interface ImGroupMessageNotification {
id: number // 消息编号 id: number // 消息编号
clientMessageId: string // 客户端消息编号 clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号 senderId: number // 发送人编号
groupId: number // 群编号 groupId: number // 群编号
type: number // 消息类型 type: number // 内容类型
content: string // 消息内容 content: string // 消息内容
status: number // 消息状态 status: number // 消息状态
sendTime: string // 发送时间 sendTime: string // 发送时间
@ -36,13 +49,35 @@ export interface ImGroupMessageDTO {
readId?: number // 已读位置 readId?: number // 已读位置
} }
// 消息已读同步通知(对齐后端 ImMessageReadNotification
export interface ImMessageReadNotification {
id: number // 已读位置
type: number // 内容类型
senderId?: number // 发送人编号
receiverId?: number // 私聊接收人编号
groupId?: number // 群编号
channelId?: number // 频道编号
readId?: number // 已读位置
}
// 消息回执通知(对齐后端 ImMessageReceiptNotification
export interface ImMessageReceiptNotification {
id: number // 消息编号
type: number // 内容类型
senderId?: number // 已读方用户编号
receiverId?: number // 私聊接收人编号
groupId?: number // 群编号
readCount?: number // 群回执已读人数
receiptStatus?: number // 群回执状态
}
// ==================== 本地会话 / 消息结构 ==================== // ==================== 本地会话 / 消息结构 ====================
/** 引用消息 */ /** 引用消息 */
export interface QuoteMessage { export interface QuoteMessage {
messageId: number // 引用消息编号 messageId: number // 引用消息编号
senderId: number // 引用消息发送人编号 senderId: number // 引用消息发送人编号
type: number // 引用消息类型 type: number // 引用内容类型
content: string // 引用消息内容 content: string // 引用消息内容
} }
@ -61,7 +96,7 @@ export interface Conversation {
lastContent: string // 会话列表展示的最后一条消息摘要 lastContent: string // 会话列表展示的最后一条消息摘要
lastSendTime: number // 最后一条消息时间,用于排序 lastSendTime: number // 最后一条消息时间,用于排序
lastSenderId?: number // 发送人编号 lastSenderId?: number // 发送人编号
lastMessageType?: number // 消息类型,对齐 ImMessageType lastMessageType?: number // 内容类型,对齐 ImContentType
lastMessageId?: number // 最后一条服务端消息编号 lastMessageId?: number // 最后一条服务端消息编号
lastClientMessageId?: string // 最后一条客户端消息编号 lastClientMessageId?: string // 最后一条客户端消息编号
lastMessageStatus?: number // 最后一条消息状态 lastMessageStatus?: number // 最后一条消息状态
@ -84,10 +119,10 @@ export interface Conversation {
// 消息数据结构 // 消息数据结构
export interface Message { export interface Message {
// ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO ========== // ========== 后端字段(对齐 ImPrivateMessageNotification / ImGroupMessageNotification ==========
id?: number // 服务端消息编号,发送中为空 id?: number // 服务端消息编号,发送中为空
clientMessageId: string // 客户端消息编号,本地生成用于合并去重 clientMessageId: string // 客户端消息编号,本地生成用于合并去重
type: number // 消息类型,对齐 ImMessageType type: number // 内容类型,对齐 ImContentType
content: string // 消息内容JSON 字符串 content: string // 消息内容JSON 字符串
status: number // 消息状态,对齐 ImMessageStatus status: number // 消息状态,对齐 ImMessageStatus
sendTime: number // 发送时间(前端转毫秒时间戳;后端为 LocalDateTime 字符串) sendTime: number // 发送时间(前端转毫秒时间戳;后端为 LocalDateTime 字符串)

View File

@ -85,19 +85,19 @@
<!-- 控制类消息撤回 / 已读 / 回执 --> <!-- 控制类消息撤回 / 已读 / 回执 -->
<span <span
v-else-if="props.type === ImMessageType.RECALL" v-else-if="props.type === ImContentType.RECALL"
class="text-12px text-[var(--el-text-color-secondary)]" class="text-12px text-[var(--el-text-color-secondary)]"
> >
[消息已撤回] [消息已撤回]
</span> </span>
<span <span
v-else-if="props.type === ImMessageType.READ" v-else-if="props.type === ImContentType.READ"
class="text-12px text-[var(--el-text-color-secondary)]" class="text-12px text-[var(--el-text-color-secondary)]"
> >
[已读回执] [已读回执]
</span> </span>
<span <span
v-else-if="props.type === ImMessageType.RECEIPT" v-else-if="props.type === ImContentType.RECEIPT"
class="text-12px text-[var(--el-text-color-secondary)]" class="text-12px text-[var(--el-text-color-secondary)]"
> >
[回执] [回执]
@ -139,7 +139,7 @@ import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime' import { formatSeconds } from '@/utils/formatTime'
import { DICT_TYPE, getDictLabel } from '@/utils/dict' import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { import {
ImMessageType, ImContentType,
ImRtcCallEndReason, ImRtcCallEndReason,
ImRtcCallMediaType, ImRtcCallMediaType,
isFriendChatTip, isFriendChatTip,
@ -169,7 +169,7 @@ import { openSafeUrl } from '@/utils/url'
defineOptions({ name: 'ImMessageContentPreview' }) defineOptions({ name: 'ImMessageContentPreview' })
const props = defineProps<{ const props = defineProps<{
/** 消息类型,对应 ImMessageType */ /** 内容类型,对应 ImContentType */
type?: number type?: number
/** 消息 contentJSON 字符串或裸文本) */ /** 消息 contentJSON 字符串或裸文本) */
content?: string content?: string
@ -178,14 +178,14 @@ const props = defineProps<{
}>() }>()
/** 各类型判定 */ /** 各类型判定 */
const isText = computed(() => props.type === ImMessageType.TEXT) const isText = computed(() => props.type === ImContentType.TEXT)
const isImage = computed(() => props.type === ImMessageType.IMAGE) const isImage = computed(() => props.type === ImContentType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE) const isFile = computed(() => props.type === ImContentType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE) const isVoice = computed(() => props.type === ImContentType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO) const isVideo = computed(() => props.type === ImContentType.VIDEO)
const isCard = computed(() => props.type === ImMessageType.CARD) const isCard = computed(() => props.type === ImContentType.CARD)
const isFace = computed(() => props.type === ImMessageType.FACE) const isFace = computed(() => props.type === ImContentType.FACE)
const isMerge = computed(() => props.type === ImMessageType.MERGE) const isMerge = computed(() => props.type === ImContentType.MERGE)
/** 文本内容:从 TextMessage payload 取 .content */ /** 文本内容:从 TextMessage payload 取 .content */
const textContent = computed( const textContent = computed(
@ -278,7 +278,7 @@ const rtcCallTipText = computed(() => {
return '' return ''
} }
const mediaLabel = payload.mediaType === ImRtcCallMediaType.VIDEO ? '视频' : '语音' const mediaLabel = payload.mediaType === ImRtcCallMediaType.VIDEO ? '视频' : '语音'
if (props.type === ImMessageType.RTC_CALL_START) { if (props.type === ImContentType.RTC_CALL_START) {
const inviter = payload.inviterNickname?.trim() || `用户(${payload.inviterUserId ?? ''})` const inviter = payload.inviterNickname?.trim() || `用户(${payload.inviterUserId ?? ''})`
return `${inviter} 发起了${mediaLabel}通话` return `${inviter} 发起了${mediaLabel}通话`
} }

View File

@ -10,7 +10,7 @@
{{ detail.senderNickname }} ({{ detail.senderId }}) {{ detail.senderNickname }} ({{ detail.senderId }})
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="类型"> <el-descriptions-item label="类型">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" /> <dict-tag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="detail.type" />
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item v-if="MESSAGE_GROUP_READ_ENABLED" label="状态"> <el-descriptions-item v-if="MESSAGE_GROUP_READ_ENABLED" label="状态">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" /> <dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />

View File

@ -18,15 +18,15 @@
class="!w-240px" class="!w-240px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="消息类型" prop="type"> <el-form-item label="内容类型" prop="type">
<el-select <el-select
v-model="queryParams.type" v-model="queryParams.type"
placeholder="请选择消息类型" placeholder="请选择内容类型"
clearable clearable
class="!w-240px" class="!w-240px"
> >
<el-option <el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_TYPE)" v-for="dict in getIntDictOptions(DICT_TYPE.IM_CONTENT_TYPE)"
:key="dict.value" :key="dict.value"
:label="dict.label" :label="dict.label"
:value="dict.value" :value="dict.value"
@ -78,7 +78,7 @@
</el-table-column> </el-table-column>
<el-table-column label="类型" align="center" prop="type" width="100"> <el-table-column label="类型" align="center" prop="type" width="100">
<template #default="{ row }"> <template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="row.type" /> <dict-tag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="row.type" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column

View File

@ -12,7 +12,7 @@
{{ detail.receiverNickname }} ({{ detail.receiverId }}) {{ detail.receiverNickname }} ({{ detail.receiverId }})
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="类型"> <el-descriptions-item label="类型">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" /> <dict-tag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="detail.type" />
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item v-if="MESSAGE_PRIVATE_READ_ENABLED" label="状态"> <el-descriptions-item v-if="MESSAGE_PRIVATE_READ_ENABLED" label="状态">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" /> <dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />

View File

@ -22,15 +22,15 @@
class="!w-240px" class="!w-240px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="消息类型" prop="type"> <el-form-item label="内容类型" prop="type">
<el-select <el-select
v-model="queryParams.type" v-model="queryParams.type"
placeholder="请选择消息类型" placeholder="请选择内容类型"
clearable clearable
class="!w-240px" class="!w-240px"
> >
<el-option <el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_TYPE)" v-for="dict in getIntDictOptions(DICT_TYPE.IM_CONTENT_TYPE)"
:key="dict.value" :key="dict.value"
:label="dict.label" :label="dict.label"
:value="dict.value" :value="dict.value"
@ -82,7 +82,7 @@
</el-table-column> </el-table-column>
<el-table-column label="类型" align="center" prop="type" width="100"> <el-table-column label="类型" align="center" prop="type" width="100">
<template #default="{ row }"> <template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="row.type" /> <dict-tag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="row.type" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="内容预览" align="left" min-width="240"> <el-table-column label="内容预览" align="left" min-width="240">

View File

@ -1,6 +1,6 @@
<template> <template>
<el-card shadow="never" class="!rounded-8px mb-16px"> <el-card shadow="never" class="!rounded-8px mb-16px">
<template #header>消息类型分布</template> <template #header>内容类型分布</template>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div> <div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card> </el-card>
</template> </template>
@ -19,7 +19,7 @@ let chart: echarts.ECharts | null = null
/** 渲染饼图type 在前端按字典翻译为名称给 echarts */ /** 渲染饼图type 在前端按字典翻译为名称给 echarts */
const render = (data: StatisticsApi.ImStatisticsMessageTypeVO[]) => { const render = (data: StatisticsApi.ImStatisticsMessageTypeVO[]) => {
const items = data.map((d) => ({ const items = data.map((d) => ({
name: getDictLabel(DICT_TYPE.IM_MESSAGE_TYPE, d.type) || `未知(${d.type})`, name: getDictLabel(DICT_TYPE.IM_CONTENT_TYPE, d.type) || `未知(${d.type})`,
value: d.value value: d.value
})) }))
chart?.setOption({ chart?.setOption({

View File

@ -1,5 +1,5 @@
/** IM 消息类型枚举(对齐后端 ImMessageTypeEnum */ /** IM 内容类型枚举(对齐后端 ImContentTypeEnum */
export const ImMessageType = { export const ImContentType = {
// ========== 用户聊天消息101-105 直接复用 OpenIM 段位编号) ========== // ========== 用户聊天消息101-105 直接复用 OpenIM 段位编号) ==========
TEXT: 101, // 文本(对应 OpenIM Text=101 TEXT: 101, // 文本(对应 OpenIM Text=101
IMAGE: 102, // 图片(对应 OpenIM Picture=102 IMAGE: 102, // 图片(对应 OpenIM Picture=102
@ -64,75 +64,75 @@ export const ImMessageType = {
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_BANNED] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */ /** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_BANNED] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */
export function isGroupNotification(type: number): boolean { export function isGroupNotification(type: number): boolean {
return ( return (
type >= ImMessageType.GROUP_CREATE type >= ImContentType.GROUP_CREATE &&
&& type <= ImMessageType.GROUP_BANNED type <= ImContentType.GROUP_BANNED &&
&& type !== ImMessageType.GROUP_MEMBER_SETTING_UPDATE type !== ImContentType.GROUP_MEMBER_SETTING_UPDATE
) )
} }
/** 判断是否「好友通知事件」1201-1210 段位 */ /** 判断是否「好友通知事件」1201-1210 段位 */
export function isFriendNotification(type: number): boolean { export function isFriendNotification(type: number): boolean {
return type >= ImMessageType.FRIEND_REQUEST_APPROVED && type <= ImMessageType.FRIEND_UPDATE return type >= ImContentType.FRIEND_REQUEST_APPROVED && type <= ImContentType.FRIEND_UPDATE
} }
/** 判断是否「加群申请通知事件」1503/1505/1506 走私聊通道,按段位识别 */ /** 判断是否「加群申请通知事件」1503/1505/1506 */
export function isGroupRequestNotification(type: number): boolean { export function isGroupRequestNotification(type: number): boolean {
return ( return (
type === ImMessageType.GROUP_REQUEST_RECEIVED type === ImContentType.GROUP_REQUEST_RECEIVED ||
|| type === ImMessageType.GROUP_REQUEST_APPROVED type === ImContentType.GROUP_REQUEST_APPROVED ||
|| type === ImMessageType.GROUP_REQUEST_REJECTED type === ImContentType.GROUP_REQUEST_REJECTED
) )
} }
/** 判断是否「会话内的好友事件气泡」FRIEND_ADD / FRIEND_DELETE 直接渲染成灰色提示,与群事件同处理 */ /** 判断是否「会话内的好友事件气泡」FRIEND_ADD / FRIEND_DELETE 直接渲染成灰色提示,与群事件同处理 */
export function isFriendChatTip(type: number): boolean { export function isFriendChatTip(type: number): boolean {
return type === ImMessageType.FRIEND_ADD || type === ImMessageType.FRIEND_DELETE return type === ImContentType.FRIEND_ADD || type === ImContentType.FRIEND_DELETE
} }
/** 判断是否「会话内的通话事件气泡」RTC_CALL_START / RTC_CALL_END 渲染成灰色提示 */ /** 判断是否「会话内的通话事件气泡」RTC_CALL_START / RTC_CALL_END 渲染成灰色提示 */
export function isRtcCallTip(type: number): boolean { export function isRtcCallTip(type: number): boolean {
return type === ImMessageType.RTC_CALL_START || type === ImMessageType.RTC_CALL_END return type === ImContentType.RTC_CALL_START || type === ImContentType.RTC_CALL_END
} }
/** /**
* IM normal vs event ImMessageTypeEnum.normal * IM normal vs event ImContentTypeEnum.normal
* *
* *
* 1. Im{Private,Group}MessageSendReqVO.isNormalType normal=true * 1. Im{Private,Group}MessageSendReqVO.isNormalType normal=true
* 2. / websocketStore normal + * 2. / websocketStore normal +
* 3. lastType / @ ConversationItem normal * 3. lastType / @ ConversationItem normal
* 4. MessageItem.vue canPin normal / * 4. MessageItem.vue canPin normal /
* *
* CARD/ FACE1/2/3 4 = * CARD/ FACE1/2/3 4 =
*/ */
const ImMessageTypeNormals: number[] = [ const ImContentTypeNormals: number[] = [
ImMessageType.TEXT, ImContentType.TEXT,
ImMessageType.IMAGE, ImContentType.IMAGE,
ImMessageType.FILE, ImContentType.FILE,
ImMessageType.VOICE, ImContentType.VOICE,
ImMessageType.VIDEO, ImContentType.VIDEO,
ImMessageType.CARD, ImContentType.CARD,
ImMessageType.FACE, ImContentType.FACE,
ImMessageType.MERGE, ImContentType.MERGE,
ImMessageType.MATERIAL // 频道素材计入未读数 + 进会话列表 ImContentType.MATERIAL // 频道素材计入未读数 + 进会话列表
] ]
/** 判断是否"普通消息" */ /** 判断是否"普通消息" */
export function isNormalMessage(type: number): boolean { export function isNormalMessage(type: number): boolean {
return ImMessageTypeNormals.includes(type) return ImContentTypeNormals.includes(type)
} }
/** IM 媒体消息类型集合:发送依赖本地 File 上传,刷新后 _localFile 丢失即不可恢复 */ /** IM 媒体内容类型集合:发送依赖本地 File 上传,刷新后 _localFile 丢失即不可恢复 */
const ImMessageTypeMedia: number[] = [ const ImContentTypeMedia: number[] = [
ImMessageType.IMAGE, ImContentType.IMAGE,
ImMessageType.FILE, ImContentType.FILE,
ImMessageType.VOICE, ImContentType.VOICE,
ImMessageType.VIDEO ImContentType.VIDEO
] ]
/** 判断是否「媒体消息」:图片 / 文件 / 语音 / 视频 */ /** 判断是否「媒体消息」:图片 / 文件 / 语音 / 视频 */
export function isMediaMessageType(type: number): boolean { export function isMediaMessageType(type: number): boolean {
return ImMessageTypeMedia.includes(type) return ImContentTypeMedia.includes(type)
} }
/** /**
@ -149,6 +149,7 @@ export const ImMessageStatus = {
/** IM 会话类型枚举 */ /** IM 会话类型枚举 */
export const ImConversationType = { export const ImConversationType = {
NONE: 0, // 无会话
PRIVATE: 1, // 私聊 PRIVATE: 1, // 私聊
GROUP: 2, // 群聊 GROUP: 2, // 群聊
CHANNEL: 3 // 频道 / 公众号 CHANNEL: 3 // 频道 / 公众号
@ -225,11 +226,9 @@ export const ImRtcCallStage = {
/** ImRtcCallStage 取值类型 */ /** ImRtcCallStage 取值类型 */
export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage] export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE / ImChannelMessageDTO.TYPE */ /** IM WebSocket 外层帧类型 */
export const ImWebSocketMessageType = { export const ImWebSocketMessageType = {
PRIVATE_MESSAGE: 'im-private-message', // 私聊通道 NOTIFICATION: 'im-notification' // IM 通知
GROUP_MESSAGE: 'im-group-message', // 群聊通道
CHANNEL_MESSAGE: 'im-channel-message' // 频道通道
} as const } as const
/** IM 消息回执状态枚举(对齐后端 ImMessageReceiptStatusEnum */ /** IM 消息回执状态枚举(对齐后端 ImMessageReceiptStatusEnum */

View File

@ -6,7 +6,7 @@
// 2. fallbackName 由调用方传入典型来源Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底 // 2. fallbackName 由调用方传入典型来源Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底
// ==================================================================== // ====================================================================
import { ImConversationType, ImMessageType, isFriendChatTip, isGroupNotification, isRtcCallTip } from './constants' import { ImConversationType, ImContentType, isFriendChatTip, isGroupNotification, isRtcCallTip } from './constants'
import { import {
getCardLabelInfo, getCardLabelInfo,
parseMessage, parseMessage,
@ -103,47 +103,47 @@ export function summarizeMessageContent(
opts?: { withFileName?: boolean } opts?: { withFileName?: boolean }
): string { ): string {
switch (message.type) { switch (message.type) {
case ImMessageType.TEXT: case ImContentType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? '' return parseMessage<TextMessage>(message.content)?.content ?? ''
case ImMessageType.IMAGE: case ImContentType.IMAGE:
return '[图片]' return '[图片]'
case ImMessageType.VOICE: case ImContentType.VOICE:
return '[语音]' return '[语音]'
case ImMessageType.VIDEO: case ImContentType.VIDEO:
return '[视频]' return '[视频]'
case ImMessageType.FILE: { case ImContentType.FILE: {
if (opts?.withFileName) { if (opts?.withFileName) {
const file = parseMessage<FileMessage>(message.content) const file = parseMessage<FileMessage>(message.content)
return file?.name ? `[文件] ${file.name}` : '[文件]' return file?.name ? `[文件] ${file.name}` : '[文件]'
} }
return '[文件]' return '[文件]'
} }
case ImMessageType.CARD: case ImContentType.CARD:
return `[${getCardLabelInfo(parseMessage<CardMessage>(message.content)).label}]` return `[${getCardLabelInfo(parseMessage<CardMessage>(message.content)).label}]`
case ImMessageType.FACE: case ImContentType.FACE:
return buildFacePreviewText(parseMessage<FaceMessage>(message.content)) return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
case ImMessageType.MERGE: case ImContentType.MERGE:
return '[聊天记录]' return '[聊天记录]'
case ImMessageType.MATERIAL: { case ImContentType.MATERIAL: {
const material = parseMessage<MaterialMessage>(message.content) const material = parseMessage<MaterialMessage>(message.content)
return material?.title ? `[频道] ${material.title}` : '[频道]' return material?.title ? `[频道] ${material.title}` : '[频道]'
} }
case ImMessageType.RTC_CALL_START: case ImContentType.RTC_CALL_START:
case ImMessageType.RTC_CALL_END: case ImContentType.RTC_CALL_END:
return '[语音通话]' return '[语音通话]'
default: default:
return '' return ''
} }
} }
/** 会话列表最后一条摘要RECALL 走 buildRecallTip + fallbackName其它按消息类型派生 */ /** 会话列表最后一条摘要RECALL 走 buildRecallTip + fallbackName其它按内容类型派生 */
export function resolveConversationLastContent( export function resolveConversationLastContent(
message: Message, message: Message,
conversationType: number, conversationType: number,
conversationTargetId: number, conversationTargetId: number,
fallbackName?: string fallbackName?: string
): string { ): string {
if (message.type === ImMessageType.RECALL) { if (message.type === ImContentType.RECALL) {
return buildRecallTip( return buildRecallTip(
message.senderId, message.senderId,
message.selfSend, message.selfSend,

View File

@ -2,7 +2,7 @@
* IM Unicode emoji * IM Unicode emoji
* *
* FacePicker emoji tab使 * FacePicker emoji tab使
* Unicode emoji TEXT FACE * Unicode emoji TEXT FACE
*/ */
export const IM_EMOJI_LIST: string[] = [ export const IM_EMOJI_LIST: string[] = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',

View File

@ -3,7 +3,7 @@ import { useUserStore } from '@/store/modules/user'
import { import {
ImRtcCallEndReason, ImRtcCallEndReason,
ImConversationType, ImConversationType,
ImMessageType, ImContentType,
type ImConversationTypeValue type ImConversationTypeValue
} from './constants' } from './constants'
import { getCurrentUserId } from '@/utils/auth' import { getCurrentUserId } from '@/utils/auth'
@ -18,7 +18,7 @@ export type { QuoteMessage } from '../home/types'
// IM 消息 content 编解码 & 展示工具 // IM 消息 content 编解码 & 展示工具
// ==================================================================== // ====================================================================
// 约定:消息的 content 字段统一存 JSON 字符串,字段名、结构对齐后端 // 约定:消息的 content 字段统一存 JSON 字符串,字段名、结构对齐后端
// cn.iocoder.yudao.module.im.service.websocket.dto.message.* 下的 DTO。 // cn.iocoder.yudao.module.im.service.websocket.notification.message.* 下的 DTO。
// 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage<T> // 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage<T>
// 序列化直接 JSON.stringify(payload)。 // 序列化直接 JSON.stringify(payload)。
// ==================================================================== // ====================================================================
@ -204,7 +204,7 @@ export interface ImageMessage extends Quotable {
size?: number size?: number
} }
/** 语音消息 payload对齐后端 AudioMessageImMessageType 保留 VOICE 命名) */ /** 语音消息 payload对齐后端 AudioMessageImContentType 保留 VOICE 命名) */
export interface AudioMessage extends Quotable { export interface AudioMessage extends Quotable {
url: string url: string
/** 时长(秒) */ /** 时长(秒) */
@ -324,7 +324,7 @@ export interface MergeMessageItem {
senderNickname: string senderNickname: string
/** 发送人头像快照 */ /** 发送人头像快照 */
senderAvatar?: string senderAvatar?: string
/** 消息类型,对齐 ImMessageType */ /** 内容类型,对齐 ImContentType */
type: number type: number
/** 原消息 contentJSON嵌套合并消息时仍按本结构层层展开 */ /** 原消息 contentJSON嵌套合并消息时仍按本结构层层展开 */
content: string content: string
@ -449,19 +449,19 @@ export interface AddableFacePayload {
} }
/** /**
* payload null * payload null
* *
* MessageItem nullable * MessageItem nullable
*/ */
export function extractAddableFace(message: Message): AddableFacePayload | null { export function extractAddableFace(message: Message): AddableFacePayload | null {
if (message.type === ImMessageType.FACE) { if (message.type === ImContentType.FACE) {
const face = parseMessage<FaceMessage>(message.content) const face = parseMessage<FaceMessage>(message.content)
if (!face?.url) { if (!face?.url) {
return null return null
} }
return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name } return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name }
} }
if (message.type === ImMessageType.IMAGE) { if (message.type === ImContentType.IMAGE) {
const image = parseMessage<ImageMessage>(message.content) const image = parseMessage<ImageMessage>(message.content)
if (!image?.url) { if (!image?.url) {
return null return null
@ -718,7 +718,7 @@ export function resolveGroupNotificationSegments(
return [] return []
} }
// ENTER 主语是 entrant 而非 operator独立处理其它 case 都以 operatorUserId 为主语 // ENTER 主语是 entrant 而非 operator独立处理其它 case 都以 operatorUserId 为主语
if (message.type === ImMessageType.GROUP_MEMBER_ENTER) { if (message.type === ImContentType.GROUP_MEMBER_ENTER) {
const entrantId = payload.entrantUserId ?? payload.operatorUserId const entrantId = payload.entrantUserId ?? payload.operatorUserId
return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] : [] return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 加入了群聊')] : []
} }
@ -732,31 +732,31 @@ export function resolveGroupNotificationSegments(
const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolveName) const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolveName)
switch (message.type) { switch (message.type) {
case ImMessageType.GROUP_CREATE: case ImContentType.GROUP_CREATE:
return [operatorSegment, tipText(' 创建了群聊')] return [operatorSegment, tipText(' 创建了群聊')]
case ImMessageType.GROUP_NAME_UPDATE: case ImContentType.GROUP_NAME_UPDATE:
return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)] return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)]
case ImMessageType.GROUP_NOTICE_UPDATE: case ImContentType.GROUP_NOTICE_UPDATE:
return [operatorSegment, tipText(' 更新了群公告')] return [operatorSegment, tipText(' 更新了群公告')]
case ImMessageType.GROUP_INFO_UPDATE: case ImContentType.GROUP_INFO_UPDATE:
return payload.newAvatar return payload.newAvatar
? [operatorSegment, tipText(' 更换了群头像')] ? [operatorSegment, tipText(' 更换了群头像')]
: [operatorSegment, tipText(' 更新了群信息')] : [operatorSegment, tipText(' 更新了群信息')]
case ImMessageType.GROUP_DISSOLVE: case ImContentType.GROUP_DISSOLVE:
return [operatorSegment, tipText(' 解散了群聊')] return [operatorSegment, tipText(' 解散了群聊')]
case ImMessageType.GROUP_MEMBER_INVITE: case ImContentType.GROUP_MEMBER_INVITE:
return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')] return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')]
case ImMessageType.GROUP_MEMBER_QUIT: case ImContentType.GROUP_MEMBER_QUIT:
return [operatorSegment, tipText(' 退出了群聊')] return [operatorSegment, tipText(' 退出了群聊')]
case ImMessageType.GROUP_MEMBER_KICK: case ImContentType.GROUP_MEMBER_KICK:
return [operatorSegment, tipText(' 移出了 '), ...memberSegments] return [operatorSegment, tipText(' 移出了 '), ...memberSegments]
case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE: case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE:
return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)] return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)]
case ImMessageType.GROUP_ADMIN_ADD: case ImContentType.GROUP_ADMIN_ADD:
return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')] return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')]
case ImMessageType.GROUP_ADMIN_REMOVE: case ImContentType.GROUP_ADMIN_REMOVE:
return [operatorSegment, tipText(' 撤销了 '), ...memberSegments, tipText(' 的管理员身份')] return [operatorSegment, tipText(' 撤销了 '), ...memberSegments, tipText(' 的管理员身份')]
case ImMessageType.GROUP_OWNER_TRANSFER: case ImContentType.GROUP_OWNER_TRANSFER:
return payload.newOwnerUserId return payload.newOwnerUserId
? [ ? [
operatorSegment, operatorSegment,
@ -764,11 +764,11 @@ export function resolveGroupNotificationSegments(
tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId)) tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId))
] ]
: [] : []
case ImMessageType.GROUP_MESSAGE_PIN: case ImContentType.GROUP_MESSAGE_PIN:
return [operatorSegment, tipText(' 置顶了一条消息')] return [operatorSegment, tipText(' 置顶了一条消息')]
case ImMessageType.GROUP_MESSAGE_UNPIN: case ImContentType.GROUP_MESSAGE_UNPIN:
return [operatorSegment, tipText(' 取消了一条置顶消息')] return [operatorSegment, tipText(' 取消了一条置顶消息')]
case ImMessageType.GROUP_MEMBER_MUTED: case ImContentType.GROUP_MEMBER_MUTED:
return payload.mutedUserId return payload.mutedUserId
? [ ? [
operatorSegment, operatorSegment,
@ -777,7 +777,7 @@ export function resolveGroupNotificationSegments(
tipText(' 禁言') tipText(' 禁言')
] ]
: [] : []
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED: case ImContentType.GROUP_MEMBER_CANCEL_MUTED:
return payload.mutedUserId return payload.mutedUserId
? [ ? [
operatorSegment, operatorSegment,
@ -786,11 +786,11 @@ export function resolveGroupNotificationSegments(
tipText(' 的禁言') tipText(' 的禁言')
] ]
: [] : []
case ImMessageType.GROUP_MUTED: case ImContentType.GROUP_MUTED:
return [operatorSegment, tipText(' 开启了全群禁言')] return [operatorSegment, tipText(' 开启了全群禁言')]
case ImMessageType.GROUP_CANCEL_MUTED: case ImContentType.GROUP_CANCEL_MUTED:
return [operatorSegment, tipText(' 关闭了全群禁言')] return [operatorSegment, tipText(' 关闭了全群禁言')]
case ImMessageType.GROUP_BANNED: case ImContentType.GROUP_BANNED:
return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')] return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')]
default: default:
return [] return []
@ -813,9 +813,9 @@ export function resolveGroupNotificationText(
/** 会话内好友事件 segments */ /** 会话内好友事件 segments */
export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] { export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] {
switch (message.type) { switch (message.type) {
case ImMessageType.FRIEND_ADD: case ImContentType.FRIEND_ADD:
return [tipText('你们已经是好友了,开始聊天吧')] return [tipText('你们已经是好友了,开始聊天吧')]
case ImMessageType.FRIEND_DELETE: case ImContentType.FRIEND_DELETE:
return [tipText('你已删除好友')] return [tipText('你已删除好友')]
default: default:
return [] return []
@ -874,7 +874,7 @@ export function resolveRtcCallTipSegments(message: {
if (!payload) { if (!payload) {
return [] return []
} }
if (message.type === ImMessageType.RTC_CALL_START) { if (message.type === ImContentType.RTC_CALL_START) {
return payload.inviterUserId return payload.inviterUserId
? [ ? [
tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
@ -882,7 +882,7 @@ export function resolveRtcCallTipSegments(message: {
] ]
: [] : []
} }
if (message.type === ImMessageType.RTC_CALL_END) { if (message.type === ImContentType.RTC_CALL_END) {
return [tipText('语音通话已经结束')] return [tipText('语音通话已经结束')]
} }
return [] return []
@ -907,10 +907,10 @@ export function resolveRtcCallLastContent(
if (conversationType === ImConversationType.PRIVATE) { if (conversationType === ImConversationType.PRIVATE) {
return '[语音通话]' return '[语音通话]'
} }
if (message.type === ImMessageType.RTC_CALL_END) { if (message.type === ImContentType.RTC_CALL_END) {
return '语音通话已经结束' return '语音通话已经结束'
} }
if (message.type === ImMessageType.RTC_CALL_START) { if (message.type === ImContentType.RTC_CALL_START) {
const payload = parseRtcCallPayload(message.content) const payload = parseRtcCallPayload(message.content)
if (payload) { if (payload) {
return `${resolveRtcInviterLabel(payload)} 发起了语音通话` return `${resolveRtcInviterLabel(payload)} 发起了语音通话`