diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts new file mode 100644 index 000000000..5407b50b4 --- /dev/null +++ b/src/views/im/home/composables/useMediaUploader.ts @@ -0,0 +1,197 @@ +import { updateFile } from '@/api/infra/file' +import { useUserStore } from '@/store/modules/user' + +import { useConversationStore } from '../store/conversationStore' +import { useMessageSender } from './useMessageSender' +import { useMuteOverlay } from './useMuteOverlay' +import { ImMessageStatus } from '../../utils/constants' +import { getConversationKey } from '../../utils/conversation' +import { + generateClientMessageId, + serializeMessage, + withQuotePayload, + type QuoteMessage +} from '../../utils/message' +import type { Conversation, Message } from '../types' + +/** 单次媒体上传的入参(image / file / voice 共用;video 走低层 helper 自行组装) */ +export interface UploadAndSendMediaOptions

{ + file: File + type: number // 对齐 ImMessageType + kind: string // 文案:「图片」/「文件」/「语音」,仅日志用 + /** 由 url 生成消息 payload;占位阶段传 blob URL,上传成功后再用真实 url 重生成 */ + buildPayload: (url: string) => P + /** 引用消息(若有),写进 payload.quote */ + quote?: QuoteMessage + /** 锁定起始会话,上传期间会话切走则放弃发送 */ + conversation: Conversation +} + +/** + * 媒体上传 + 发送 composable(image / file / voice / video 共用底层 helper) + * + * 与 useMessageSender.sendRaw 的「先发请求再 ack」不同,媒体链路必须「先占位再上传」: + * 1. 立即 insertMessage 写入占位消息(status=SENDING、content 用 blob URL、_localFile 内存留 File) + * 2. updateFile 上传,onUploadProgress 回调 patchMessage 更新 uploadProgress;UI 实时显示进度条 + * 3. 上传成功后用真实 url 重生 content,patchMessage 替换;旧 blob URL 由 store 自动 revoke + * 4. 走 sendRaw(existingClientMessageId) 复用占位发送请求,避免重复插入两条 + * + * 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_localFile 还在内存就行) + */ +export const useMediaUploader = () => { + const conversationStore = useConversationStore() + const userStore = useUserStore() + const muteOverlay = useMuteOverlay() + const { sendRaw } = useMessageSender() + + /** + * 立即写入媒体占位消息(低层 helper;image/file/voice 走 uploadAndSendMedia 包装,video 直接用本函数) + * + * 用 createObjectURL(file) 生成临时 blob URL 喂给 buildContent;占位 status=SENDING + uploadProgress=0; + * file 挂在 _localFile 上供失败重试时重走上传 + */ + const insertMediaPlaceholder = (opts: { + file: File + type: number + conversation: Conversation + buildContent: (blobUrl: string) => string + }): { clientMessageId: string; blobUrl: string } => { + const { conversation } = opts + const blobUrl = URL.createObjectURL(opts.file) + const clientMessageId = generateClientMessageId() + const placeholder: Message = { + id: 0, + clientMessageId, + type: opts.type, + content: opts.buildContent(blobUrl), + status: ImMessageStatus.SENDING, + sendTime: Date.now(), + senderId: Number(userStore.getUser?.id) || 0, + targetId: conversation.targetId, + selfSend: true, + uploadProgress: 0, + _localFile: opts.file + } + conversationStore.insertMessage( + { + type: conversation.type, + targetId: conversation.targetId, + name: conversation.name || String(conversation.targetId), + avatar: conversation.avatar || '' + }, + placeholder + ) + return { clientMessageId, blobUrl } + } + + /** + * 把占位消息置为 FAILED(上传失败 / 会话切走 / 禁言期到点 等场景统一收尾) + * + * 同时清掉 uploadProgress —— 失败后 MessageItem 的 isUploading 不再命中,进度遮罩 / 文件点击禁用 / 语音 loading 抑制 都同步解除; + * _localFile 保留,让用户点重试可以走 uploadAndSendMedia 重传 + */ + const markMediaFailed = ( + conversationType: number, + targetId: number, + clientMessageId: string + ): void => { + conversationStore.patchMessage(conversationType, targetId, clientMessageId, { + status: ImMessageStatus.FAILED, + uploadProgress: undefined + }) + } + + /** + * 占位完成后用真实 url 替换 content,再走 sendRaw 完成发送 + * + * 上传成功 → patch content → sendRaw 复用 existingClientMessageId;store 内部 revoke 旧 blob URL + */ + const commitMediaPlaceholder = async (opts: { + type: number + conversation: Conversation + clientMessageId: string + realContent: string + }): Promise => { + conversationStore.patchMessage( + opts.conversation.type, + opts.conversation.targetId, + opts.clientMessageId, + { content: opts.realContent } + ) + await sendRaw(opts.type, opts.realContent, { + existingClientMessageId: opts.clientMessageId, + targetId: opts.conversation.targetId + }) + } + + /** + * 上传媒体文件并发送消息(高层入口;image / file / voice 用) + * + * @returns 占位消息的 clientMessageId(调用方按需用于后续 patch / 移除;上传失败时占位仍保留为 FAILED 态) + */ + const uploadAndSendMedia = async

( + opts: UploadAndSendMediaOptions

+ ): Promise => { + const { conversation } = opts + const startKey = getConversationKey(conversation) + + // 1. 立即占位 + const { clientMessageId } = insertMediaPlaceholder({ + file: opts.file, + type: opts.type, + conversation, + buildContent: (blobUrl) => + serializeMessage(withQuotePayload

(opts.buildPayload(blobUrl), opts.quote)) + }) + + // 2. 上传:进度回调 patch uploadProgress;失败保留 _localFile 供重试 + let url: string | undefined + try { + const form = new FormData() + form.append('file', opts.file) + const res = (await updateFile(form, (event: ProgressEvent) => { + if (!event.total) { + return + } + const percent = Math.round((event.loaded / event.total) * 100) + conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { + uploadProgress: percent + }) + })) as { data?: string } + url = res?.data + } catch (e) { + console.error(`[IM] ${opts.kind}上传失败`, e) + } + if (!url) { + markMediaFailed(conversation.type, conversation.targetId, clientMessageId) + return clientMessageId + } + + // 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED + const activeConversation = conversationStore.activeConversation + if (!activeConversation || getConversationKey(activeConversation) !== startKey) { + console.warn(`[IM] ${opts.kind}上传期间切换了会话,放弃发送`, { startKey }) + markMediaFailed(conversation.type, conversation.targetId, clientMessageId) + return clientMessageId + } + if (muteOverlay.value) { + console.warn(`[IM] ${opts.kind}上传期间被禁言,放弃发送`, { startKey }) + markMediaFailed(conversation.type, conversation.targetId, clientMessageId) + return clientMessageId + } + + // 4. patch content + sendRaw 收尾 + const realContent = serializeMessage( + withQuotePayload

