feat(im): 新增频道消息的前端实现

im
YunaiV 2026-05-19 13:26:32 +08:00
parent b52ad0c34b
commit 5ebbbf7499
16 changed files with 360 additions and 24 deletions

View File

@ -0,0 +1,42 @@
import request from '@/config/axios'
export interface ImManagerChannelVO {
id: number
code: string
name: string
avatar?: string
sort: number
status: number
createTime?: Date
}
// 获得频道分页
export const getManagerChannelPage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel/page', params })
}
// 获得频道详情
export const getManagerChannel = (id: number) => {
return request.get({ url: '/im/manager/channel/get?id=' + id })
}
// 新增频道
export const createManagerChannel = (data: ImManagerChannelVO) => {
return request.post({ url: '/im/manager/channel/create', data })
}
// 修改频道
export const updateManagerChannel = (data: ImManagerChannelVO) => {
return request.put({ url: '/im/manager/channel/update', data })
}
// 删除频道
export const deleteManagerChannel = (id: number) => {
return request.delete({ url: '/im/manager/channel/delete?id=' + id })
}
// 获得启用的频道精简列表(表单选择用)
// TODO @AI改成 simplelist 命名上?
export const getEnabledChannelList = () => {
return request.get<ImManagerChannelVO[]>({ url: '/im/manager/channel/simple-list' })
}

View File

@ -0,0 +1,39 @@
import request from '@/config/axios'
export interface ImManagerChannelMaterialVO {
id: number
channelId: number
channelName?: string
type: number
title: string
coverUrl?: string
summary?: string
content?: string
url?: string
createTime?: Date
}
// 获得素材分页
export const getManagerChannelMaterialPage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel-material/page', params })
}
// 获得素材详情
export const getManagerChannelMaterial = (id: number) => {
return request.get({ url: '/im/manager/channel-material/get?id=' + id })
}
// 新增素材
export const createManagerChannelMaterial = (data: ImManagerChannelMaterialVO) => {
return request.post({ url: '/im/manager/channel-material/create', data })
}
// 修改素材
export const updateManagerChannelMaterial = (data: ImManagerChannelMaterialVO) => {
return request.put({ url: '/im/manager/channel-material/update', data })
}
// 删除素材
export const deleteManagerChannelMaterial = (id: number) => {
return request.delete({ url: '/im/manager/channel-material/delete?id=' + id })
}

View File

@ -0,0 +1,33 @@
import request from '@/config/axios'
export interface ImManagerChannelMessageVO {
id: number
channelId: number
channelName?: string
materialId: number
materialTitle?: string
type: number
content?: string
receiverUserIds?: number[]
sendTime?: Date
}
export interface ImManagerChannelMessageSendReqVO {
materialId: number
receiverUserIds?: number[]
}
// 立即推送频道消息
export const sendManagerChannelMessage = (data: ImManagerChannelMessageSendReqVO) => {
return request.post({ url: '/im/manager/channel-message/send', data })
}
// 删除频道消息
export const deleteManagerChannelMessage = (id: number) => {
return request.delete({ url: '/im/manager/channel-message/delete?id=' + id })
}
// 获得频道消息分页
export const getManagerChannelMessagePage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel-message/page', params })
}

View File

@ -0,0 +1,36 @@
import request from '@/config/axios'
export interface ImChannelMessageRespVO {
id: number
channelId: number
materialId: number
type: number
content: string
sendTime: string
}
// 用户端能看到的频道素材详情
export interface ImChannelMaterialRespVO {
id: number
channelId: number
type: number
title: string
coverUrl?: string
summary?: string
content?: string
url?: string
}
// 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页
export const pullChannelMessages = (params: { minId: number; size?: number }) => {
return request.get<ImChannelMessageRespVO[]>({
url: '/im/channel/message/pull',
params
})
}
// 获取频道素材详情;用于客户端点击图文卡片渲染详情页
// TODO @AI这个地址也要改把。
export const getChannelMaterial = (id: number) => {
return request.get<ImChannelMaterialRespVO>({ url: '/im/channel/material/get?id=' + id })
}

View File

@ -13,6 +13,10 @@ import {
pullGroupMessages as apiPullGroupMessages,
type ImGroupMessageRespVO
} from '@/api/im/message/group'
import {
pullChannelMessages as apiPullChannelMessages,
type ImChannelMessageRespVO
} from '@/api/im/message/channel'
import {
ImConversationType,
ImMessageType,
@ -25,8 +29,21 @@ import {
MESSAGE_PRIVATE_READ_ENABLED
} from '../../utils/config'
import { useUserStore } from '@/store/modules/user'
import { useChannelStore } from '../store/channelStore'
import type { Message } from '../types'
/** 构建频道会话描述;优先从 channelStore 取真实 name / avatar缺失时退化为占位 */
// TODO @AI这个可以抽到 channel.ts 搞个工具类么?类似 user.ts 获取名字这种。
export const buildChannelConversationStub = (channelId: number) => {
const channel = useChannelStore().getChannel(channelId)
return {
type: ImConversationType.CHANNEL,
targetId: channelId,
name: channel?.name || `频道 ${channelId}`,
avatar: channel?.avatar || ''
}
}
/**
* 线
*
@ -84,6 +101,26 @@ export const useMessagePuller = () => {
}
}
/** 服务端频道消息 -> 本地 Message */
const convertChannelMessage = (message: ImChannelMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: '',
type: message.type,
content: message.content,
status: 0, // 频道消息无状态机;占位 UNREAD
sendTime: new Date(message.sendTime).getTime(),
senderId: 0, // 系统下发,无发送人
targetId: message.channelId, // 会话归属到频道编号
selfSend: false,
materialId: message.materialId // 详情页拉富文本用
}
}
/** 频道:会话归属到 channelIdname / avatar 暂用占位,将来接入 channelStore 后再填真值 */
const convertChannelConversation = (message: ImChannelMessageRespVO) =>
buildChannelConversationStub(message.channelId)
/** 私聊:会话归属到对端 userId */
const convertPrivateConversation = (message: ImPrivateMessageRespVO) => {
const targetId = getPrivatePeerId(message)
@ -109,14 +146,20 @@ export const useMessagePuller = () => {
/** 循环拉取指定会话类型的消息:以列表最后一条 id 作为下次 minId直到接口返回空列表 */
const pullByType = async (conversationType: number, startMinId: number) => {
// 私聊 / 群聊各自一套接口和分页大小,按 isPrivate 在循环内分支调度
// 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度
let minId = startMinId || 0
const isPrivate = conversationType === ImConversationType.PRIVATE
const isChannel = conversationType === ImConversationType.CHANNEL
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
while (true) {
const list = isPrivate
? await apiPullPrivateMessages({ minId, size })
: await apiPullGroupMessages({ minId, size })
let list: any[] | undefined
if (isPrivate) {
list = await apiPullPrivateMessages({ minId, size })
} else if (isChannel) {
list = await apiPullChannelMessages({ minId, size })
} else {
list = await apiPullGroupMessages({ minId, size })
}
if (!list || list.length === 0) {
break
}
@ -124,6 +167,14 @@ export const useMessagePuller = () => {
// 逐条 dispatch原消息走 insertMessageRECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id先更新 status 再插信号所以原消息一定先到、recallMessage 找得到
for (const raw of list) {
if (isChannel) {
const message = raw as ImChannelMessageRespVO
conversationStore.insertMessage(
convertChannelConversation(message),
convertChannelMessage(message)
)
continue
}
if (isPrivate) {
const message = raw as ImPrivateMessageRespVO
// 特殊:撤回消息的处理
@ -197,10 +248,11 @@ export const useMessagePuller = () => {
try {
conversationStore.loading = true
try {
// 并发拉取私聊 + 群聊,降低初始加载耗时
// 并发拉取私聊 + 群聊 + 频道,降低初始加载耗时
await Promise.all([
pullByType(ImConversationType.PRIVATE, conversationStore.privateMessageMaxId),
pullByType(ImConversationType.GROUP, conversationStore.groupMessageMaxId)
pullByType(ImConversationType.GROUP, conversationStore.groupMessageMaxId),
pullByType(ImConversationType.CHANNEL, conversationStore.channelMessageMaxId)
])
} catch (e) {
console.error('[IM] 拉取离线消息失败:', e)
@ -214,6 +266,8 @@ export const useMessagePuller = () => {
for (const item of buffered) {
if (item.conversationType === ImConversationType.PRIVATE) {
wsStore.handlePrivateMessage(item.payload)
} else if (item.conversationType === ImConversationType.CHANNEL) {
wsStore.handleChannelMessage(item.payload)
} else {
wsStore.handleGroupMessage(item.payload)
}

View File

@ -232,6 +232,12 @@ export const useMessageSender = () => {
}
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
const isPrivate = conversation.type === ImConversationType.PRIVATE
const isGroup = conversation.type === ImConversationType.GROUP
// 频道目前不上报已读
// TODO @AI频道已读应该还是要上报的同步到别的端。但是不用记录 status 字段。
if (!isPrivate && !isGroup) {
return
}
const readEnabled = isPrivate ? MESSAGE_PRIVATE_READ_ENABLED : MESSAGE_GROUP_READ_ENABLED
if (!readEnabled) {
return

View File

@ -40,6 +40,7 @@ import { useGroupStore } from './store/groupStore'
import { useGroupRequestStore } from './store/groupRequestStore'
import { useDraftStore } from './store/draftStore'
import { useFaceStore } from './store/faceStore'
import { useChannelStore } from './store/channelStore'
import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
@ -63,6 +64,7 @@ const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const draftStore = useDraftStore()
const faceStore = useFaceStore()
const channelStore = useChannelStore()
const { pullOnce } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
const voicePlayer = useVoicePlayer()
@ -72,9 +74,9 @@ onMounted(async () => {
// 0.1 IDB /
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
// 0.2 / store
void groupRequestStore.fetchUnhandledList().catch((e) =>
console.warn('[IM] 拉取未处理加群申请失败', e)
)
void groupRequestStore
.fetchUnhandledList()
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
// 1.1 loading=true saveConversations + WebSocket connect pullOnce maxId pull 线
conversationStore.loading = true
@ -101,6 +103,10 @@ onMounted(async () => {
} else {
requiredFetches.push(groupStore.fetchGroups())
}
// TODO @AI// IDB pull 2.1 2.2
// IDB pull
void channelStore.fetchChannels().catch((e) => console.warn('[IM] 拉取频道列表失败', e))
// 2.3 TODO @AI
if (requiredFetches.length > 0) {
await Promise.all(requiredFetches)
}

View File

@ -149,6 +149,13 @@
[聊天记录]
</div>
<!-- 频道素材图文卡片点击拉富文本 / 跳外链 -->
<!-- TODO @AI在对话界面里时目前碰到消息无法跳转的情况ps考虑到可以兼容到私聊群聊消息是不是把 materialId 也在 content 里存储一份说白了materialId 只是为了管理后台的检索其它地方尽量使用 content 里的 materialId 字段 -->
<MaterialBubble
v-else-if="isMaterial"
:message="{ content: props.content, materialId: props.materialId }"
/>
<!-- 未知类型降级 -->
<div
v-else
@ -184,6 +191,7 @@ import { summarizeMessageContent } from '@/views/im/utils/conversation'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import TipSegments from './TipSegments.vue'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
import MaterialBubble from './MaterialBubble.vue'
defineOptions({ name: 'ImMessageBubble' })
@ -198,6 +206,8 @@ const props = defineProps<{
uploadProgress?: number | null
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
mentions?: MentionCandidate[]
/** MATERIAL 气泡的素材编号;点击「查看详情」时拉富文本正文 */
materialId?: number
}>()
const emit = defineEmits<{
@ -216,6 +226,7 @@ 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)
/** 媒体上传中uploadProgress 非 null 即视为上传中 */
const isUploading = computed(() => props.uploadProgress != null)
@ -229,7 +240,9 @@ const uploadProgressText = computed(() => `${uploadProgress.value}%`)
*/
const parsedContent = computed<unknown>(() => parseMessage(props.content))
const textPayload = computed(() => (isText.value ? (parsedContent.value as TextMessage | null) : null))
const textPayload = computed(() =>
isText.value ? (parsedContent.value as TextMessage | null) : null
)
/** 文本气泡 segment 数组mention 高亮 + URL 自动识别 + 普通文本三段拼接 */
const textSegments = computed(() => {
@ -242,14 +255,18 @@ const textSegments = computed(() => {
const imagePayload = computed(() =>
isImage.value ? (parsedContent.value as ImageMessage | null) : null
)
const filePayload = computed(() => (isFile.value ? (parsedContent.value as FileMessage | null) : null))
const filePayload = computed(() =>
isFile.value ? (parsedContent.value as FileMessage | null) : null
)
const voicePayload = computed(() =>
isVoice.value ? (parsedContent.value as AudioMessage | null) : null
)
const videoPayload = computed(() =>
isVideo.value ? (parsedContent.value as VideoMessage | null) : null
)
const cardPayload = computed(() => (isCard.value ? (parsedContent.value as CardMessage | null) : null))
const cardPayload = computed(() =>
isCard.value ? (parsedContent.value as CardMessage | null) : null
)
const mergePayload = computed(() =>
isMerge.value ? (parsedContent.value as MergeMessage | null) : null
)

View File

@ -87,20 +87,27 @@
<Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" />
</span>
<!-- 消息行头像 + 气泡内部按 selfSend reverse -->
<!-- 消息行头像 + 气泡内部按 selfSend reverse频道素材仿公众号样式居中且不显示头像 -->
<div
class="flex flex-1 min-w-0 gap-2 items-start"
:class="{ 'flex-row-reverse': message.selfSend }"
:class="{ 'flex-row-reverse': message.selfSend, 'justify-center': isMaterial }"
>
<!-- 头像点击弹 UserInfoCard UserAvatar 内部承接 -->
<!-- 头像点击弹 UserInfoCard UserAvatar 内部承接频道素材消息不显示头像 -->
<UserAvatar
v-if="!isMaterial"
:id="message.selfSend ? userStore.getUser?.id : message.senderId"
:name="senderRealNickname"
:url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
:size="36"
/>
<div class="flex flex-col gap-0.5 max-w-[70%]" :class="{ 'items-end': message.selfSend }">
<div
class="flex flex-col gap-0.5"
:class="[
message.selfSend ? 'items-end' : '',
isMaterial ? 'w-[80%] min-w-[320px] max-w-[720px]' : 'max-w-[70%]'
]"
>
<!-- 群聊对方消息气泡上方显示发送者昵称 -->
<div
v-if="showSenderName"
@ -116,6 +123,7 @@
:self-send="message.selfSend"
:upload-progress="message.uploadProgress"
:mentions="textMentions"
:material-id="message.materialId"
@click-card="handleCardClick"
@open-merge="handleMergeOpen"
/>
@ -292,11 +300,14 @@ const { copy: copyToClipboard } = useClipboard({ legacy: true })
// ==================== ====================
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染 */
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染;频道素材每条都显示 */
const shouldShowTimeTip = computed(() => {
if (!props.message.sendTime) {
return false
}
if (props.message.type === ImMessageType.MATERIAL) {
return true
}
if (!props.prevMessage?.sendTime) {
return true
}
@ -306,6 +317,8 @@ const shouldShowTimeTip = computed(() => {
/** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble */
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isMerge = computed(() => props.message.type === ImMessageType.MERGE)
/** 频道素材消息:仿微信公众号样式 —— 卡片靠右、不显示左侧头像 */
const isMaterial = computed(() => props.message.type === ImMessageType.MATERIAL)
// ==================== / / 广 ====================

View File

@ -163,8 +163,10 @@
</transition>
</div>
<!-- 底部输入框常驻多选模式底栏作为浮层盖在上面保持下方输入框尺寸不变 -->
<div class="relative">
<!-- 底部输入框频道单向消息无需输入框多选模式底栏作为浮层盖在上面保持下方输入框尺寸不变 -->
<!-- TODO @AI暂时去掉频道的右键引用多选 -->
<!-- TODO @AI转发时不允许选择频道这块要屏蔽下 -->
<div v-if="!isChannel" class="relative">
<MessageInput />
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
</div>
@ -295,6 +297,9 @@ const isGroup = computed(
const isPrivate = computed(
() => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
)
const isChannel = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE单边删除语义下「被对方删除」不触发本端横幅 */
const showNotFriendBanner = computed(() => {

View File

@ -125,6 +125,7 @@ export const useConversationStore = defineStore('imConversationStore', {
activeConversation: null as Conversation | null, // 当前激活的会话
privateMessageMaxId: 0, // 私聊最大消息 id作为 pull 的游标
groupMessageMaxId: 0, // 群聊最大消息 id作为 pull 的游标
channelMessageMaxId: 0, // 频道最大消息 id作为 pull 的游标
loading: false, // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表(按推送顺序倒序,最大 CONVERSATION_RECENT_FORWARD_MAX 个)
}),
@ -195,6 +196,7 @@ export const useConversationStore = defineStore('imConversationStore', {
}
this.privateMessageMaxId = Number(meta.privateMessageMaxId) || 0
this.groupMessageMaxId = Number(meta.groupMessageMaxId) || 0
this.channelMessageMaxId = Number((meta as any).channelMessageMaxId) || 0
if (!meta.conversations || meta.conversations.length === 0) {
return
}
@ -265,10 +267,11 @@ export const useConversationStore = defineStore('imConversationStore', {
const meta: ConversationStoreMeta = {
privateMessageMaxId: this.privateMessageMaxId,
groupMessageMaxId: this.groupMessageMaxId,
channelMessageMaxId: this.channelMessageMaxId,
conversations: this.conversations
.filter((c) => !c.deleted)
.map(({ messages, ...rest }) => rest)
}
} as ConversationStoreMeta
const tasks: Promise<unknown>[] = [
imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
]
@ -871,6 +874,10 @@ export const useConversationStore = defineStore('imConversationStore', {
if (messageId > this.groupMessageMaxId) {
this.groupMessageMaxId = messageId
}
} else if (conversationType === ImConversationType.CHANNEL) {
if (messageId > this.channelMessageMaxId) {
this.channelMessageMaxId = messageId
}
}
},

View File

@ -29,6 +29,8 @@ import {
} from './rtcStore'
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
import type { ImChannelMessageRespVO } from '@/api/im/message/channel'
import { buildChannelConversationStub } from '../composables/useMessagePuller'
import type {
WebSocketFrame,
ImPrivateMessageDTO,
@ -117,6 +119,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
messageBuffer: [] as Array<
| { conversationType: typeof ImConversationType.PRIVATE; payload: ImPrivateMessageDTO }
| { conversationType: typeof ImConversationType.GROUP; payload: ImGroupMessageDTO }
| { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageRespVO }
> // 初始化加载期内先把普通消息丢进缓冲区pull 完成后再一次性回放
}),
@ -209,11 +212,60 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
case ImWebSocketMessageType.GROUP_MESSAGE:
this.dispatchGroupFrame(content as ImGroupMessageDTO)
break
case ImWebSocketMessageType.CHANNEL_MESSAGE:
this.handleChannelMessage(content as ImChannelMessageRespVO)
break
default:
console.debug('[IM WS] 未识别事件', frame)
}
},
/**
* + insertMessage
* pull WS id conversationStore.insertMessage id
*/
handleChannelMessage(websocketMessage: ImChannelMessageRespVO) {
const conversationStore = useConversationStore()
// 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
if (conversationStore.loading) {
this.messageBuffer.push({
conversationType: ImConversationType.CHANNEL,
payload: websocketMessage
})
return
}
const sendTimeMs =
typeof websocketMessage.sendTime === 'number'
? websocketMessage.sendTime
: new Date(websocketMessage.sendTime).getTime()
conversationStore.insertMessage(
buildChannelConversationStub(websocketMessage.channelId),
{
id: websocketMessage.id,
clientMessageId: '',
type: websocketMessage.type,
content: websocketMessage.content,
status: 0,
sendTime: sendTimeMs,
senderId: 0,
targetId: websocketMessage.channelId,
selfSend: false,
materialId: websocketMessage.materialId
}
)
// 非当前会话 + 未免打扰:响一下提示音
const conversation = conversationStore.getConversation(
ImConversationType.CHANNEL,
websocketMessage.channelId
)
const isActive =
conversationStore.activeConversation?.type === ImConversationType.CHANNEL
&& conversationStore.activeConversation?.targetId === websocketMessage.channelId
if (!isActive && !conversation?.silent && isNormalMessage(websocketMessage.type)) {
playAudioTip()
}
},
/** content 既可能已是对象也可能是 JSON 字符串(后端用 Map 序列化下发) */
safeParse(raw: unknown): Record<string, any> | null {
if (!raw) {

View File

@ -78,6 +78,7 @@ export interface Message {
receiverUserIds?: number[] // 群定向接收用户列表
receiptStatus?: number // 群回执状态,对齐 ImGroupReceiptStatus仅群消息
readCount?: number // 群回执已读人数(仅群消息)
materialId?: number // 关联频道素材编号(仅频道消息 type=MATERIAL
// ========== 前端扩展字段 ==========
// 发送人显示名一律渲染时实时算utils/user.getSenderDisplayName / getSenderRealNickname
@ -103,6 +104,7 @@ export type ConversationMeta = Omit<Conversation, 'messages'>
export interface ConversationStoreMeta {
privateMessageMaxId: number // 私聊消息最大编号
groupMessageMaxId: number // 群聊消息最大编号
channelMessageMaxId?: number // 频道消息最大编号
conversations: ConversationMeta[] // 会话索引(不含 messages
}

View File

@ -9,6 +9,8 @@ export const ImMessageType = {
MERGE: 107, // 合并转发(对应 OpenIM Merger=107payload 内嵌完整快照)
CARD: 108, // 名片(对应 OpenIM Card=108
FACE: 115, // 表情贴图(对应 OpenIM Face=115Unicode emoji 仍走 TEXT
// ========== 频道消息扩展段125+OpenIM 122 之后未占用,作为频道 / 公众号类消息扩展) ==========
MATERIAL: 125, // 频道素材图文卡片title + coverUrl + summary + url详情拉 get-content
// ========== 信号类2101 / 2200 直接复用 OpenIM 段位编号2201 自有扩展) ==========
RECALL: 2101, // 撤回(对应 OpenIM RevokeNotification=2101
RECEIPT: 2200, // 回执(对应 OpenIM HasReadReceipt=2200
@ -111,7 +113,8 @@ const ImMessageTypeNormals: number[] = [
ImMessageType.VIDEO,
ImMessageType.CARD,
ImMessageType.FACE,
ImMessageType.MERGE
ImMessageType.MERGE,
ImMessageType.MATERIAL // 频道素材计入未读数 + 进会话列表
]
/** 判断是否"普通消息" */
@ -148,7 +151,8 @@ export const ImMessageStatus = {
/** IM 会话类型枚举 */
export const ImConversationType = {
PRIVATE: 1, // 私聊
GROUP: 2 // 群聊
GROUP: 2, // 群聊
CHANNEL: 3 // 频道 / 公众号
} as const
/** ImConversationType 取值(用于消息 payload 字段类型收窄) */
@ -164,6 +168,11 @@ export function isGroupConversation(type: number | undefined): boolean {
return type === ImConversationType.GROUP
}
/** 是否频道会话;同时收窄类型 */
export function isChannelConversation(type: number | undefined): boolean {
return type === ImConversationType.CHANNEL
}
/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum */
export const ImRtcCallMediaType = {
VOICE: 1,
@ -217,10 +226,11 @@ export const ImRtcCallStage = {
/** ImRtcCallStage 取值类型 */
export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE */
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE / ImChannelMessageDTO.TYPE */
export const ImWebSocketMessageType = {
PRIVATE_MESSAGE: 'im-private-message', // 私聊通道
GROUP_MESSAGE: 'im-group-message' // 群聊通道
GROUP_MESSAGE: 'im-group-message', // 群聊通道
CHANNEL_MESSAGE: 'im-channel-message' // 频道通道
} as const
/** IM 群回执状态枚举(对齐后端 ImGroupMessageReceiptStatusEnum */

View File

@ -16,6 +16,7 @@ import {
type CardMessage,
type FaceMessage,
type FileMessage,
type MaterialMessage,
type TextMessage,
type TipSegment
} from './message'
@ -123,6 +124,10 @@ export function summarizeMessageContent(
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
case ImMessageType.MERGE:
return '[聊天记录]'
case ImMessageType.MATERIAL: {
const material = parseMessage<MaterialMessage>(message.content)
return material?.title ? `[频道] ${material.title}` : '[频道]'
}
case ImMessageType.RTC_CALL_START:
case ImMessageType.RTC_CALL_END:
return '[语音通话]'

View File

@ -332,6 +332,15 @@ export interface MergeMessage {
messages: MergeMessageItem[]
}
/** 频道素材消息 payload对齐后端 MaterialMessage */
export interface MaterialMessage {
title?: string
coverUrl?: string
summary?: string
/** 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url */
url?: string
}
// ==================== 合并转发 payload 构造 ====================
/** 单个发送人的快照昵称 / 头像 */