diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts
index 5407b50b4..abeade319 100644
--- a/src/views/im/home/composables/useMediaUploader.ts
+++ b/src/views/im/home/composables/useMediaUploader.ts
@@ -4,23 +4,97 @@ 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 { ImMessageStatus, ImMessageType } from '../../utils/constants'
import { getConversationKey } from '../../utils/conversation'
import {
generateClientMessageId,
+ parseMessage,
serializeMessage,
withQuotePayload,
- type QuoteMessage
+ type AudioMessage,
+ type FileMessage,
+ type ImageMessage,
+ type QuoteMessage,
+ type VideoMessage
} from '../../utils/message'
import type { Conversation, Message } from '../types'
-/** 单次媒体上传的入参(image / file / voice 共用;video 走低层 helper 自行组装) */
-export interface UploadAndSendMediaOptions
{
+/** 单条媒体 payload 联合(覆盖 IMAGE / FILE / VOICE / VIDEO 四种) */
+export type MediaPayload = ImageMessage | FileMessage | AudioMessage | VideoMessage
+
+/**
+ * 媒体特定的元数据上下文:首发 / 重传共用入参;不同 type 关心不同字段
+ *
+ * - voiceDuration:语音时长(秒),首发由 VoiceRecorder 给,重传从旧 AudioMessage.duration 取
+ * - videoProbe:视频元信息(首发由 probeVideoFile 解出,重传从旧 VideoMessage 直接拷字段)
+ * - videoCoverUrl:视频封面真实 URL;占位阶段用 blob,commit 用真实 URL,重传时旧值若是 blob 会被跳过
+ */
+export interface MediaTypeContext {
+ voiceDuration?: number
+ videoProbe?: { duration?: number; width?: number; height?: number }
+ videoCoverUrl?: string
+}
+
+interface MediaTypeHandler {
+ /** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
+ kind: string
+ /** 由 file + url + context 生成 payload;占位时 url 是 blob URL,commit 时是真实 url */
+ build: (file: File, url: string, context: MediaTypeContext) => MediaPayload
+ /** 重传场景:从旧 content 提取 context(让重传不需要重做 probe / 重录语音) */
+ extractResendContext: (oldContent: string) => MediaTypeContext
+}
+
+/** 媒体类型注册表:image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */
+export const mediaTypeHandlers: Partial> = {
+ [ImMessageType.IMAGE]: {
+ kind: '图片',
+ build: (_file, url) => ({ url }) as ImageMessage,
+ extractResendContext: () => ({})
+ },
+ [ImMessageType.FILE]: {
+ kind: '文件',
+ build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
+ extractResendContext: () => ({})
+ },
+ [ImMessageType.VOICE]: {
+ kind: '语音',
+ build: (_file, url, context) =>
+ ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
+ extractResendContext: (oldContent) => {
+ const old = parseMessage(oldContent)
+ return { voiceDuration: old?.duration ?? 0 }
+ }
+ },
+ [ImMessageType.VIDEO]: {
+ kind: '视频',
+ build: (file, url, context) =>
+ ({
+ url,
+ coverUrl: context.videoCoverUrl,
+ duration: context.videoProbe?.duration,
+ width: context.videoProbe?.width,
+ height: context.videoProbe?.height,
+ size: file.size
+ }) as VideoMessage,
+ extractResendContext: (oldContent) => {
+ const old = parseMessage(oldContent)
+ // 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
+ const reuseCover = old?.coverUrl && !old.coverUrl.startsWith('blob:') ? old.coverUrl : undefined
+ return {
+ videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
+ videoCoverUrl: reuseCover
+ }
+ }
+ }
+}
+
+/** 单次媒体上传的入参(image / file / voice 走 uploadAndSendMedia;video 走低层 helper 自行组装) */
+export interface UploadAndSendMediaOptions {
file: File
- type: number // 对齐 ImMessageType
- kind: string // 文案:「图片」/「文件」/「语音」,仅日志用
- /** 由 url 生成消息 payload;占位阶段传 blob URL,上传成功后再用真实 url 重生成 */
- buildPayload: (url: string) => P
+ /** 对齐 ImMessageType;mediaTypeHandlers 必须有对应项 */
+ type: number
+ /** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
+ context?: MediaTypeContext
/** 引用消息(若有),写进 payload.quote */
quote?: QuoteMessage
/** 锁定起始会话,上传期间会话切走则放弃发送 */
@@ -101,6 +175,57 @@ export const useMediaUploader = () => {
})
}
+ /**
+ * 生成 axios `onUploadProgress` 回调:用 closure 缓存上次百分比,未变化直接 return 不进 store
+ *
+ * XHR onProgress 大文件下每秒触发 10-50 次,但 Math.round 后百分比有大量重复(一秒内可能十几次同一个数字);
+ * 在源头去重,能省掉 store 的 find + Object.assign + Vue reactivity 触发链
+ */
+ const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => {
+ let lastPercent = -1
+ return (event: ProgressEvent): void => {
+ if (!event.total) {
+ return
+ }
+ const percent = Math.round((event.loaded / event.total) * 100)
+ if (percent === lastPercent) {
+ return
+ }
+ lastPercent = percent
+ conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
+ uploadProgress: percent
+ })
+ }
+ }
+
+ /** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
+ const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
+
+ /**
+ * 上传完成后的收口校验:会话仍是占位时锁定的那个 + 当前未被禁言;任一不满足 markMediaFailed + 返回 false
+ *
+ * image / file / voice / video 链路都要在「拿到真实 url 后、调 sendRaw 之前」过一遍这两道
+ */
+ const verifyMediaUploadStillAllowed = (
+ conversation: Conversation,
+ startKey: string,
+ type: number,
+ clientMessageId: string
+ ): boolean => {
+ const activeConversation = conversationStore.activeConversation
+ if (!activeConversation || getConversationKey(activeConversation) !== startKey) {
+ console.warn(`[IM] ${getMediaKind(type)}上传期间切换了会话,放弃发送`, { startKey })
+ markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
+ return false
+ }
+ if (muteOverlay.value) {
+ console.warn(`[IM] ${getMediaKind(type)}上传期间被禁言,放弃发送`, { startKey })
+ markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
+ return false
+ }
+ return true
+ }
+
/**
* 占位完成后用真实 url 替换 content,再走 sendRaw 完成发送
*
@@ -118,30 +243,38 @@ export const useMediaUploader = () => {
opts.clientMessageId,
{ content: opts.realContent }
)
+ // 显式传 conversation 而非依赖 sendRaw 内部取 active:
+ // verifyMediaUploadStillAllowed 与 sendRaw 之间存在微秒窗口,期间用户切会话也能保证发到原会话
await sendRaw(opts.type, opts.realContent, {
existingClientMessageId: opts.clientMessageId,
- targetId: opts.conversation.targetId
+ targetId: opts.conversation.targetId,
+ conversation: opts.conversation
})
}
/**
- * 上传媒体文件并发送消息(高层入口;image / file / voice 用)
+ * 上传媒体文件并发送消息(高层入口;image / file / voice 用,video 走低层 helper 自行组装)
*
* @returns 占位消息的 clientMessageId(调用方按需用于后续 patch / 移除;上传失败时占位仍保留为 FAILED 态)
*/
- const uploadAndSendMedia = async (
- opts: UploadAndSendMediaOptions
- ): Promise => {
+ const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise => {
const { conversation } = opts
+ const handler = mediaTypeHandlers[opts.type]
+ if (!handler) {
+ console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type })
+ return ''
+ }
const startKey = getConversationKey(conversation)
+ const context = opts.context ?? {}
+ const buildContent = (url: string): string =>
+ serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote))
// 1. 立即占位
const { clientMessageId } = insertMediaPlaceholder({
file: opts.file,
type: opts.type,
conversation,
- buildContent: (blobUrl) =>
- serializeMessage(withQuotePayload(opts.buildPayload(blobUrl), opts.quote))
+ buildContent
})
// 2. 上传:进度回调 patch uploadProgress;失败保留 _localFile 供重试
@@ -149,18 +282,13 @@ export const useMediaUploader = () => {
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 }
+ const res = (await updateFile(
+ form,
+ createUploadProgressHandler(conversation, clientMessageId)
+ )) as { data?: string }
url = res?.data
} catch (e) {
- console.error(`[IM] ${opts.kind}上传失败`, e)
+ console.error(`[IM] ${handler.kind}上传失败`, e)
}
if (!url) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
@@ -168,30 +296,27 @@ export const useMediaUploader = () => {
}
// 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)
+ if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
return clientMessageId
}
// 4. patch content + sendRaw 收尾
- const realContent = serializeMessage(
- withQuotePayload
(opts.buildPayload(url), opts.quote)
- )
await commitMediaPlaceholder({
type: opts.type,
conversation,
clientMessageId,
- realContent
+ realContent: buildContent(url)
})
return clientMessageId
}
- return { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder }
+ return {
+ uploadAndSendMedia,
+ insertMediaPlaceholder,
+ markMediaFailed,
+ commitMediaPlaceholder,
+ createUploadProgressHandler,
+ verifyMediaUploadStillAllowed,
+ getMediaKind
+ }
}
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 62fe04ffe..95d9de8c7 100644
--- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue
+++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue
@@ -170,16 +170,15 @@ import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
-import { useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
+import {
+ mediaTypeHandlers,
+ useMediaUploader
+} from '@/views/im/home/composables/useMediaUploader'
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
serializeMessage,
- type ImageMessage,
- type FileMessage,
- type AudioMessage,
- type VideoMessage,
type QuoteMessage,
withQuotePayload
} from '@/views/im/utils/message'
@@ -198,8 +197,14 @@ const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const { send } = useMessageSender()
-const { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder } =
- useMediaUploader()
+const {
+ uploadAndSendMedia,
+ insertMediaPlaceholder,
+ markMediaFailed,
+ commitMediaPlaceholder,
+ createUploadProgressHandler,
+ verifyMediaUploadStillAllowed
+} = useMediaUploader()
const editorRef = useTemplateRef('editorRef')
const imageInputRef = useTemplateRef('imageInputRef')
@@ -573,16 +578,6 @@ function consumeReply(): QuoteMessage | undefined {
return quote
}
-/** 校验当前激活会话仍是 startKey;切走了记日志 + 返回 false,调用方放弃发送 */
-function isStillSameConversation(startKey: string, kind: string): boolean {
- const conversation = conversationStore.activeConversation
- if (!conversation || getConversationKey(conversation) !== startKey) {
- console.warn(`[IM] ${kind}上传期间切换了会话,放弃发送`, { startKey })
- return false
- }
- return true
-}
-
// ==================== 表情 ====================
const emojiVisible = ref(false)
/** 切换表情面板;打开时互斥关掉语音面板 */
@@ -826,29 +821,25 @@ async function uploadAndSendImage(file: File) {
if (!context) {
return
}
- await uploadAndSendMedia({
+ await uploadAndSendMedia({
file,
type: ImMessageType.IMAGE,
- kind: '图片',
quote: context.quote,
- conversation: context.conversation,
- buildPayload: (url) => ({ url })
+ conversation: context.conversation
})
}
-/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
+/** 上传并发送 FILE 消息;payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */
async function uploadAndSendFile(file: File) {
const context = prepareMediaUpload()
if (!context) {
return
}
- await uploadAndSendMedia({
+ await uploadAndSendMedia({
file,
type: ImMessageType.FILE,
- kind: '文件',
quote: context.quote,
- conversation: context.conversation,
- buildPayload: (url) => ({ url, name: file.name, size: file.size })
+ conversation: context.conversation
})
}
@@ -879,20 +870,19 @@ function openVoice() {
voiceVisible.value = true
emojiVisible.value = false
}
-/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia */
+/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia;duration 走 context */
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
const context = prepareMediaUpload()
if (!context) {
return
}
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
- await uploadAndSendMedia({
+ await uploadAndSendMedia({
file,
type: ImMessageType.VOICE,
- kind: '语音',
quote: context.quote,
conversation: context.conversation,
- buildPayload: (url) => ({ url, duration: payload.duration })
+ context: { voiceDuration: payload.duration }
})
}
@@ -1008,12 +998,11 @@ async function uploadAndSendVideo(file: File) {
const startKey = getConversationKey(conversation)
// 1. 立即占位:blob URL 同时作 url + coverUrl 让