✨ feat(im): 新增频道消息的前端实现
parent
b52ad0c34b
commit
5ebbbf7499
|
|
@ -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' })
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,10 @@ import {
|
||||||
pullGroupMessages as apiPullGroupMessages,
|
pullGroupMessages as apiPullGroupMessages,
|
||||||
type ImGroupMessageRespVO
|
type ImGroupMessageRespVO
|
||||||
} from '@/api/im/message/group'
|
} from '@/api/im/message/group'
|
||||||
|
import {
|
||||||
|
pullChannelMessages as apiPullChannelMessages,
|
||||||
|
type ImChannelMessageRespVO
|
||||||
|
} from '@/api/im/message/channel'
|
||||||
import {
|
import {
|
||||||
ImConversationType,
|
ImConversationType,
|
||||||
ImMessageType,
|
ImMessageType,
|
||||||
|
|
@ -25,8 +29,21 @@ import {
|
||||||
MESSAGE_PRIVATE_READ_ENABLED
|
MESSAGE_PRIVATE_READ_ENABLED
|
||||||
} from '../../utils/config'
|
} from '../../utils/config'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
import { useChannelStore } from '../store/channelStore'
|
||||||
import type { Message } from '../types'
|
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 */
|
/** 私聊:会话归属到对端 userId */
|
||||||
const convertPrivateConversation = (message: ImPrivateMessageRespVO) => {
|
const convertPrivateConversation = (message: ImPrivateMessageRespVO) => {
|
||||||
const targetId = getPrivatePeerId(message)
|
const targetId = getPrivatePeerId(message)
|
||||||
|
|
@ -109,14 +146,20 @@ export const useMessagePuller = () => {
|
||||||
|
|
||||||
/** 循环拉取指定会话类型的消息:以列表最后一条 id 作为下次 minId,直到接口返回空列表 */
|
/** 循环拉取指定会话类型的消息:以列表最后一条 id 作为下次 minId,直到接口返回空列表 */
|
||||||
const pullByType = async (conversationType: number, startMinId: number) => {
|
const pullByType = async (conversationType: number, startMinId: number) => {
|
||||||
// 私聊 / 群聊各自一套接口和分页大小,按 isPrivate 在循环内分支调度
|
// 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度
|
||||||
let minId = startMinId || 0
|
let minId = startMinId || 0
|
||||||
const isPrivate = conversationType === ImConversationType.PRIVATE
|
const isPrivate = conversationType === ImConversationType.PRIVATE
|
||||||
|
const isChannel = conversationType === ImConversationType.CHANNEL
|
||||||
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
|
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
|
||||||
while (true) {
|
while (true) {
|
||||||
const list = isPrivate
|
let list: any[] | undefined
|
||||||
? await apiPullPrivateMessages({ minId, size })
|
if (isPrivate) {
|
||||||
: await apiPullGroupMessages({ minId, size })
|
list = await apiPullPrivateMessages({ minId, size })
|
||||||
|
} else if (isChannel) {
|
||||||
|
list = await apiPullChannelMessages({ minId, size })
|
||||||
|
} else {
|
||||||
|
list = await apiPullGroupMessages({ minId, size })
|
||||||
|
}
|
||||||
if (!list || list.length === 0) {
|
if (!list || list.length === 0) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +167,14 @@ export const useMessagePuller = () => {
|
||||||
// 逐条 dispatch:原消息走 insertMessage;RECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。
|
// 逐条 dispatch:原消息走 insertMessage;RECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。
|
||||||
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到
|
||||||
for (const raw of list) {
|
for (const raw of list) {
|
||||||
|
if (isChannel) {
|
||||||
|
const message = raw as ImChannelMessageRespVO
|
||||||
|
conversationStore.insertMessage(
|
||||||
|
convertChannelConversation(message),
|
||||||
|
convertChannelMessage(message)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
const message = raw as ImPrivateMessageRespVO
|
const message = raw as ImPrivateMessageRespVO
|
||||||
// 特殊:撤回消息的处理
|
// 特殊:撤回消息的处理
|
||||||
|
|
@ -197,10 +248,11 @@ export const useMessagePuller = () => {
|
||||||
try {
|
try {
|
||||||
conversationStore.loading = true
|
conversationStore.loading = true
|
||||||
try {
|
try {
|
||||||
// 并发拉取私聊 + 群聊,降低初始加载耗时
|
// 并发拉取私聊 + 群聊 + 频道,降低初始加载耗时
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
pullByType(ImConversationType.PRIVATE, conversationStore.privateMessageMaxId),
|
pullByType(ImConversationType.PRIVATE, conversationStore.privateMessageMaxId),
|
||||||
pullByType(ImConversationType.GROUP, conversationStore.groupMessageMaxId)
|
pullByType(ImConversationType.GROUP, conversationStore.groupMessageMaxId),
|
||||||
|
pullByType(ImConversationType.CHANNEL, conversationStore.channelMessageMaxId)
|
||||||
])
|
])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[IM] 拉取离线消息失败:', e)
|
console.error('[IM] 拉取离线消息失败:', e)
|
||||||
|
|
@ -214,6 +266,8 @@ export const useMessagePuller = () => {
|
||||||
for (const item of buffered) {
|
for (const item of buffered) {
|
||||||
if (item.conversationType === ImConversationType.PRIVATE) {
|
if (item.conversationType === ImConversationType.PRIVATE) {
|
||||||
wsStore.handlePrivateMessage(item.payload)
|
wsStore.handlePrivateMessage(item.payload)
|
||||||
|
} else if (item.conversationType === ImConversationType.CHANNEL) {
|
||||||
|
wsStore.handleChannelMessage(item.payload)
|
||||||
} else {
|
} else {
|
||||||
wsStore.handleGroupMessage(item.payload)
|
wsStore.handleGroupMessage(item.payload)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,12 @@ export const useMessageSender = () => {
|
||||||
}
|
}
|
||||||
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
|
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
|
||||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
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
|
const readEnabled = isPrivate ? MESSAGE_PRIVATE_READ_ENABLED : MESSAGE_GROUP_READ_ENABLED
|
||||||
if (!readEnabled) {
|
if (!readEnabled) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { useGroupStore } from './store/groupStore'
|
||||||
import { useGroupRequestStore } from './store/groupRequestStore'
|
import { useGroupRequestStore } from './store/groupRequestStore'
|
||||||
import { useDraftStore } from './store/draftStore'
|
import { useDraftStore } from './store/draftStore'
|
||||||
import { useFaceStore } from './store/faceStore'
|
import { useFaceStore } from './store/faceStore'
|
||||||
|
import { useChannelStore } from './store/channelStore'
|
||||||
import { useMessagePuller } from './composables/useMessagePuller'
|
import { useMessagePuller } from './composables/useMessagePuller'
|
||||||
import { useMessageSender } from './composables/useMessageSender'
|
import { useMessageSender } from './composables/useMessageSender'
|
||||||
import { useVoicePlayer } from './composables/useVoicePlayer'
|
import { useVoicePlayer } from './composables/useVoicePlayer'
|
||||||
|
|
@ -63,6 +64,7 @@ const groupStore = useGroupStore()
|
||||||
const groupRequestStore = useGroupRequestStore()
|
const groupRequestStore = useGroupRequestStore()
|
||||||
const draftStore = useDraftStore()
|
const draftStore = useDraftStore()
|
||||||
const faceStore = useFaceStore()
|
const faceStore = useFaceStore()
|
||||||
|
const channelStore = useChannelStore()
|
||||||
const { pullOnce } = useMessagePuller()
|
const { pullOnce } = useMessagePuller()
|
||||||
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
||||||
const voicePlayer = useVoicePlayer()
|
const voicePlayer = useVoicePlayer()
|
||||||
|
|
@ -72,9 +74,9 @@ onMounted(async () => {
|
||||||
// 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程
|
// 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程
|
||||||
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
|
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
|
||||||
// 0.2 我管理的群下未处理加群申请:会话列表全局入口 / 群顶部横幅都从这份 store 派生;后台拉,不阻断
|
// 0.2 我管理的群下未处理加群申请:会话列表全局入口 / 群顶部横幅都从这份 store 派生;后台拉,不阻断
|
||||||
void groupRequestStore.fetchUnhandledList().catch((e) =>
|
void groupRequestStore
|
||||||
console.warn('[IM] 拉取未处理加群申请失败', e)
|
.fetchUnhandledList()
|
||||||
)
|
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
|
||||||
|
|
||||||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||||
conversationStore.loading = true
|
conversationStore.loading = true
|
||||||
|
|
@ -101,6 +103,10 @@ onMounted(async () => {
|
||||||
} else {
|
} else {
|
||||||
requiredFetches.push(groupStore.fetchGroups())
|
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) {
|
if (requiredFetches.length > 0) {
|
||||||
await Promise.all(requiredFetches)
|
await Promise.all(requiredFetches)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,13 @@
|
||||||
[聊天记录]
|
[聊天记录]
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 频道素材:图文卡片,点击拉富文本 / 跳外链 -->
|
||||||
|
<!-- TODO @AI:在对话界面里时,目前碰到消息无法跳转的情况。。。ps:考虑到可以兼容到私聊、群聊消息,是不是把 materialId 也在 content 里存储一份!说白了,materialId 只是为了管理后台的检索,其它地方尽量使用 content 里的 materialId 字段。 -->
|
||||||
|
<MaterialBubble
|
||||||
|
v-else-if="isMaterial"
|
||||||
|
:message="{ content: props.content, materialId: props.materialId }"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 未知类型降级 -->
|
<!-- 未知类型降级 -->
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -184,6 +191,7 @@ import { summarizeMessageContent } from '@/views/im/utils/conversation'
|
||||||
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
|
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
|
||||||
import TipSegments from './TipSegments.vue'
|
import TipSegments from './TipSegments.vue'
|
||||||
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
|
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
|
||||||
|
import MaterialBubble from './MaterialBubble.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ImMessageBubble' })
|
defineOptions({ name: 'ImMessageBubble' })
|
||||||
|
|
||||||
|
|
@ -198,6 +206,8 @@ const props = defineProps<{
|
||||||
uploadProgress?: number | null
|
uploadProgress?: number | null
|
||||||
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
|
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
|
||||||
mentions?: MentionCandidate[]
|
mentions?: MentionCandidate[]
|
||||||
|
/** MATERIAL 气泡的素材编号;点击「查看详情」时拉富文本正文 */
|
||||||
|
materialId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -216,6 +226,7 @@ const isVideo = computed(() => props.type === ImMessageType.VIDEO)
|
||||||
const isFace = computed(() => props.type === ImMessageType.FACE)
|
const isFace = computed(() => props.type === ImMessageType.FACE)
|
||||||
const isCard = computed(() => props.type === ImMessageType.CARD)
|
const isCard = computed(() => props.type === ImMessageType.CARD)
|
||||||
const isMerge = computed(() => props.type === ImMessageType.MERGE)
|
const isMerge = computed(() => props.type === ImMessageType.MERGE)
|
||||||
|
const isMaterial = computed(() => props.type === ImMessageType.MATERIAL)
|
||||||
|
|
||||||
/** 媒体上传中:uploadProgress 非 null 即视为上传中 */
|
/** 媒体上传中:uploadProgress 非 null 即视为上传中 */
|
||||||
const isUploading = computed(() => props.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 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 自动识别 + 普通文本三段拼接 */
|
/** 文本气泡 segment 数组:mention 高亮 + URL 自动识别 + 普通文本三段拼接 */
|
||||||
const textSegments = computed(() => {
|
const textSegments = computed(() => {
|
||||||
|
|
@ -242,14 +255,18 @@ const textSegments = computed(() => {
|
||||||
const imagePayload = computed(() =>
|
const imagePayload = computed(() =>
|
||||||
isImage.value ? (parsedContent.value as ImageMessage | null) : null
|
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(() =>
|
const voicePayload = computed(() =>
|
||||||
isVoice.value ? (parsedContent.value as AudioMessage | null) : null
|
isVoice.value ? (parsedContent.value as AudioMessage | null) : null
|
||||||
)
|
)
|
||||||
const videoPayload = computed(() =>
|
const videoPayload = computed(() =>
|
||||||
isVideo.value ? (parsedContent.value as VideoMessage | null) : null
|
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(() =>
|
const mergePayload = computed(() =>
|
||||||
isMerge.value ? (parsedContent.value as MergeMessage | null) : null
|
isMerge.value ? (parsedContent.value as MergeMessage | null) : null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -87,20 +87,27 @@
|
||||||
<Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" />
|
<Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- 消息行:头像 + 气泡内部按 selfSend reverse -->
|
<!-- 消息行:头像 + 气泡内部按 selfSend reverse;频道素材仿公众号样式居中且不显示头像 -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 min-w-0 gap-2 items-start"
|
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
|
<UserAvatar
|
||||||
|
v-if="!isMaterial"
|
||||||
:id="message.selfSend ? userStore.getUser?.id : message.senderId"
|
:id="message.selfSend ? userStore.getUser?.id : message.senderId"
|
||||||
:name="senderRealNickname"
|
:name="senderRealNickname"
|
||||||
:url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
|
:url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
|
||||||
:size="36"
|
: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
|
<div
|
||||||
v-if="showSenderName"
|
v-if="showSenderName"
|
||||||
|
|
@ -116,6 +123,7 @@
|
||||||
:self-send="message.selfSend"
|
:self-send="message.selfSend"
|
||||||
:upload-progress="message.uploadProgress"
|
:upload-progress="message.uploadProgress"
|
||||||
:mentions="textMentions"
|
:mentions="textMentions"
|
||||||
|
:material-id="message.materialId"
|
||||||
@click-card="handleCardClick"
|
@click-card="handleCardClick"
|
||||||
@open-merge="handleMergeOpen"
|
@open-merge="handleMergeOpen"
|
||||||
/>
|
/>
|
||||||
|
|
@ -292,11 +300,14 @@ const { copy: copyToClipboard } = useClipboard({ legacy: true })
|
||||||
|
|
||||||
// ==================== 消息类型判断 ====================
|
// ==================== 消息类型判断 ====================
|
||||||
|
|
||||||
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染 */
|
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染;频道素材每条都显示 */
|
||||||
const shouldShowTimeTip = computed(() => {
|
const shouldShowTimeTip = computed(() => {
|
||||||
if (!props.message.sendTime) {
|
if (!props.message.sendTime) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (props.message.type === ImMessageType.MATERIAL) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (!props.prevMessage?.sendTime) {
|
if (!props.prevMessage?.sendTime) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -306,6 +317,8 @@ const shouldShowTimeTip = computed(() => {
|
||||||
/** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble) */
|
/** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble) */
|
||||||
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
|
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
|
||||||
const isMerge = computed(() => props.message.type === ImMessageType.MERGE)
|
const isMerge = computed(() => props.message.type === ImMessageType.MERGE)
|
||||||
|
/** 频道素材消息:仿微信公众号样式 —— 卡片靠右、不显示左侧头像 */
|
||||||
|
const isMaterial = computed(() => props.message.type === ImMessageType.MATERIAL)
|
||||||
|
|
||||||
|
|
||||||
// ==================== 事件消息(撤回 / 好友 / 群广播) ====================
|
// ==================== 事件消息(撤回 / 好友 / 群广播) ====================
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,10 @@
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部:输入框常驻;多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
|
<!-- 底部:输入框(频道单向消息无需输入框);多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
|
||||||
<div class="relative">
|
<!-- TODO @AI:暂时去掉频道的右键:引用、多选; -->
|
||||||
|
<!-- TODO @AI:转发时,不允许选择【频道】。这块要屏蔽下; -->
|
||||||
|
<div v-if="!isChannel" class="relative">
|
||||||
<MessageInput />
|
<MessageInput />
|
||||||
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
|
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -295,6 +297,9 @@ const isGroup = computed(
|
||||||
const isPrivate = computed(
|
const isPrivate = computed(
|
||||||
() => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
|
() => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
|
||||||
)
|
)
|
||||||
|
const isChannel = computed(
|
||||||
|
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
|
||||||
|
)
|
||||||
|
|
||||||
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
|
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
|
||||||
const showNotFriendBanner = computed(() => {
|
const showNotFriendBanner = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
activeConversation: null as Conversation | null, // 当前激活的会话
|
activeConversation: null as Conversation | null, // 当前激活的会话
|
||||||
privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标
|
privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标
|
||||||
groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标
|
groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标
|
||||||
|
channelMessageMaxId: 0, // 频道最大消息 id,作为 pull 的游标
|
||||||
loading: false, // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
|
loading: false, // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
|
||||||
recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表(按推送顺序倒序,最大 CONVERSATION_RECENT_FORWARD_MAX 个)
|
recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表(按推送顺序倒序,最大 CONVERSATION_RECENT_FORWARD_MAX 个)
|
||||||
}),
|
}),
|
||||||
|
|
@ -195,6 +196,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
}
|
}
|
||||||
this.privateMessageMaxId = Number(meta.privateMessageMaxId) || 0
|
this.privateMessageMaxId = Number(meta.privateMessageMaxId) || 0
|
||||||
this.groupMessageMaxId = Number(meta.groupMessageMaxId) || 0
|
this.groupMessageMaxId = Number(meta.groupMessageMaxId) || 0
|
||||||
|
this.channelMessageMaxId = Number((meta as any).channelMessageMaxId) || 0
|
||||||
if (!meta.conversations || meta.conversations.length === 0) {
|
if (!meta.conversations || meta.conversations.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -265,10 +267,11 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
const meta: ConversationStoreMeta = {
|
const meta: ConversationStoreMeta = {
|
||||||
privateMessageMaxId: this.privateMessageMaxId,
|
privateMessageMaxId: this.privateMessageMaxId,
|
||||||
groupMessageMaxId: this.groupMessageMaxId,
|
groupMessageMaxId: this.groupMessageMaxId,
|
||||||
|
channelMessageMaxId: this.channelMessageMaxId,
|
||||||
conversations: this.conversations
|
conversations: this.conversations
|
||||||
.filter((c) => !c.deleted)
|
.filter((c) => !c.deleted)
|
||||||
.map(({ messages, ...rest }) => rest)
|
.map(({ messages, ...rest }) => rest)
|
||||||
}
|
} as ConversationStoreMeta
|
||||||
const tasks: Promise<unknown>[] = [
|
const tasks: Promise<unknown>[] = [
|
||||||
imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
|
imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
|
||||||
]
|
]
|
||||||
|
|
@ -871,6 +874,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (messageId > this.groupMessageMaxId) {
|
if (messageId > this.groupMessageMaxId) {
|
||||||
this.groupMessageMaxId = messageId
|
this.groupMessageMaxId = messageId
|
||||||
}
|
}
|
||||||
|
} else if (conversationType === ImConversationType.CHANNEL) {
|
||||||
|
if (messageId > this.channelMessageMaxId) {
|
||||||
|
this.channelMessageMaxId = messageId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import {
|
||||||
} from './rtcStore'
|
} from './rtcStore'
|
||||||
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
|
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
|
||||||
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
|
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
|
||||||
|
import type { ImChannelMessageRespVO } from '@/api/im/message/channel'
|
||||||
|
import { buildChannelConversationStub } from '../composables/useMessagePuller'
|
||||||
import type {
|
import type {
|
||||||
WebSocketFrame,
|
WebSocketFrame,
|
||||||
ImPrivateMessageDTO,
|
ImPrivateMessageDTO,
|
||||||
|
|
@ -117,6 +119,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
messageBuffer: [] as Array<
|
messageBuffer: [] as Array<
|
||||||
| { conversationType: typeof ImConversationType.PRIVATE; payload: ImPrivateMessageDTO }
|
| { conversationType: typeof ImConversationType.PRIVATE; payload: ImPrivateMessageDTO }
|
||||||
| { conversationType: typeof ImConversationType.GROUP; payload: ImGroupMessageDTO }
|
| { conversationType: typeof ImConversationType.GROUP; payload: ImGroupMessageDTO }
|
||||||
|
| { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageRespVO }
|
||||||
> // 初始化加载期内,先把普通消息丢进缓冲区,pull 完成后再一次性回放
|
> // 初始化加载期内,先把普通消息丢进缓冲区,pull 完成后再一次性回放
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -209,11 +212,60 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
case ImWebSocketMessageType.GROUP_MESSAGE:
|
case ImWebSocketMessageType.GROUP_MESSAGE:
|
||||||
this.dispatchGroupFrame(content as ImGroupMessageDTO)
|
this.dispatchGroupFrame(content as ImGroupMessageDTO)
|
||||||
break
|
break
|
||||||
|
case ImWebSocketMessageType.CHANNEL_MESSAGE:
|
||||||
|
this.handleChannelMessage(content as ImChannelMessageRespVO)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
console.debug('[IM WS] 未识别事件', frame)
|
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 序列化下发) */
|
/** content 既可能已是对象也可能是 JSON 字符串(后端用 Map 序列化下发) */
|
||||||
safeParse(raw: unknown): Record<string, any> | null {
|
safeParse(raw: unknown): Record<string, any> | null {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export interface Message {
|
||||||
receiverUserIds?: number[] // 群定向接收用户列表
|
receiverUserIds?: number[] // 群定向接收用户列表
|
||||||
receiptStatus?: number // 群回执状态,对齐 ImGroupReceiptStatus(仅群消息)
|
receiptStatus?: number // 群回执状态,对齐 ImGroupReceiptStatus(仅群消息)
|
||||||
readCount?: number // 群回执已读人数(仅群消息)
|
readCount?: number // 群回执已读人数(仅群消息)
|
||||||
|
materialId?: number // 关联频道素材编号(仅频道消息 type=MATERIAL)
|
||||||
|
|
||||||
// ========== 前端扩展字段 ==========
|
// ========== 前端扩展字段 ==========
|
||||||
// 发送人显示名一律渲染时实时算:utils/user.getSenderDisplayName / getSenderRealNickname
|
// 发送人显示名一律渲染时实时算:utils/user.getSenderDisplayName / getSenderRealNickname
|
||||||
|
|
@ -103,6 +104,7 @@ export type ConversationMeta = Omit<Conversation, 'messages'>
|
||||||
export interface ConversationStoreMeta {
|
export interface ConversationStoreMeta {
|
||||||
privateMessageMaxId: number // 私聊消息最大编号
|
privateMessageMaxId: number // 私聊消息最大编号
|
||||||
groupMessageMaxId: number // 群聊消息最大编号
|
groupMessageMaxId: number // 群聊消息最大编号
|
||||||
|
channelMessageMaxId?: number // 频道消息最大编号
|
||||||
conversations: ConversationMeta[] // 会话索引(不含 messages)
|
conversations: ConversationMeta[] // 会话索引(不含 messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export const ImMessageType = {
|
||||||
MERGE: 107, // 合并转发(对应 OpenIM Merger=107;payload 内嵌完整快照)
|
MERGE: 107, // 合并转发(对应 OpenIM Merger=107;payload 内嵌完整快照)
|
||||||
CARD: 108, // 名片(对应 OpenIM Card=108)
|
CARD: 108, // 名片(对应 OpenIM Card=108)
|
||||||
FACE: 115, // 表情贴图(对应 OpenIM Face=115;Unicode emoji 仍走 TEXT)
|
FACE: 115, // 表情贴图(对应 OpenIM Face=115;Unicode emoji 仍走 TEXT)
|
||||||
|
// ========== 频道消息扩展段(125+;OpenIM 122 之后未占用,作为频道 / 公众号类消息扩展) ==========
|
||||||
|
MATERIAL: 125, // 频道素材(图文卡片:title + coverUrl + summary + url;详情拉 get-content)
|
||||||
// ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ==========
|
// ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ==========
|
||||||
RECALL: 2101, // 撤回(对应 OpenIM RevokeNotification=2101)
|
RECALL: 2101, // 撤回(对应 OpenIM RevokeNotification=2101)
|
||||||
RECEIPT: 2200, // 回执(对应 OpenIM HasReadReceipt=2200)
|
RECEIPT: 2200, // 回执(对应 OpenIM HasReadReceipt=2200)
|
||||||
|
|
@ -111,7 +113,8 @@ const ImMessageTypeNormals: number[] = [
|
||||||
ImMessageType.VIDEO,
|
ImMessageType.VIDEO,
|
||||||
ImMessageType.CARD,
|
ImMessageType.CARD,
|
||||||
ImMessageType.FACE,
|
ImMessageType.FACE,
|
||||||
ImMessageType.MERGE
|
ImMessageType.MERGE,
|
||||||
|
ImMessageType.MATERIAL // 频道素材计入未读数 + 进会话列表
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 判断是否"普通消息" */
|
/** 判断是否"普通消息" */
|
||||||
|
|
@ -148,7 +151,8 @@ export const ImMessageStatus = {
|
||||||
/** IM 会话类型枚举 */
|
/** IM 会话类型枚举 */
|
||||||
export const ImConversationType = {
|
export const ImConversationType = {
|
||||||
PRIVATE: 1, // 私聊
|
PRIVATE: 1, // 私聊
|
||||||
GROUP: 2 // 群聊
|
GROUP: 2, // 群聊
|
||||||
|
CHANNEL: 3 // 频道 / 公众号
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** ImConversationType 取值(用于消息 payload 字段类型收窄) */
|
/** ImConversationType 取值(用于消息 payload 字段类型收窄) */
|
||||||
|
|
@ -164,6 +168,11 @@ export function isGroupConversation(type: number | undefined): boolean {
|
||||||
return type === ImConversationType.GROUP
|
return type === ImConversationType.GROUP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 是否频道会话;同时收窄类型 */
|
||||||
|
export function isChannelConversation(type: number | undefined): boolean {
|
||||||
|
return type === ImConversationType.CHANNEL
|
||||||
|
}
|
||||||
|
|
||||||
/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum) */
|
/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum) */
|
||||||
export const ImRtcCallMediaType = {
|
export const ImRtcCallMediaType = {
|
||||||
VOICE: 1,
|
VOICE: 1,
|
||||||
|
|
@ -217,10 +226,11 @@ export const ImRtcCallStage = {
|
||||||
/** ImRtcCallStage 取值类型 */
|
/** ImRtcCallStage 取值类型 */
|
||||||
export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
|
export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
|
||||||
|
|
||||||
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE) */
|
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE / ImChannelMessageDTO.TYPE) */
|
||||||
export const ImWebSocketMessageType = {
|
export const ImWebSocketMessageType = {
|
||||||
PRIVATE_MESSAGE: 'im-private-message', // 私聊通道
|
PRIVATE_MESSAGE: 'im-private-message', // 私聊通道
|
||||||
GROUP_MESSAGE: 'im-group-message' // 群聊通道
|
GROUP_MESSAGE: 'im-group-message', // 群聊通道
|
||||||
|
CHANNEL_MESSAGE: 'im-channel-message' // 频道通道
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** IM 群回执状态枚举(对齐后端 ImGroupMessageReceiptStatusEnum) */
|
/** IM 群回执状态枚举(对齐后端 ImGroupMessageReceiptStatusEnum) */
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type CardMessage,
|
type CardMessage,
|
||||||
type FaceMessage,
|
type FaceMessage,
|
||||||
type FileMessage,
|
type FileMessage,
|
||||||
|
type MaterialMessage,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type TipSegment
|
type TipSegment
|
||||||
} from './message'
|
} from './message'
|
||||||
|
|
@ -123,6 +124,10 @@ export function summarizeMessageContent(
|
||||||
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
|
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
|
||||||
case ImMessageType.MERGE:
|
case ImMessageType.MERGE:
|
||||||
return '[聊天记录]'
|
return '[聊天记录]'
|
||||||
|
case ImMessageType.MATERIAL: {
|
||||||
|
const material = parseMessage<MaterialMessage>(message.content)
|
||||||
|
return material?.title ? `[频道] ${material.title}` : '[频道]'
|
||||||
|
}
|
||||||
case ImMessageType.RTC_CALL_START:
|
case ImMessageType.RTC_CALL_START:
|
||||||
case ImMessageType.RTC_CALL_END:
|
case ImMessageType.RTC_CALL_END:
|
||||||
return '[语音通话]'
|
return '[语音通话]'
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,15 @@ export interface MergeMessage {
|
||||||
messages: MergeMessageItem[]
|
messages: MergeMessageItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频道素材消息 payload(对齐后端 MaterialMessage) */
|
||||||
|
export interface MaterialMessage {
|
||||||
|
title?: string
|
||||||
|
coverUrl?: string
|
||||||
|
summary?: string
|
||||||
|
/** 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url */
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 合并转发 payload 构造 ====================
|
// ==================== 合并转发 payload 构造 ====================
|
||||||
|
|
||||||
/** 单个发送人的快照昵称 / 头像 */
|
/** 单个发送人的快照昵称 / 头像 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue