refactor: 扁平化 IM WebSocket 通知推送 API
- 将 WebSocket 推送入口统一为 userId/userIds + conversationType + contentType + payload - 移除业务侧 ImNotificationWebSocketDTO 构造和无会话专用发送入口 - 收敛私聊、群聊、频道、好友、加群申请、RTC 通知调用路径 - 精简 ImNotificationWebSocketDTO,仅保留统一外壳字段 - 保留群消息 payload 的 receiptStatus、readCount、receiverUserIds - 更新相关单元测试,覆盖群消息通知 payload 字段pull/884/MERGE
parent
2685bc357f
commit
4879c4705f
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 // 是否需要回执
|
||||
|
|
|
|||
|
|
@ -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(普通私聊用户消息)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 好友状态
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 走 uploadAndSendMedia;video 走低层 helper 自行组装) */
|
||||
export interface UploadAndSendMediaOptions {
|
||||
file: File
|
||||
/** 对齐 ImMessageType;mediaTypeHandlers 必须有对应项 */
|
||||
/** 对齐 ImContentType;mediaTypeHandlers 必须有对应项 */
|
||||
type: number
|
||||
/** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
|
||||
context?: MediaTypeContext
|
||||
|
|
@ -124,23 +124,23 @@ export interface UploadAndSendMediaOptions {
|
|||
*
|
||||
* 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<!--
|
||||
表情面板(多 tab):emoji / 个人表情 / N 个系统表情包
|
||||
- 对齐微信 PC:底部 tab 栏切换面板内容;emoji 保持 Unicode(仍由 TEXT 通道发送)
|
||||
- 个人表情 / 系统表情走 FACE 消息类型,通过 select-face 事件由调用方走 sendRaw 发送
|
||||
- 个人表情 / 系统表情走 FACE 内容类型,通过 select-face 事件由调用方走 sendRaw 发送
|
||||
- mode='emoji' 时只显示 emoji tab + 隐藏底部 tab 栏,给留言 / 评论这类只发文本的场景用
|
||||
- 定位由调用方决定(通常浮在表情按钮上方)
|
||||
-->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/** 消息 content(JSON 字符串) */
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
<div class="mt-1.5">
|
||||
<!-- 撤回:单独走灰色 tip,sender 名段可点击 -->
|
||||
<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 ''
|
||||
|
|
|
|||
|
|
@ -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: '复制',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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=true:clear 语义是清会话本身,跳过气泡渲染 */
|
||||
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.type(ImMessageType)再分流
|
||||
* 按 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.type(ImMessageType)分到已读 / 回执 / 好友通知 / 普通消息
|
||||
* 私聊统一帧分发:按 payload.type(ImContentType)分到已读 / 回执 / 好友通知 / 普通消息
|
||||
*
|
||||
* 对应后端 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.type(ImMessageType)分到已读 / 回执 / 群个人信号 / 普通消息
|
||||
* 群聊统一帧分发:按 payload.type(ImContentType)分到已读 / 回执 / 群个人信号 / 普通消息
|
||||
*
|
||||
* 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 单条入库后双方收到同一份 payload,payload.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 系列:
|
||||
* - 1503:admin 侧拉单条 push 进 unhandledList;申请人侧不收
|
||||
* - 1505 / 1506:admin 侧从 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 的结构
|
||||
* 单一 dispatcher,按 type 分发到 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 字符串)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/** 消息 content(JSON 字符串或裸文本) */
|
||||
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}通话`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)/ 表情(FACE)都是「用户主动发的聊天消息」,1/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) */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* IM 常用 Unicode emoji 列表
|
||||
*
|
||||
* 所见即所得(不依赖图片资源),由表情面板(FacePicker 的 emoji tab)使用;
|
||||
* Unicode emoji 选中后由调用方插入到输入框走 TEXT 通道,不走 FACE 消息类型
|
||||
* Unicode emoji 选中后由调用方插入到输入框走 TEXT 通道,不走 FACE 内容类型
|
||||
*/
|
||||
export const IM_EMOJI_LIST: string[] = [
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
|
|
|
|||
|
|
@ -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(对齐后端 AudioMessage;ImMessageType 保留 VOICE 命名) */
|
||||
/** 语音消息 payload(对齐后端 AudioMessage;ImContentType 保留 VOICE 命名) */
|
||||
export interface AudioMessage extends Quotable {
|
||||
url: string
|
||||
/** 时长(秒) */
|
||||
|
|
@ -324,7 +324,7 @@ export interface MergeMessageItem {
|
|||
senderNickname: string
|
||||
/** 发送人头像快照 */
|
||||
senderAvatar?: string
|
||||
/** 消息类型,对齐 ImMessageType */
|
||||
/** 内容类型,对齐 ImContentType */
|
||||
type: number
|
||||
/** 原消息 content(JSON);嵌套合并消息时仍按本结构层层展开 */
|
||||
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)} 发起了语音通话`
|
||||
|
|
|
|||
Loading…
Reference in New Issue