(opts.buildPayload(url), opts.quote) + ) + await commitMediaPlaceholder({ + type: opts.type, + conversation, + clientMessageId, + realContent + }) + return clientMessageId + } + + return { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder } +} diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 7b6a6b5f1..2b0981682 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -28,6 +28,13 @@ interface SendExtOptions { targetId?: number // 覆盖默认的 targetId /** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */ quote?: QuoteMessage + /** + * 复用已存在的本地占位消息 clientMessageId(媒体上传场景) + * + * 媒体上传链路在请求服务端前已经 insertMessage 了占位(带 blob URL + 进度条), + * 这里跳过 buildLocalMessage / insertMessage,直接拿这个 id 走 ackMessage 收尾,避免重复插入两条 + */ + existingClientMessageId?: string } /** @@ -81,22 +88,36 @@ export const useMessageSender = () => { return } - // 2. 构造本地消息并乐观插入会话;状态先置 SENDING,请求结果回来由 ackMessage 更新 - const clientMessageId = generateClientMessageId() - const message = buildLocalMessage({ - clientMessageId, - content, - targetId: realTarget, - type, - atUserIds: options?.atUserIds - }) - const conversationInfo = { - type: conversation.type, - targetId: realTarget, - name: conversation.name || String(realTarget), - avatar: conversation.avatar || '' + // 2. 准备 clientMessageId:媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id;其余场景走默认乐观插入 + let clientMessageId: string + if (options?.existingClientMessageId) { + clientMessageId = options.existingClientMessageId + // 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送, + // 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条" + const targetConversation = conversationStore.getConversation(conversation.type, realTarget) + const stillExists = targetConversation?.messages.some( + (m) => m.clientMessageId === clientMessageId + ) + if (!stillExists) { + return + } + } else { + clientMessageId = generateClientMessageId() + const message = buildLocalMessage({ + clientMessageId, + content, + targetId: realTarget, + type, + atUserIds: options?.atUserIds + }) + const conversationInfo = { + type: conversation.type, + targetId: realTarget, + name: conversation.name || String(realTarget), + avatar: conversation.avatar || '' + } + conversationStore.insertMessage(conversationInfo, message) } - conversationStore.insertMessage(conversationInfo, message) // 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED try { diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 08a3e0d99..62fe04ffe 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -8,7 +8,9 @@

{{ muteOverlay.text }} @@ -17,7 +19,9 @@ 内层白色圆角卡片 = editor + 工具栏;border + rounded 模拟微信"输入框"边界, 避免之前"无框 Web 输入"的散开感;border 走 scoped CSS(UnoCSS 不带 border-style preflight) --> -
+
- - + +
+ +
+ {{ uploadProgressText }} +
+
+
@@ -99,6 +107,18 @@
{{ formatFileSize(filePayload.size) }}
+ +
+
+
+
+ + {{ uploadProgressText }} + +
- + 不接入第三方播放器、不重写 UI,保持和图片 / 文件分支一样的轻量观感;上传中半透明遮罩显示进度 --> +
+ +
+ {{ uploadProgressText }} +
+
[视频消息]
+ + +
+
+ +
+
+ {{ cardPayload.nickname }} +
+
+
+
+ 个人名片 +
+