✨ feat(im): 优化【消息引用】的功能,增加文件、图片的展示调整
parent
ef901b5381
commit
43666dc56c
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,38 +1,78 @@
|
|||
<template>
|
||||
<!--
|
||||
引用消息预览块,对齐微信 PC:浅灰底块 + 大 padding + 文本可换行(line-clamp 2 行)
|
||||
引用预览块(对齐微信 PC):2px 竖线作为「引用」视觉标识 + 紧凑内容预览
|
||||
- clickable=true(气泡内):点击触发 locate emit;撤回态禁用跳转
|
||||
- closable=true(输入条):显示右上 × 圆形按钮,hover 时显示圆形底
|
||||
- 撤回降级:命中本地缓存且 type === RECALL 时显示「原消息已撤回」斜体灰字
|
||||
- 富预览:type 为 IMAGE / VIDEO 时直接从 quote.content 取缩略图,不依赖本地缓存
|
||||
- closable=true(输入条):尾部 × 关闭按钮
|
||||
- mirrored=true(自己发送的气泡):竖线镜像到右侧,与气泡同侧
|
||||
- 内容预览(与主气泡同源,不缩写成「[文件]/[语音]」):
|
||||
- 文本:截断后纯文字
|
||||
- 图片 / 视频:缩略图(IMAGE 用 thumbnailUrl/url,VIDEO 用 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;
|
||||
|
|
|
|||
|
|
@ -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 缩进 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue