gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/composables/useMediaUploader.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,408 @@
import type { Conversation, Message } from '../types'
import type { AxiosProgressEvent } from '#/api/infra/file'
import { isOpenableUrl } from '#/packages/utils/src'
import { message } from 'ant-design-vue'
import { uploadFile } from '#/api/infra/file'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
  MESSAGE_FILE_MAX_MB,
  MESSAGE_IMAGE_MAX_MB,
  MESSAGE_VIDEO_MAX_MB,
  MESSAGE_VOICE_MAX_MB
} from '../../utils/config'
import { ImContentType, ImMessageStatus } from '../../utils/constants'
import { getConversationKey } from '../../utils/conversation'
import {
  type AudioMessage,
  BLOB_URL_PREFIX,
  type FileMessage,
  generateClientMessageId,
  type ImageMessage,
  parseMessage,
  type QuoteMessage,
  serializeMessage,
  type VideoMessage,
  withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay'
type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0]
/** å•条媒体 payload è”合(覆盖 IMAGE / FILE / VOICE / VIDEO å››ç§ï¼‰ */
export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage
/**
 * åª’体特定的元数据上下文:首发 / é‡ä¼ å…±ç”¨å…¥å‚;不同 type å…³å¿ƒä¸åŒå­—段
 *
 * - voiceDuration:语音时长(秒),首发由 VoiceRecorder ç»™ï¼Œé‡ä¼ ä»Žæ—§ AudioMessage.duration å–
 * - videoProbe:视频元信息(首发由 probeVideoFile è§£å‡ºï¼Œé‡ä¼ ä»Žæ—§ VideoMessage ç›´æŽ¥æ‹·å­—段)
 * - videoCoverUrl:视频封面真实 URL;占位阶段不设(避免传 blob å½“ poster åœ¨éƒ¨åˆ†æµè§ˆå™¨é€€åŒ–),commit é˜¶æ®µç”± cover ä¸Šä¼ ç»“果填入;重传时从旧 VideoMessage.coverUrl å¤ç”¨ï¼Œæ—§å€¼è‹¥æ˜¯ blob ä¼šè¢«è·³è¿‡
 */
export interface MediaTypeContext {
  voiceDuration?: number
  videoProbe?: { duration?: number; height?: number; width?: number; }
  videoCoverUrl?: string
}
export 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<Record<number, MediaTypeHandler>> = {
  [ImContentType.IMAGE]: {
    kind: '图片',
    build: (_file, url) => ({ url }) as ImageMessage,
    extractResendContext: () => ({})
  },
  [ImContentType.FILE]: {
    kind: '文件',
    build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
    extractResendContext: () => ({})
  },
  [ImContentType.VOICE]: {
    kind: '语音',
    build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
    extractResendContext: (oldContent) => {
      const old = parseMessage<AudioMessage>(oldContent)
      return { voiceDuration: old?.duration ?? 0 }
    }
  },
  [ImContentType.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<VideoMessage>(oldContent)
      // æ—§ coverUrl æ˜¯ blob è¯´æ˜Žä¸Šä¼ æœŸå¤±è´¥ï¼ˆcover æ²¡ä¼ æˆåŠŸï¼‰ï¼Œä¸å¤ç”¨ï¼›çœŸå®ž URL ç›´æŽ¥å¤ç”¨ï¼Œçœä¸€æ¬¡å°é¢ä¸Šä¼ 
      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
      }
    }
  }
}
/** å•次媒体上传的入参(image / file / voice èµ° uploadAndSendMedia;video èµ°ä½Žå±‚ helper è‡ªè¡Œç»„装) */
export interface UploadAndSendMediaOptions {
  file: File
  /** å¯¹é½ ImContentType;mediaTypeHandlers å¿…须有对应项 */
  type: number
  /** åª’体特定的元数据(如语音时长 / è§†é¢‘元信息);不传按空对象处理 */
  context?: MediaTypeContext
  /** å¼•用消息(若有),写进 payload.quote */
  quote?: QuoteMessage
  /** é”å®šèµ·å§‹ä¼šè¯ï¼Œä¸Šä¼ æœŸé—´ä¼šè¯åˆ‡èµ°åˆ™æ”¾å¼ƒå‘送 */
  conversation: Conversation
  /** é‡è¯•已有占位消息时复用的客户端消息编号 */
  existingClientMessageId?: string
}
/**
 * åª’体上传 + å‘送 composable(image / file / voice / video å…±ç”¨åº•层 helper)
 *
 * ä¸Ž useMessageSender.sendRaw çš„「先发请求再 ack」不同,媒体链路必须「先占位再上传」:
 * 1. ç«‹å³ insertMessage å†™å…¥å ä½æ¶ˆæ¯ï¼ˆstatus=SENDING、content ç”¨ blob URL、_localFile å†…存留 File)
 * 2. uploadFile ä¸Šä¼ ï¼ŒonUploadProgress å›žè°ƒ patchMessage æ›´æ–° uploadProgress;UI å®žæ—¶æ˜¾ç¤ºè¿›åº¦æ¡
 * 3. ä¸Šä¼ æˆåŠŸåŽç”¨çœŸå®ž url é‡ç”Ÿ content,patchMessage æ›¿æ¢ï¼›æ—§ blob URL ç”± store è‡ªåЍ revoke
 * 4. èµ° sendRaw(existingClientMessageId) å¤ç”¨å ä½å‘送请求,避免重复插入两条
 *
 * ä»»æ„å¤±è´¥æŠŠæ¶ˆæ¯çŠ¶æ€ç½® FAILED;MessageItem ä¸Šç‚¹é‡è¯•再走一次本函数(_localFile è¿˜åœ¨å†…存就行)
 */
