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 {
type: number // 参见 ImMessageTypeEnum 枚举类,由前端按 DICT_TYPE.IM_MESSAGE_TYPE 翻译
type: number // 参见 ImContentTypeEnum 枚举类,由前端按 DICT_TYPE.IM_CONTENT_TYPE 翻译
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 } })
}
// 获得消息类型分布(最近 30 天)
// 获得内容类型分布(最近 30 天)
export const getMessageTypeDistribution = (): Promise<ImStatisticsMessageTypeVO[]> => {
return request.get<ImStatisticsMessageTypeVO[]>({
url: '/im/manager/statistics/message-type-distribution'

View File

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

View File

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

View File

@ -328,7 +328,7 @@ export enum DICT_TYPE {
MES_WM_PACKAGE_STATUS = 'mes_wm_package_status', // MES 装箱单状态
// ========== 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_RECEIPT_STATUS = 'im_message_receipt_status', // IM 消息回执状态0=不需要 / 1=待完成 / 2=已完成
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ import {
type QuoteMessage,
type TextMessage
} 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 { getClientConversationId } from '../../utils/db'
import type { Conversation, Message } from '../types'
@ -193,7 +193,7 @@ export const useMessageSender = () => {
return false
}
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 { useGroupRequestStore } from '../../../../store/groupRequestStore'
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 { buildRecallTip } from '@/views/im/utils/conversation'
import type { Conversation } from '../../../../types'
@ -183,7 +183,7 @@ const lastContentDisplay = computed(() => {
return draft.value.plain
}
if (
props.conversation.lastMessageType === ImMessageType.RECALL &&
props.conversation.lastMessageType === ImContentType.RECALL &&
props.conversation.lastSenderId != null
) {
return buildRecallTip(

View File

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

View File

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

View File

@ -157,7 +157,7 @@
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)]"
>
[不支持的消息类型]
[不支持的内容类型]
</div>
</template>
@ -167,7 +167,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { formatFileSize } from '@/utils/file'
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 {
parseMessage,
@ -193,7 +193,7 @@ import MaterialBubble from './MaterialBubble.vue'
defineOptions({ name: 'ImMessageBubble' })
const props = defineProps<{
/** 消息类型,对齐 ImMessageType */
/** 内容类型,对齐 ImContentType */
type: number
/** 消息 contentJSON 字符串) */
content: string
@ -213,15 +213,15 @@ const emit = defineEmits<{
}>()
/** 各 type 判定 */
const isText = computed(() => props.type === ImMessageType.TEXT)
const isImage = computed(() => props.type === ImMessageType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO)
const isFace = computed(() => props.type === ImMessageType.FACE)
const isCard = computed(() => props.type === ImMessageType.CARD)
const isMerge = computed(() => props.type === ImMessageType.MERGE)
const isMaterial = computed(() => props.type === ImMessageType.MATERIAL)
const isText = computed(() => props.type === ImContentType.TEXT)
const isImage = computed(() => props.type === ImContentType.IMAGE)
const isFile = computed(() => props.type === ImContentType.FILE)
const isVoice = computed(() => props.type === ImContentType.VOICE)
const isVideo = computed(() => props.type === ImContentType.VIDEO)
const isFace = computed(() => props.type === ImContentType.FACE)
const isCard = computed(() => props.type === ImContentType.CARD)
const isMerge = computed(() => props.type === ImContentType.MERGE)
const isMaterial = computed(() => props.type === ImContentType.MATERIAL)
/** 媒体上传中uploadProgress 非 null 即视为上传中 */
const isUploading = computed(() => props.uploadProgress != null)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,15 +18,15 @@
class="!w-240px"
/>
</el-form-item>
<el-form-item label="消息类型" prop="type">
<el-form-item label="内容类型" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择消息类型"
placeholder="请选择内容类型"
clearable
class="!w-240px"
>
<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"
:label="dict.label"
:value="dict.value"
@ -78,7 +78,7 @@
</el-table-column>
<el-table-column label="类型" align="center" prop="type" width="100">
<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>
</el-table-column>
<el-table-column

View File

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

View File

@ -22,15 +22,15 @@
class="!w-240px"
/>
</el-form-item>
<el-form-item label="消息类型" prop="type">
<el-form-item label="内容类型" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择消息类型"
placeholder="请选择内容类型"
clearable
class="!w-240px"
>
<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"
:label="dict.label"
:value="dict.value"
@ -82,7 +82,7 @@
</el-table-column>
<el-table-column label="类型" align="center" prop="type" width="100">
<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>
</el-table-column>
<el-table-column label="内容预览" align="left" min-width="240">

View File

@ -1,6 +1,6 @@
<template>
<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>
</el-card>
</template>
@ -19,7 +19,7 @@ let chart: echarts.ECharts | null = null
/** 渲染饼图type 在前端按字典翻译为名称给 echarts */
const render = (data: StatisticsApi.ImStatisticsMessageTypeVO[]) => {
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
}))
chart?.setOption({

View File

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

View File

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

View File

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

View File

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