feat(im): 优化【消息引用】的功能,增加文件、图片的展示调整

im
YunaiV 2026-05-01 19:05:57 +08:00
parent ef901b5381
commit 43666dc56c
3 changed files with 151 additions and 97 deletions

View File

@ -50,14 +50,6 @@
>
{{ senderDisplayName }}
</div>
<!-- 引用块:气泡正上方,与气泡同侧;点击触发 MessagePanel 滚定位 -->
<ReplyPreview
v-if="quote"
:quote="quote"
clickable
class="max-w-[280px]"
@locate="emit('locate', $event)"
/>
<div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
<!-- 消息内容 type v-if 分支 -->
<!-- 文本消息 -->
@ -205,6 +197,15 @@
</el-tag>
</div>
</div>
<!-- 引用块气泡下方与气泡同侧selfSend 时竖线镜像到右侧 -->
<ReplyPreview
v-if="quote"
:quote="quote"
clickable
:mirrored="message.selfSend"
class="max-w-[280px]"
@locate="emit('locate', $event)"
/>
</div>
</div>
</template>
@ -225,6 +226,7 @@ import {
getQuoteFromMessage,
parseMessage,
resolveTipText,
getFileIconInfo,
type TextMessage,
type ImageMessage,
type FileMessage,
@ -363,44 +365,8 @@ const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.message.content) : null
)
/**
* 文件类型图标 + 配色按扩展名分发
*
* 对齐微信观感PDF / Word / Excel 绿 / PPT / 压缩包 / 媒体 / 文本
* 其它走通用 file-filled后续多了类型在这里加 case不动模板
*/
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' }
})
/** 文件类型图标 + 配色:按扩展名分发,跟 ReplyPreview 共用 getFileIconInfo */
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
/** 文件点击 → 新窗口下载 */
function handleFileClick() {

View File

@ -1,38 +1,78 @@
<template>
<!--
引用消息预览块对齐微信 PC浅灰底块 + padding + 文本可换行line-clamp 2
引用预览块对齐微信 PC2px 竖线作为引用视觉标识 + 紧凑内容预览
- clickable=true气泡内点击触发 locate emit撤回态禁用跳转
- closable=true输入条显示右上 × 圆形按钮hover 时显示圆形底
- 撤回降级命中本地缓存且 type === RECALL 时显示原消息已撤回斜体灰字
- 富预览type IMAGE / VIDEO 时直接从 quote.content 取缩略图不依赖本地缓存
- closable=true输入条尾部 × 关闭按钮
- mirrored=true自己发送的气泡竖线镜像到右侧与气泡同侧
- 内容预览与主气泡同源不缩写成[文件]/[语音]
- 文本截断后纯文字
- 图片 / 视频缩略图IMAGE thumbnailUrl/urlVIDEO coverUrl
- 文件file icon + 文件名 + 大小
- 语音audio icon + 时长
- 撤回命中本地缓存且 type === RECALL原消息已撤回斜体灰字
-->
<div
class="im-reply-preview flex gap-2 items-start min-w-0 px-3 py-2 rounded text-13px bg-[var(--el-fill-color-light)]"
:class="{
'cursor-pointer hover:bg-[var(--el-fill-color)]': clickable && !isRecalled
}"
class="im-reply-preview flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--el-text-color-secondary)] rounded transition-colors"
:class="[
mirrored ? 'im-reply-preview--end pl-1 pr-2' : 'pl-2 pr-1',
{
'cursor-pointer hover:text-[var(--el-text-color-primary)]': clickable && !isRecalled,
'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable
}
]"
@click="onClick"
>
<span class="flex-shrink-0">{{ senderName }}:</span>
<!-- 撤回降级 -->
<span v-if="isRecalled" class="italic"></span>
<!-- 文本 -->
<span v-else-if="isText" class="im-reply-preview__text min-w-0">{{ textPreview }}</span>
<!-- 文件icon + 文件名 + 大小 -->
<template v-else-if="isFile">
<Icon
:icon="fileIcon.icon"
:color="fileIcon.color"
:size="14"
class="flex-shrink-0"
/>
<span v-if="filePayload?.name" class="im-reply-preview__text min-w-0">
{{ filePayload.name }}
</span>
<span
v-if="filePayload?.size"
class="flex-shrink-0 text-[var(--el-text-color-placeholder)]"
>
{{ formatFileSize(filePayload.size) }}
</span>
</template>
<!-- 语音audio icon + 时长 -->
<template v-else-if="isVoice">
<Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" />
<span v-if="voicePayload?.duration" class="flex-shrink-0">
{{ formatSeconds(voicePayload.duration) }}
</span>
</template>
<!-- 图片 / 视频缩略图 -->
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
class="flex-shrink-0 object-cover w-8 h-8 rounded"
class="flex-shrink-0 object-cover w-6 h-6 rounded"
alt=""
/>
<div
class="im-reply-preview__text flex-1 min-w-0 leading-relaxed text-[var(--el-text-color-secondary)]"
:class="{ italic: isRecalled }"
>
<span>{{ senderName }}:</span>
<span class="ml-1">{{ snippetText }}</span>
</div>
<!-- 关闭按钮 -->
<button
v-if="closable"
type="button"
class="im-reply-preview__close flex-shrink-0 inline-flex items-center justify-center w-5 h-5 mt-0.5 cursor-pointer rounded-full bg-transparent border-none text-[var(--el-text-color-secondary)] transition-colors hover:bg-[var(--el-fill-color-darker)] hover:text-[var(--el-text-color-primary)]"
class="im-reply-preview__close flex-shrink-0 inline-flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-transparent border-none text-[var(--el-text-color-secondary)] hover:bg-[var(--el-fill-color)] hover:text-[var(--el-text-color-primary)]"
@click.stop="emit('close')"
>
<Icon icon="ant-design:close-outlined" :size="12" />
<Icon icon="ant-design:close-outlined" :size="10" />
</button>
</div>
</template>
@ -40,23 +80,26 @@
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useConversationStore } from '../../../../store/conversationStore'
import { getSenderDisplayName } from '../../../../../utils/user'
import { ImMessageType } from '../../../../../utils/constants'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { ImMessageType } from '@/views/im/utils/constants'
import {
parseMessage,
getFileIconInfo,
type AudioMessage,
type FileMessage,
type ImageMessage,
type TextMessage,
type VideoMessage,
type QuoteMessage
} from '../../../../../utils/message'
} from '@/views/im/utils/message'
defineOptions({ name: 'ImReplyPreview' })
/** 文本摘要在引用块里展示的最大字符数;后端 quote.content 已截断到 1000,这里再压一次给单行预览 */
/** 文本摘要在引用块里展示的最大字符数 */
const MAX_TEXT_PREVIEW_LEN = 60
const props = withDefaults(
@ -66,10 +109,13 @@ const props = withDefaults(
clickable?: boolean
/** 输入条为 true 显示 × 关闭按钮 */
closable?: boolean
/** 自己发送的气泡为 true把竖线镜像到右侧与气泡同侧 */
mirrored?: boolean
}>(),
{
clickable: false,
closable: false
closable: false,
mirrored: false
}
)
@ -101,37 +147,31 @@ const senderName = computed(() => {
return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId)
})
/** quote.content 解析一次缓存,让 snippetText / thumbnailUrl 复用,长会话每条引用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial<TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage>
/** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial<
TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage
>
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
/** 摘要文案:已撤回降级,否则按 type 从 quote.content 派生(文本截断 / 非文本走类型 tag */
const snippetText = computed(() => {
if (isRecalled.value) {
return '原消息已撤回'
}
const { type } = props.quote
if (type === ImMessageType.TEXT) {
const text = parsedPayload.value?.content ?? ''
return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}`
}
if (type === ImMessageType.IMAGE) {
return '[图片]'
}
if (type === ImMessageType.FILE) {
const name = parsedPayload.value?.name
return name ? `[文件 ${name}]` : '[文件]'
}
if (type === ImMessageType.VOICE) {
const duration = parsedPayload.value?.duration
return duration ? `[语音 ${duration}″]` : '[语音]'
}
if (type === ImMessageType.VIDEO) {
return '[视频]'
}
return ''
const isText = computed(() => props.quote.type === ImMessageType.TEXT)
const isFile = computed(() => props.quote.type === ImMessageType.FILE)
const isVoice = computed(() => props.quote.type === ImMessageType.VOICE)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => {
const text = parsedPayload.value?.content ?? ''
return text.length <= MAX_TEXT_PREVIEW_LEN
? text
: `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}`
})
/** 文件 / 语音 payload 直接复用 parsedPayload省一次解析 */
const filePayload = computed(() => parsedPayload.value)
const voicePayload = computed(() => parsedPayload.value)
/** 文件 icon按扩展名挑色跟主气泡渲染同源 */
const fileIcon = computed(() => getFileIconInfo(filePayload.value?.name))
/** 缩略图 URL仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => {
if (isRecalled.value) {
@ -157,7 +197,16 @@ function onClick() {
</script>
<style scoped>
/* 文字超过 2 行截断,避免长引用把输入条 / 气泡撑高;UnoCSS 的 line-clamp 工具类在本项目未启用,走 scoped CSS */
/* 默认左侧 2px 竖线作为「引用」视觉标识mirrored 时镜像到右侧 */
.im-reply-preview {
border-left: 2px solid var(--el-border-color);
}
.im-reply-preview--end {
border-left: 0;
border-right: 2px solid var(--el-border-color);
}
/* 文字超过 2 行截断,避免长引用把输入条 / 气泡撑高UnoCSS 的 line-clamp 工具类在本项目未启用,走 scoped CSS */
.im-reply-preview__text {
display: -webkit-box;
-webkit-line-clamp: 2;

View File

@ -216,6 +216,45 @@ export const playAudioTip = () => {
}
}
// ==================== 文件图标 ====================
/**
* +
*
* MessageItem.vue ReplyPreview.vue
*/
export function getFileIconInfo(filename: string | undefined): { icon: string; color: string } {
const ext = (filename || '').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' }
}
// ==================== 管理后台展示工具 ====================
/** 详情弹窗里把 content JSON 美化成 2 缩进 */