feat(im): 新增 MessageItem.vue

im
YunaiV 2026-04-27 19:11:31 +08:00
parent ccc9aca21c
commit cb5d30e327
1 changed files with 556 additions and 0 deletions

View File

@ -0,0 +1,556 @@
<template>
<!-- TODO @AI自己发的消息是不是头像在最右侧 -->
<!-- TODO @AI消息内容的气泡是不是指向自己 -->
<!-- TODO @AI@全部人的消息高亮在消息内容里 -->
<!-- TODO @AI文件消息展示的不如微信的好看可能要优化下 -->
<!-- 时间分隔线TIP_TIME=20居中灰色时间 -->
<div
v-if="isTipTime"
class="flex items-center justify-center px-4 pt-2.5 pb-0.5 text-12px text-[var(--el-text-color-disabled)]"
>
{{ formatTipTime(message.sendTime) }}
</div>
<!-- 系统提示文案TIP_TEXT=21 -->
<div
v-else-if="isTipText"
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
>
{{ tipText }}
</div>
<!-- 撤回消息整行展示灰色 tip 文案 -->
<div
v-else-if="isRecall"
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
>
{{ recallTip }}
</div>
<!-- 普通消息气泡 -->
<div
v-else
class="flex gap-2 items-start px-4 py-2"
:class="{ 'flex-row-reverse': message.selfSend }"
@contextmenu.prevent="handleContextMenu"
>
<!-- 对方消息头像在左点头像弹 UserInfoCard -->
<UserAvatar
v-if="!message.selfSend"
:id="message.senderId"
:name="message.senderNickName || String(message.senderId)"
:url="senderAvatar"
:size="36"
/>
<div class="flex flex-col gap-0.5 max-w-[70%]" :class="{ 'items-end': message.selfSend }">
<!-- 群聊对方消息气泡上方显示发送者昵称 -->
<div
v-if="showSenderName"
class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight"
>
{{ message.senderNickName || message.senderId }}
</div>
<div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
<!-- 消息内容 type v-if 分支 -->
<!-- 文本消息 -->
<div
v-if="isText"
class="px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
:class="
message.selfSend
? 'text-black bg-[#95ec69]'
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
"
>
{{ textContent }}
</div>
<!-- 图片消息点击大图预览 <el-image> 自身承接 -->
<el-image
v-else-if="isImage && imagePayload"
class="max-w-[220px] rounded cursor-zoom-in"
:src="imagePayload.thumbnailUrl || imagePayload.url"
:preview-src-list="[imagePayload.url]"
:preview-teleported="true"
fit="contain"
/>
<!-- 文件消息 -->
<div
v-else-if="isFile && filePayload"
class="flex gap-2.5 items-center min-w-[220px] max-w-[320px] px-3.5 py-2.5 border rounded cursor-pointer transition-colors"
:class="
message.selfSend
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
"
@click="handleFileClick"
>
<el-icon class="message-bubble__file-icon flex-shrink-0 !text-[32px]">
<Document />
</el-icon>
<div class="flex-1 min-w-0">
<div
class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ filePayload.name }}
</div>
<div class="mt-0.5 text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(filePayload.size) }}
</div>
</div>
</div>
<!-- 语音消息 -->
<div
v-else-if="isVoice && voicePayload"
class="flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
:class="message.selfSend ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]'"
@click="handleVoiceClick"
>
<el-icon
class="message-bubble__voice-icon !text-[18px]"
:class="{ 'im-voice-playing': voicePlaying }"
>
<Microphone />
</el-icon>
<span class="text-13px text-[var(--el-text-color-primary)]">
{{ formatSeconds(voicePayload.duration) }}
</span>
</div>
<!-- 视频消息直接用原生 <video controls> 内嵌播放poster 取后端给的封面图
不接入第三方播放器不重写 UI保持和图片 / 文件分支一样的轻量观感 -->
<video
v-else-if="isVideo && videoPayload?.url"
class="max-w-[280px] max-h-[320px] rounded bg-black"
:src="videoPayload.url"
:poster="videoPayload.coverUrl"
controls
preload="metadata"
></video>
<!-- 视频消息但 payload 解析失败 / url降级展示避免出现裸的 [视频消息] -->
<div
v-else-if="isVideo"
class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-light)]"
>
[视频消息]
</div>
<!-- 未知类型降级展示 -->
<div
v-else
class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-light)]"
>
[不支持的消息类型]
</div>
<!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 -->
<div class="flex gap-1.5 items-center text-base">
<template v-if="message.selfSend">
<el-icon v-if="message.status === ImMessageStatus.SENDING" class="is-loading">
<Loading />
</el-icon>
<el-icon
v-else-if="message.status === ImMessageStatus.FAILED"
color="#f56c6c"
class="cursor-pointer"
title="发送失败,点击重试"
@click="handleResend"
>
<WarningFilled />
</el-icon>
<!-- 已读态私聊 -->
<span
v-else-if="privateReadLabel"
class="text-12px whitespace-nowrap"
:class="
message.status === ImMessageStatus.READ
? 'text-[#409eff]'
: 'text-[var(--el-text-color-secondary)]'
"
>
{{ privateReadLabel }}
</span>
<!-- 群回执点击弹 popover 展示已读 / 未读成员列表 -->
<MessageReadStatus
v-else-if="showGroupReadStatus"
:message="message"
:group-id="conversationStore.activeConversation?.targetId || 0"
:group-members="groupMembersForReadStatus"
class="text-12px whitespace-nowrap text-[var(--el-text-color-secondary)]"
/>
</template>
<!-- @ 我提示 -->
<el-tag
v-if="!message.selfSend && isAtMe"
type="danger"
size="small"
effect="plain"
class="ml-1"
>
@
</el-tag>
</div>
</div>
</div>
<!-- 自己消息头像在右点头像也能弹 UserInfoCard -->
<UserAvatar
v-if="message.selfSend"
:id="userStore.getUser?.id"
:name="userStore.getUser?.nickname"
:url="userStore.getUser?.avatar"
:size="36"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import { Loading, WarningFilled, Document, Microphone } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import {
ImMessageType,
ImMessageStatus,
ImGroupReceiptStatus,
ImConversationType
} from '../../../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
import {
parseMessage,
buildRecallTip,
type TextMessage,
type ImageMessage,
type FileMessage,
type AudioMessage,
type VideoMessage
} from '../../../../../utils/message'
import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue'
import UserAvatar from '../../../../components/UserAvatar.vue'
import type { GroupMemberLite } from '../ChatGroupMember.vue'
defineOptions({ name: 'ImMessageItem' })
const props = defineProps<{
message: Message
}>()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
/** 时间分隔线 / 系统提示文案 */
const isTipTime = computed(() => props.message.type === ImMessageType.TIP_TIME)
const isTipText = computed(() => props.message.type === ImMessageType.TIP_TEXT)
/** 是否文本消息 */
const isText = computed(() => props.message.type === ImMessageType.TEXT)
const isImage = computed(() => props.message.type === ImMessageType.IMAGE)
const isFile = computed(() => props.message.type === ImMessageType.FILE)
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
/** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */
const showSenderName = computed(() => {
if (props.message.selfSend) {
return false
}
return conversationStore.activeConversation?.type === ImConversationType.GROUP
})
// conversation.avatar openConversation
const senderAvatar = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || props.message.selfSend) {
return ''
}
if (conversation.type === ImConversationType.GROUP) {
const group = groupStore.getGroup(conversation.targetId)
return group?.members?.find((m) => m.userId === props.message.senderId)?.avatar || ''
}
return conversation.avatar || ''
})
/**
* 时间分隔线文案
* - 今天HH:mm
* - 昨天昨天 HH:mm
* - 一周内周X HH:mm
* - 超过一周MM-dd HH:mm
*/
function formatTipTime(timestamp: number): string {
if (!timestamp) {
return ''
}
const d = new Date(timestamp)
const now = new Date()
const pad = (n: number) => n.toString().padStart(2, '0')
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
if (sameDay(d, now)) {
return hm
}
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (sameDay(d, yesterday)) {
return `昨天 ${hm}`
}
if (now.getTime() - d.getTime() < 7 * 24 * 60 * 60 * 1000) {
const weeks = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${weeks[d.getDay()]} ${hm}`
}
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`
}
/** 文本内容 */
const textContent = computed(() => parseMessage<TextMessage>(props.message.content)?.content ?? '')
/**
* TIP_TEXT 文案后端这里 content 通常是裸字符串群解散 / 退群 / 踢人 等系统提示
* 老接口可能包成 {"content": "..."}解析得到 .content 就用否则当裸文案展示避免出现空行
*/
const tipText = computed(() => {
const raw = props.message.content || ''
if (!raw) {
return ''
}
const parsed = parseMessage<TextMessage>(raw)
if (parsed && typeof parsed.content === 'string') {
return parsed.content
}
return raw
})
const imagePayload = computed(() =>
isImage.value ? parseMessage<ImageMessage>(props.message.content) : null
)
const filePayload = computed(() =>
isFile.value ? parseMessage<FileMessage>(props.message.content) : null
)
const voicePayload = computed(() =>
isVoice.value ? parseMessage<AudioMessage>(props.message.content) : null
)
const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.message.content) : null
)
/** 文件点击 → 新窗口下载 */
function handleFileClick() {
if (!filePayload.value?.url) {
return
}
window.open(filePayload.value.url, '_blank')
}
/** 语音点击 → 当前只做简单 Audio() 播放;真实项目可接入全局 Player */
const voicePlaying = ref(false)
let currentAudio: HTMLAudioElement | null = null
function handleVoiceClick() {
if (!voicePayload.value?.url) {
return
}
if (voicePlaying.value && currentAudio) {
currentAudio.pause()
voicePlaying.value = false
return
}
currentAudio = new Audio(voicePayload.value.url)
voicePlaying.value = true
currentAudio.addEventListener('ended', () => {
voicePlaying.value = false
currentAudio = null
})
currentAudio.play().catch(() => {
voicePlaying.value = false
})
}
onBeforeUnmount(() => {
if (currentAudio) {
currentAudio.pause()
currentAudio.src = ''
currentAudio = null
}
voicePlaying.value = false
})
// buildRecallTip 线 content
// recallMessage ""
const recallTip = computed(() =>
buildRecallTip(props.message.senderNickName, props.message.selfSend)
)
/** 私聊「已读 / 未读」态(仅对自己发送的私聊消息展示) */
const privateReadLabel = computed(() => {
if (!props.message.selfSend) {
return ''
}
if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) {
return ''
}
if (props.message.status === ImMessageStatus.READ) {
return '已读'
}
if (props.message.status === ImMessageStatus.UNREAD) {
return '未读'
}
return ''
})
/**是否需要显示群回执 popover自己发的群消息且后端开启了回执NO_RECEIPT 表示发送时未要求回执,不渲染) */
const showGroupReadStatus = computed(() => {
if (!props.message.selfSend) {
return false
}
if (conversationStore.activeConversation?.type !== ImConversationType.GROUP) {
return false
}
const status = props.message.receiptStatus
if (status === undefined || status === null) {
return false
}
return status !== ImGroupReceiptStatus.NO_RECEIPT
})
// MessageReadStatus
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
// TODO @AIgroup
const g = groupStore.getGroup(conversation.targetId)
return (g?.members || []).map((m) => ({
userId: m.userId,
showNickName: m.displayUserName || m.nickname,
showImage: m.avatar,
quit: m.status === CommonStatusEnum.DISABLE
}))
})
/** 是否 @我(群消息展示小徽标) */
const isAtMe = computed(() => {
const myId = Number(userStore.getUser?.id) || 0
if (!myId) {
return false
}
return (props.message.atUserIds || []).includes(myId)
})
/**
* 右键菜单项
* - 删除从本地消息列表移除不动后端
* - 撤回仅自己发送已送达 id的消息
*
* TIP_TIME / TIP_TEXT 态不弹菜单
*/
async function handleContextMenu(e: MouseEvent) {
if (isTipTime.value || isTipText.value) {
return
}
// TODO @AI
const items: Array<{ key: string; name: string; disabled?: boolean }> = [
{ key: 'DELETE', name: '删除' }
]
if (props.message.selfSend && !!props.message.id && !isRecall.value) {
items.push({ key: 'RECALL', name: '撤回' })
}
// TODO @AI
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
if (item.key === 'RECALL') {
await handleRecall()
} else if (item.key === 'DELETE') {
handleDelete()
}
})
}
// TODO @AI
async function handleRecall() {
try {
await ElMessageBox.confirm('确定要撤回这条消息吗?', '撤回消息', { type: 'warning' })
await recall(props.message)
} catch {
//
}
}
/**
* 失败消息点击重试先把 FAILED 的本地占位消息从列表里去掉再用同样的 type + content 走一遍 sendRaw
* 后者会新建 clientMessageId 并重新跑乐观更新流程
*
* 不还原原 needReceipt群回执是发送时的扩展选项不会持久化到 message强行猜测可能与原意不符
* 默认按"无回执"重发绝大多数场景符合预期要回执就重新发一次更直观
*/
async function handleResend() {
if (props.message.status !== ImMessageStatus.FAILED) {
return
}
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
conversationStore.removeMessage(conversation.type, conversation.targetId, {
id: props.message.id,
clientMessageId: props.message.clientMessageId
})
await sendRaw(props.message.type, props.message.content, {
atUserIds: props.message.atUserIds
})
}
// TODO @AI
function handleDelete() {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
conversationStore.removeMessage(conversation.type, conversation.targetId, {
id: props.message.id,
clientMessageId: props.message.clientMessageId
})
}
</script>
<style scoped>
/* el-icon color .el-icon{color:var(--color)}
这里把 file / voice 图标的 fill 锁死避免字体色跟随主题变白 */
.message-bubble__file-icon :deep(svg) {
fill: #409eff !important;
}
.message-bubble__voice-icon :deep(svg) {
fill: #606266 !important;
}
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
fill: #409eff !important;
}
/* 播放中的脉冲动画keyframes 用 UnoCSS 不好写,保留 scoped */
.im-voice-playing {
animation: im-voice-icon-pulse 0.8s infinite;
}
@keyframes im-voice-icon-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
</style>