diff --git a/src/api/im/manager/channel/index.ts b/src/api/im/manager/channel/index.ts new file mode 100644 index 000000000..f6313e71d --- /dev/null +++ b/src/api/im/manager/channel/index.ts @@ -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({ url: '/im/manager/channel/simple-list' }) +} diff --git a/src/api/im/manager/channel/material/index.ts b/src/api/im/manager/channel/material/index.ts new file mode 100644 index 000000000..e72d8182e --- /dev/null +++ b/src/api/im/manager/channel/material/index.ts @@ -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 }) +} diff --git a/src/api/im/manager/channel/message/index.ts b/src/api/im/manager/channel/message/index.ts new file mode 100644 index 000000000..4b13356a0 --- /dev/null +++ b/src/api/im/manager/channel/message/index.ts @@ -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 }) +} diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts new file mode 100644 index 000000000..9a69fb3b2 --- /dev/null +++ b/src/api/im/message/channel/index.ts @@ -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({ + url: '/im/channel/message/pull', + params + }) +} + +// 获取频道素材详情;用于客户端点击图文卡片渲染详情页 +// TODO @AI:这个地址,也要改把。 +export const getChannelMaterial = (id: number) => { + return request.get({ url: '/im/channel/material/get?id=' + id }) +} diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 7bca2430d..859429a0b 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -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 // 详情页拉富文本用 + } + } + + /** 频道:会话归属到 channelId;name / 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:原消息走 insertMessage;RECALL 信号走 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) } diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 74a6e9ffe..f0576352a 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -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 diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index 896450c57..138e2ac7e 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -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) } diff --git a/src/views/im/home/pages/conversation/components/message/MessageBubble.vue b/src/views/im/home/pages/conversation/components/message/MessageBubble.vue index 985e95c89..857f7d2a4 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageBubble.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageBubble.vue @@ -149,6 +149,13 @@ [聊天记录] + + + +
() 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(() => 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 ) diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 65af80f35..9a6487da4 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -87,20 +87,27 @@ - +
- + -
+
@@ -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) // ==================== 事件消息(撤回 / 好友 / 群广播) ==================== diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 3c4c19edf..1f9c64610 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -163,8 +163,10 @@
- -
+ + + +
@@ -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(() => { diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index b85deb04d..a5851fd8a 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -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[] = [ 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 + } } }, diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index c7501d6a6..0da3c9e84 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -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 | null { if (!raw) { diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 2866cd240..077b7eb3a 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -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 export interface ConversationStoreMeta { privateMessageMaxId: number // 私聊消息最大编号 groupMessageMaxId: number // 群聊消息最大编号 + channelMessageMaxId?: number // 频道消息最大编号 conversations: ConversationMeta[] // 会话索引(不含 messages) } diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 4e923759c..c19d1d843 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -9,6 +9,8 @@ export const ImMessageType = { MERGE: 107, // 合并转发(对应 OpenIM Merger=107;payload 内嵌完整快照) CARD: 108, // 名片(对应 OpenIM Card=108) FACE: 115, // 表情贴图(对应 OpenIM Face=115;Unicode 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) */ diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts index ccf1af8af..0c180b2ae 100644 --- a/src/views/im/utils/conversation.ts +++ b/src/views/im/utils/conversation.ts @@ -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(message.content)) case ImMessageType.MERGE: return '[聊天记录]' + case ImMessageType.MATERIAL: { + const material = parseMessage(message.content) + return material?.title ? `[频道] ${material.title}` : '[频道]' + } case ImMessageType.RTC_CALL_START: case ImMessageType.RTC_CALL_END: return '[语音通话]' diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index 5da6eab75..f4b0208c9 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -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 构造 ==================== /** 单个发送人的快照昵称 / 头像 */