diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts index bbff4fc9b..c9bd879ca 100644 --- a/src/views/im/home/composables/useMediaUploader.ts +++ b/src/views/im/home/composables/useMediaUploader.ts @@ -7,6 +7,7 @@ import { useMuteOverlay } from './useMuteOverlay' import { ImMessageStatus, ImMessageType } from '../../utils/constants' import { getConversationKey } from '../../utils/conversation' import { + BLOB_URL_PREFIX, generateClientMessageId, parseMessage, serializeMessage, @@ -27,7 +28,7 @@ export type MediaPayload = ImageMessage | FileMessage | AudioMessage | VideoMess * * - voiceDuration:语音时长(秒),首发由 VoiceRecorder 给,重传从旧 AudioMessage.duration 取 * - videoProbe:视频元信息(首发由 probeVideoFile 解出,重传从旧 VideoMessage 直接拷字段) - * - videoCoverUrl:视频封面真实 URL;占位阶段用 blob,commit 用真实 URL,重传时旧值若是 blob 会被跳过 + * - videoCoverUrl:视频封面真实 URL;占位阶段不设(避免传 blob 当 poster 在部分浏览器退化),commit 阶段由 cover 上传结果填入;重传时从旧 VideoMessage.coverUrl 复用,旧值若是 blob 会被跳过 */ export interface MediaTypeContext { voiceDuration?: number @@ -79,7 +80,7 @@ export const mediaTypeHandlers: Partial> = { extractResendContext: (oldContent) => { const old = parseMessage(oldContent) // 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传 - const reuseCover = old?.coverUrl && !old.coverUrl.startsWith('blob:') ? old.coverUrl : undefined + const reuseCover = old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined return { videoProbe: { duration: old?.duration, width: old?.width, height: old?.height }, videoCoverUrl: reuseCover diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 554a960da..8b8ab18bd 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -507,7 +507,7 @@ const uploadProgressText = computed(() => `${uploadProgress.value}%`) /** * 是否在气泡尾部显示「发送中」loading 转圈 * - * 图片 / 视频 / 文件气泡内嵌已有进度反馈(遮罩 / 进度条),外层 loading 多余则抑制; + * 图片 / 视频 / 文件气泡内嵌已有进度反馈(遮罩 / 进度条),外层 loading 不再叠加; * 语音气泡只有麦克风 + 时长,无内嵌进度,必须保留外层 loading 让用户感知正在发送 */ const showSendingLoading = computed( diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index dda321dfc..8781a751a 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -67,8 +67,8 @@ function deriveLastSenderDisplayName( /** * 按 conversation.messages 末尾重算 last* 系列摘要 / 事实索引 * - * 用于:删除最后一条消息 / loadConversations drop 媒体占位后;剩余消息为空则字段一并清空。 - * 不重算 lastSendTime 兜底(保留原 conversation 现值),与 removeMessage 旧行为一致 + * 用于:删除最后一条消息 / loadConversations drop 媒体占位后;剩余消息为空则字段一并清空(含 lastSendTime=0),让空会话排到列表末尾。 + * 末条消息存在时,lastSendTime 取该消息的 sendTime;缺失时沿用 conversation 现值 */ function recomputeConversationLast(conversation: Conversation): void { const last = conversation.messages[conversation.messages.length - 1] @@ -627,11 +627,7 @@ export const useConversationStore = defineStore('imConversationStore', { if (!changed) { return } - // 替换 content 时 revoke 旧 blob URL,与 ackMessage 同语义 - if (patch.content && patch.content !== message.content) { - revokeBlobUrlsInContent(message.content) - } - Object.assign(message, patch) + applyServerMessageUpdate(message, patch) }, /** diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index d1a13d735..bc8e69100 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -105,12 +105,15 @@ export const parseMessage = (content: string): T | null => { /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ export const serializeMessage = (payload: T): string => JSON.stringify(payload) +/** `URL.createObjectURL(file)` 生成的 URL 前缀;占位 / revoke / 重传旧值识别共用 */ +export const BLOB_URL_PREFIX = 'blob:' + /** * 媒体 payload 里可能包含 blob URL 的字段(图片/文件/视频/语音都对齐这套 url 字段命名) * * 跟随 ImageMessage / VideoMessage / FileMessage / AudioMessage interface 定义同步: * - url:主体资源(占位时是 blob URL,ack 后是真实 URL) - * - coverUrl:视频封面(占位时跟 url 同 blob,cover 上传成功后是真实 URL) + * - coverUrl:视频封面(commit 后是真实 URL;占位阶段不设以避免传 blob 当 poster 在部分浏览器退化) * - thumbnailUrl:图片缩略图(当前未占位时使用 blob,预留) */ const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const @@ -122,7 +125,7 @@ const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const * 仅对当前 document 内创建的 blob URL 有效;IndexedDB 恢复出来的旧 blob URL 已随旧 document 失效,调它无害但无意义 */ export const revokeBlobUrlsInContent = (content: string): void => { - if (!content || !content.includes('blob:')) { + if (!content || !content.includes(BLOB_URL_PREFIX)) { return } const payload = parseMessage>(content) @@ -131,7 +134,7 @@ export const revokeBlobUrlsInContent = (content: string): void => { } for (const field of MEDIA_BLOB_URL_FIELDS) { const value = payload[field] - if (typeof value === 'string' && value.startsWith('blob:')) { + if (typeof value === 'string' && value.startsWith(BLOB_URL_PREFIX)) { URL.revokeObjectURL(value) } }