admin-vue3/src/views/im/manager/message/MessageContentPreview.vue

233 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<!-- 文本直接显示纯文本 -->
<span v-if="isText" class="whitespace-pre-wrap break-all">{{ textContent }}</span>
<!-- 图片:缩略图 + 点击放大 -->
<el-image
v-else-if="isImage && imagePayload"
class="w-60px h-60px rounded align-middle"
:src="imagePayload.thumbnailUrl || imagePayload.url"
:preview-src-list="[imagePayload.url]"
:preview-teleported="true"
fit="cover"
/>
<!-- 文件:图标 + 名称 + 大小,单行内显示 -->
<span v-else-if="isFile && filePayload" class="inline-flex gap-1.5 items-center">
<Icon :icon="fileIconInfo.icon" :color="fileIconInfo.color" :size="18" />
<span class="max-w-200px truncate">{{ filePayload.name }}</span>
<span v-if="filePayload.size" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(filePayload.size) }}
</span>
</span>
<!-- 语音:图标 + 时长 -->
<span v-else-if="isVoice && voicePayload" class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:audio-outlined" :size="16" color="#606266" />
<span>{{ formatSeconds(voicePayload.duration ?? 0) }}</span>
</span>
<!-- 视频:封面缩略图 + 时长 + 大小;封面缺失时降级图标占位 -->
<span v-else-if="isVideo && videoPayload" class="inline-flex gap-1.5 items-center">
<span
v-if="videoPayload.coverUrl"
class="relative inline-block w-60px h-60px rounded overflow-hidden align-middle cursor-pointer"
:title="videoPayload.url ? '点击新标签播放' : ''"
@click="openVideo"
>
<img :src="videoPayload.coverUrl" class="w-full h-full object-cover" />
<Icon
icon="ant-design:play-circle-filled"
:size="22"
color="#fff"
class="absolute inset-0 m-auto pointer-events-none"
style="filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6))"
/>
</span>
<span v-else class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:video-camera-filled" :size="16" color="#9c27b0" />
<span>[视频]</span>
</span>
<span v-if="videoPayload.duration" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatSeconds(videoPayload.duration) }}
</span>
<span v-if="videoPayload.size" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(videoPayload.size) }}
</span>
</span>
<!-- 控制类消息:撤回 / 已读 / 回执 -->
<span
v-else-if="props.type === ImMessageType.RECALL"
class="text-12px text-[var(--el-text-color-secondary)]"
>
[消息已撤回]
</span>
<span
v-else-if="props.type === ImMessageType.READ"
class="text-12px text-[var(--el-text-color-secondary)]"
>
[已读回执]
</span>
<span
v-else-if="props.type === ImMessageType.RECEIPT"
class="text-12px text-[var(--el-text-color-secondary)]"
>
[回执]
</span>
<!-- 群广播事件:拼装中文 tip 文案operator 用 senderNicknamemember / newOwner 退化为 用户(id) -->
<span
v-else-if="isGroupNotificationType"
class="text-12px text-[var(--el-text-color-secondary)]"
>
{{ groupNotificationText }}
</span>
<!-- 好友会话事件FRIEND_ADD / FRIEND_DELETE固定中文文案 -->
<span
v-else-if="isFriendChatTipType"
class="text-12px text-[var(--el-text-color-secondary)]"
>
{{ friendChatTipText }}
</span>
<!-- 其它系统事件 / 未知类型content 通常是结构化 JSON回退原始预览 -->
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime'
import {
ImMessageType,
isFriendChatTip,
isGroupNotification
} from '@/views/im/utils/constants'
import {
parseMessage,
type ImageMessage,
type FileMessage,
type AudioMessage,
type VideoMessage,
type TextMessage
} from '@/views/im/utils/message'
import {
resolveFriendNotificationText,
resolveGroupNotificationText
} from '@/views/im/utils/user'
defineOptions({ name: 'ImMessageContentPreview' })
const props = defineProps<{
/** 消息类型,对应 ImMessageType */
type?: number
/** 消息 contentJSON 字符串或裸文本) */
content?: string
/** 发送人昵称:群广播事件用作 operatorName 兜底渲染 */
senderNickname?: string
}>()
/** 各类型判定 */
const isText = computed(() => props.type === ImMessageType.TEXT)
const isImage = computed(() => props.type === ImMessageType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO)
/** 文本内容:从 TextMessage payload 取 .content */
const textContent = computed(
() => parseMessage<TextMessage>(props.content || '')?.content ?? ''
)
const imagePayload = computed(() =>
isImage.value ? parseMessage<ImageMessage>(props.content || '') : null
)
const filePayload = computed(() =>
isFile.value ? parseMessage<FileMessage>(props.content || '') : null
)
const voicePayload = computed(() =>
isVoice.value ? parseMessage<AudioMessage>(props.content || '') : null
)
const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.content || '') : null
)
/** 点击视频封面:在新标签打开视频 url不在管理后台内嵌播放避免列表里多个 video 同时占资源) */
function openVideo() {
const url = videoPayload.value?.url
if (url) {
window.open(url, '_blank')
}
}
/** 文件图标:按扩展名分配 icon + 颜色,对齐 home 端 MessageItem 的观感 */
const fileIconInfo = computed<{ icon: string; color: string }>(() => {
const name = filePayload.value?.name || ''
const ext = name.split('.').pop()?.toLowerCase() || ''
if (ext === 'pdf') {
return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
}
if (['doc', 'docx'].includes(ext)) {
return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
}
if (['xls', 'xlsx'].includes(ext)) {
return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
}
if (['ppt', 'pptx'].includes(ext)) {
return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
}
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
}
if (['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext)) {
return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
}
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) {
return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
}
if (['txt', 'md', 'log', 'json', 'xml'].includes(ext)) {
return { icon: 'ant-design:file-text-filled', color: '#909399' }
}
return { icon: 'ant-design:file-filled', color: '#909399' }
})
/** 系统事件 / 未知类型 fallback取 JSON 首层 content否则原文 */
const fallbackText = computed(() => {
const raw = props.content || ''
if (!raw) {
return ''
}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && parsed.content) {
return String(parsed.content)
}
} catch {}
return raw
})
/** 是否好友会话事件气泡FRIEND_ADD / FRIEND_DELETE */
const isFriendChatTipType = computed(() => isFriendChatTip(props.type ?? -1))
/** 好友会话事件文案:固定文案,不依赖 payload */
const friendChatTipText = computed(() => resolveFriendNotificationText({ type: props.type }))
/** 是否群广播事件 */
const isGroupNotificationType = computed(() => isGroupNotification(props.type ?? -1))
/** 群广播事件文案:复用 utils/user.resolveGroupNotificationTextadmin 端 operator 用 senderNickname 直接覆盖,其它 id 退化为 用户(id) */
const groupNotificationText = computed(() =>
resolveGroupNotificationText(
{ type: props.type, content: props.content },
(id) => `用户(${id})`,
props.senderNickname
)
)
</script>