✨ 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,
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
// ==================== 事件消息(撤回 / 好友 / 群广播) ====================
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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 '[语音通话]'
|
||||
|
|
|
|||
|
|
@ -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 构造 ====================
|
||||
|
||||
/** 单个发送人的快照昵称 / 头像 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue