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

View File

@ -1,10 +1,10 @@
<template> <template>
<!-- <!--
引用消息预览块,对齐微信 PC:浅灰底块 + padding + 文本可换行(line-clamp 2 ) 引用消息预览块对齐微信 PC浅灰底块 + padding + 文本可换行line-clamp 2
- clickable=true(气泡内): 点击触发 locate emit;撤回态禁用跳转 - clickable=true气泡内点击触发 locate emit撤回态禁用跳转
- closable=true(输入条): 显示右上 × 圆形按钮,hover 时显示圆形底 - closable=true输入条显示右上 × 圆形按钮hover 时显示圆形底
- 撤回降级:命中本地缓存且 type === RECALL 时显示原消息已撤回斜体灰字 - 撤回降级命中本地缓存且 type === RECALL 时显示原消息已撤回斜体灰字
- 富预览:type IMAGE / VIDEO 时直接从 quote.content 取缩略图,不依赖本地缓存 - 富预览type IMAGE / VIDEO 时直接从 quote.content 取缩略图不依赖本地缓存
--> -->
<div <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="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) 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(() => { const snippetText = computed(() => {
if (isRecalled.value) { if (isRecalled.value) {
return '原消息已撤回' return '原消息已撤回'
} }
const { type, content } = props.quote const { type } = props.quote
if (type === ImMessageType.TEXT) { 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)}` return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}`
} }
if (type === ImMessageType.IMAGE) { if (type === ImMessageType.IMAGE) {
return '[图片]' return '[图片]'
} }
if (type === ImMessageType.FILE) { if (type === ImMessageType.FILE) {
const name = parseMessage<FileMessage>(content)?.name const name = parsedPayload.value?.name
return name ? `[文件 ${name}]` : '[文件]' return name ? `[文件 ${name}]` : '[文件]'
} }
if (type === ImMessageType.VOICE) { if (type === ImMessageType.VOICE) {
const duration = parseMessage<AudioMessage>(content)?.duration const duration = parsedPayload.value?.duration
return duration ? `[语音 ${duration}″]` : '[语音]' return duration ? `[语音 ${duration}″]` : '[语音]'
} }
if (type === ImMessageType.VIDEO) { if (type === ImMessageType.VIDEO) {
@ -128,18 +132,17 @@ const snippetText = computed(() => {
return '' return ''
}) })
/** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */ /** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => { const thumbnailUrl = computed<string | undefined>(() => {
if (isRecalled.value) { if (isRecalled.value) {
return undefined return undefined
} }
const { type, content } = props.quote const { type } = props.quote
if (type === ImMessageType.IMAGE) { if (type === ImMessageType.IMAGE) {
const payload = parseMessage<ImageMessage>(content) return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url
return payload?.thumbnailUrl || payload?.url
} }
if (type === ImMessageType.VIDEO) { if (type === ImMessageType.VIDEO) {
return parseMessage<VideoMessage>(content)?.coverUrl return parsedPayload.value?.coverUrl
} }
return undefined return undefined
}) })