/** æŒ‰å†…容类型映射体积上限(MB);未识别类型返回 0 è¡¨ç¤ºä¸é™ */
function resolveMediaMaxMb(type: number): number {
  switch (type) {
    case ImContentType.FILE: {
      return MESSAGE_FILE_MAX_MB
    }
    case ImContentType.IMAGE: {
      return MESSAGE_IMAGE_MAX_MB
    }
    case ImContentType.VIDEO: {
      return MESSAGE_VIDEO_MAX_MB
    }
    case ImContentType.VOICE: {
      return MESSAGE_VOICE_MAX_MB
    }
    default: {
      return 0
    }
  }
}
/** æ ¡éªŒåª’体文件大小是否超过内容类型上限;超限触发 warn å¹¶è¿”回 false,调用方不应进入占位 / ä¸Šä¼ é“¾è·¯ */
export function ensureMediaSizeWithinLimit(
  file: File,
  type: number,
  warn: (text: string) => void
): boolean {
  const maxMb = resolveMediaMaxMb(type)
  if (maxMb && file.size > maxMb * 1024 * 1024) {
    warn(`文件大小超过上限 ${maxMb}MB,请压缩后再发`)
    return false
  }
  return true
}
export const useMediaUploader = () => {
  const conversationStore = useConversationStore()
  const messageStore = useMessageStore()
  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: {
    buildContent: (blobUrl: string) => string
    conversation: Conversation
    existingClientMessageId?: string
    file: File
    type: number
  }): { blobUrl: string; clientMessageId: string; } => {
    const { conversation } = opts
    const blobUrl = URL.createObjectURL(opts.file)
    const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
    if (opts.existingClientMessageId) {
      messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
        content: opts.buildContent(blobUrl),
        status: ImMessageStatus.SENDING,
        uploadProgress: 0,
        _localFile: opts.file
      })
      return { clientMessageId, blobUrl }
    }
    const placeholder: Message = {
      clientMessageId,
      type: opts.type,
      content: opts.buildContent(blobUrl),
      status: ImMessageStatus.SENDING,
      sendTime: Date.now(),
      senderId: getCurrentUserId(),
      targetId: conversation.targetId,
      selfSend: true,
      uploadProgress: 0,
      _localFile: opts.file
    }
    void messageStore.insertMessage(
      {
        type: conversation.type,
        targetId: conversation.targetId,
        name: conversation.name || String(conversation.targetId),
        avatar: conversation.avatar || ''
      },
      placeholder
    ).catch(() => undefined)
    return { clientMessageId, blobUrl }
  }
  /**
   * æŠŠå ä½æ¶ˆæ¯ç½®ä¸º FAILED(上传失败 / ä¼šè¯åˆ‡èµ° / ç¦è¨€æœŸåˆ°ç‚¹ ç­‰åœºæ™¯ç»Ÿä¸€æ”¶å°¾ï¼‰
   *
   * åŒæ—¶æ¸…掉 uploadProgress â€”— å¤±è´¥åŽ MessageItem çš„ isUploading ä¸å†å‘½ä¸­ï¼Œè¿›åº¦é®ç½© / æ–‡ä»¶ç‚¹å‡»ç¦ç”¨ / è¯­éŸ³ loading æŠ‘制 éƒ½åŒæ­¥è§£é™¤ï¼›
   * _localFile ä¿ç•™ï¼Œè®©ç”¨æˆ·ç‚¹é‡è¯•可以走 uploadAndSendMedia é‡ä¼ 
   */
  const markMediaFailed = (
    conversationType: number,
    targetId: number,
    clientMessageId: string
  ): void => {
    messageStore.patchMessage(conversationType, targetId, clientMessageId, {
      status: ImMessageStatus.FAILED,
      uploadProgress: undefined
    })
  }
  /**
   * ç”Ÿæˆ 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: UploadProgressEvent): void => {
      if (!event.total) {
        return
      }
      const percent = Math.round((event.loaded / event.total) * 100)
      if (percent === lastPercent) {
        return
      }
      lastPercent = percent
      messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
        uploadProgress: percent
      })
    }
  }
  /** å–媒体类型中文名(仅日志用);未注册 type é€€åŒ–为通用「媒体」 */
  const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
  /**
   * æŒ‰ type å– handler,缺则抛错(程序错误集中在这一处)
   *
   * è°ƒç”¨æ–¹æ‹¿åˆ°çš„æ˜¯ `MediaTypeHandler`(非 undefined),不再需要 `!` æ–­è¨€ã€‚
   * ä»…给「确定 type åœ¨è¡¨é‡Œã€çš„调用方用 â€”— image/file/voice/video å››ç±»å…¥å£ï¼›é€šç”¨ dispatcher ä»å¯ç”¨ `mediaTypeHandlers[type]?.` optional chain
   */
  const requireMediaHandler = (type: number): MediaTypeHandler => {
    const handler = mediaTypeHandlers[type]
    if (!handler) {
      throw new Error(`[IM] æœªæ³¨å†Œçš„媒体类型 ${type}`)
    }
    return handler
  }
  /**
   * ä¸Šä¼ å®ŒæˆåŽçš„æ”¶å£æ ¡éªŒï¼šä¼šè¯ä»æ˜¯å ä½æ—¶é”å®šçš„那个 + å½“前未被禁言;任一不满足 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 å®Œæˆå‘送
   *
   * ä¸Šä¼ æˆåŠŸ â†’ patch content â†’ sendRaw å¤ç”¨ existingClientMessageId;store å†…部 revoke æ—§ blob URL
   */
  const commitMediaPlaceholder = async (opts: {
    clientMessageId: string
    conversation: Conversation
    realContent: string
    type: number
  }): Promise<void> => {
    messageStore.patchMessage(
      opts.conversation.type,
      opts.conversation.targetId,
      opts.clientMessageId,
      { content: opts.realContent }
    )
    // æ˜¾å¼ä¼  conversation è€Œéžä¾èµ– sendRaw å†…部取 active:
    // verifyMediaUploadStillAllowed ä¸Ž sendRaw ä¹‹é—´å­˜åœ¨å¾®ç§’窗口,期间用户切会话也能保证发到原会话
    await sendRaw(opts.type, opts.realContent, {
      existingClientMessageId: opts.clientMessageId,
      targetId: opts.conversation.targetId,
      conversation: opts.conversation
    })
  }
  /**
   * ä¸Šä¼ åª’体文件并发送消息(高层入口;image / file / voice ç”¨ï¼Œvideo èµ°ä½Žå±‚ helper è‡ªè¡Œç»„装)
   *
   * @returns å ä½æ¶ˆæ¯çš„ clientMessageId(调用方按需用于后续 patch / ç§»é™¤ï¼›ä¸Šä¼ å¤±è´¥æ—¶å ä½ä»ä¿ç•™ä¸º FAILED æ€ï¼‰
   */
  const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise<string> => {
    const { conversation } = opts
    const handler = mediaTypeHandlers[opts.type]
    if (!handler) {
      console.warn('[IM] uploadAndSendMedia æ”¶åˆ°æœªæ³¨å†Œçš„媒体类型', { type: opts.type })
      return ''
    }
    // ä½“积上限拦截:大文件浏览器内截帧 / è§£ç å¯è‡´ OOM;超限直接 warning,不进入占位 / ä¸Šä¼ é“¾è·¯
    if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) {
      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,
      existingClientMessageId: opts.existingClientMessageId
    })
    // 2. ä¸Šä¼ ï¼šè¿›åº¦å›žè°ƒ patch uploadProgress;失败保留 _localFile ä¾›é‡è¯•
    let url: string | undefined
    try {
      url = await uploadFile(
        { file: opts.file },
        createUploadProgressHandler(conversation, clientMessageId)
      )
    } catch (error) {
      console.error(`[IM] ${handler.kind}上传失败`, error)
    }
    if (!url) {
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return clientMessageId
    }
    if (!isOpenableUrl(url)) {
      console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url })
      message.warning('上传返回的文件地址不支持打开')
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return clientMessageId
    }
    // 3. ä¸Šä¼ æœŸé—´ä¼šè¯åˆ‡æ¢ / ç”¨æˆ·ç™»å‡º / è¢«ç¦è¨€ï¼šä»»ä¸€æƒ…况都放弃发送,占位置 FAILED
    if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
      return clientMessageId
    }
    // 4. patch content + sendRaw æ”¶å°¾
    await commitMediaPlaceholder({
      type: opts.type,
      conversation,
      clientMessageId,
      realContent: buildContent(url)
    })
    return clientMessageId
  }
  return {
    uploadAndSendMedia,
    insertMediaPlaceholder,
    markMediaFailed,
    commitMediaPlaceholder,
    createUploadProgressHandler,
    verifyMediaUploadStillAllowed,
    getMediaKind,
    requireMediaHandler
  }
}