feat(im): 优化【消息引用】的功能,来自第一波 code review

im
YunaiV 2026-05-01 18:09:02 +08:00
parent 1dfab43b8a
commit cfeee7bbb7
3 changed files with 32 additions and 24 deletions

View File

@ -546,6 +546,15 @@ function clearReply() {
draftStore.clearReply(conversation)
}
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
function consumeReply(): QuoteMessage | undefined {
const quote = replyTarget.value
if (quote) {
clearReply()
}
return quote
}
// ==================== ====================
const emojiVisible = ref(false)
/** 切换表情面板;打开时互斥关掉语音面板 */
@ -767,8 +776,7 @@ function onKeydown(e: KeyboardEvent) {
// ==================== / ====================
/** 上传并发送 IMAGE 消息;quote 抓取后立即清 draft.reply 让顶部引用条同步消失 */
async function uploadAndSendImage(file: File) {
const replyQuote = replyTarget.value
clearReply()
const replyQuote = consumeReply()
const form = new FormData()
form.append('file', file)
const url = ((await updateFile(form)) as { data?: string })?.data
@ -781,8 +789,7 @@ async function uploadAndSendImage(file: File) {
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
async function uploadAndSendFile(file: File) {
const replyQuote = replyTarget.value
clearReply()
const replyQuote = consumeReply()
const form = new FormData()
form.append('file', file)
const url = ((await updateFile(form)) as { data?: string })?.data
@ -825,8 +832,7 @@ function openVoice() {
}
/** VoiceRecorder 录完后回传 blob包成 webm 文件上传,发送 VOICE 消息 */
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
const replyQuote = replyTarget.value
clearReply()
const replyQuote = consumeReply()
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
const form = new FormData()
form.append('file', file)
@ -955,8 +961,7 @@ async function uploadAndSendVideo(file: File) {
}
const startKey = getConversationKey(startConversation)
// 1.2 quote draft.reply / /
const replyQuote = replyTarget.value
clearReply()
const replyQuote = consumeReply()
// 2. probe probe cover
// 2.1 catch url=undefined step 3.2 url promise floating

View File

@ -535,7 +535,7 @@ const isAtMe = computed(() => {
/**
* 右键菜单项
* - 回复仅已落库(id0)且未撤回的消息可引用,引用块写入 draftStore.reply
* - 回复仅已落库id0且未撤回的消息可引用引用块写入 draftStore.reply
* - 删除从本地消息列表移除不动后端
* - 撤回仅自己发送已送达 id的消息
*

View File

@ -1,10 +1,10 @@
<template>
<!--
引用消息预览块,对齐微信 PC:浅灰底块 + padding + 文本可换行(line-clamp 2 )
- clickable=true(气泡内): 点击触发 locate emit;撤回态禁用跳转
- closable=true(输入条): 显示右上 × 圆形按钮,hover 时显示圆形底
- 撤回降级:命中本地缓存且 type === RECALL 时显示原消息已撤回斜体灰字
- 富预览:type IMAGE / VIDEO 时直接从 quote.content 取缩略图,不依赖本地缓存
引用消息预览块对齐微信 PC浅灰底块 + padding + 文本可换行line-clamp 2
- clickable=true气泡内点击触发 locate emit撤回态禁用跳转
- closable=true输入条显示右上 × 圆形按钮hover 时显示圆形底
- 撤回降级命中本地缓存且 type === RECALL 时显示原消息已撤回斜体灰字
- 富预览type IMAGE / VIDEO 时直接从 quote.content 取缩略图不依赖本地缓存
-->
<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)]"
@ -101,25 +101,29 @@ const senderName = computed(() => {
return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId)
})
/** 摘要文案:已撤回降级,否则按 type 从 quote.content 派生(文本截断 / 非文本走类型 tag) */
/** quote.content 解析一次缓存,让 snippetText / thumbnailUrl 复用,长会话每条引用气泡少一次 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, content } = props.quote
const { type } = props.quote
if (type === ImMessageType.TEXT) {
const text = parseMessage<TextMessage>(content)?.content ?? ''
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 = parseMessage<FileMessage>(content)?.name
const name = parsedPayload.value?.name
return name ? `[文件 ${name}]` : '[文件]'
}
if (type === ImMessageType.VOICE) {
const duration = parseMessage<AudioMessage>(content)?.duration
const duration = parsedPayload.value?.duration
return duration ? `[语音 ${duration}″]` : '[语音]'
}
if (type === ImMessageType.VIDEO) {
@ -128,18 +132,17 @@ const snippetText = computed(() => {
return ''
})
/** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
/** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => {
if (isRecalled.value) {
return undefined
}
const { type, content } = props.quote
const { type } = props.quote
if (type === ImMessageType.IMAGE) {
const payload = parseMessage<ImageMessage>(content)
return payload?.thumbnailUrl || payload?.url
return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url
}
if (type === ImMessageType.VIDEO) {
return parseMessage<VideoMessage>(content)?.coverUrl
return parsedPayload.value?.coverUrl
}
return undefined
})