From 27cd042df9aca0383a49f3514bc21958dd890912 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期一, 29 六月 2026 15:42:23 +0800
Subject: [PATCH] 银川 1.联调产品维护页面 2.添加IM即时通讯模块

---
 src/views/im/home/composables/useMediaUploader.ts |  408 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 408 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts
new file mode 100644
index 0000000..a841ecd
--- /dev/null
+++ b/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 瑙e嚭锛岄噸浼犱粠鏃� VideoMessage 鐩存帴鎷峰瓧娈碉級
+ * - videoCoverUrl锛氳棰戝皝闈㈢湡瀹� URL锛涘崰浣嶉樁娈典笉璁撅紙閬垮厤浼� blob 褰� poster 鍦ㄩ儴鍒嗘祻瑙堝櫒閫�鍖栵級锛宑ommit 闃舵鐢� cover 涓婁紶缁撴灉濉叆锛涢噸浼犳椂浠庢棫 VideoMessage.coverUrl 澶嶇敤锛屾棫鍊艰嫢鏄� blob 浼氳璺宠繃
+ */
+export interface MediaTypeContext {
+  voiceDuration?: number
+  videoProbe?: { duration?: number; height?: number; width?: number; }
+  videoCoverUrl?: string
+}
+
+export interface MediaTypeHandler {
+  /** 涓枃鍚嶏紝浠呮棩蹇楃敤锛堟浛浠d箣鍓嶆暎钀� 9 澶勭殑 kind 瀛楃涓诧級 */
+  kind: string
+  /** 鐢� file + url + context 鐢熸垚 payload锛涘崰浣嶆椂 url 鏄� blob URL锛宑ommit 鏃舵槸鐪熷疄 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锛泇ideo 璧颁綆灞� helper 鑷缁勮锛� */
+export interface UploadAndSendMediaOptions {
+  file: File
+  /** 瀵归綈 ImContentType锛沵ediaTypeHandlers 蹇呴』鏈夊搴旈」 */
+  type: number
+  /** 濯掍綋鐗瑰畾鐨勫厓鏁版嵁锛堝璇煶鏃堕暱 / 瑙嗛鍏冧俊鎭級锛涗笉浼犳寜绌哄璞″鐞� */
+  context?: MediaTypeContext
+  /** 寮曠敤娑堟伅锛堣嫢鏈夛級锛屽啓杩� payload.quote */
+  quote?: QuoteMessage
+  /** 閿佸畾璧峰浼氳瘽锛屼笂浼犳湡闂翠細璇濆垏璧板垯鏀惧純鍙戦�� */
+  conversation: Conversation
+  /** 閲嶈瘯宸叉湁鍗犱綅娑堟伅鏃跺鐢ㄧ殑瀹㈡埛绔秷鎭紪鍙� */
+  existingClientMessageId?: string
+}
+
+/**
+ * 濯掍綋涓婁紶 + 鍙戦�� composable锛坕mage / file / voice / video 鍏辩敤搴曞眰 helper锛�
+ *
+ * 涓� useMessageSender.sendRaw 鐨勩�屽厛鍙戣姹傚啀 ack銆嶄笉鍚岋紝濯掍綋閾捐矾蹇呴』銆屽厛鍗犱綅鍐嶄笂浼犮�嶏細
+ * 1. 绔嬪嵆 insertMessage 鍐欏叆鍗犱綅娑堟伅锛坰tatus=SENDING銆乧ontent 鐢� blob URL銆乢localFile 鍐呭瓨鐣� File锛�
+ * 2. uploadFile 涓婁紶锛宱nUploadProgress 鍥炶皟 patchMessage 鏇存柊 uploadProgress锛沀I 瀹炴椂鏄剧ず杩涘害鏉�
+ * 3. 涓婁紶鎴愬姛鍚庣敤鐪熷疄 url 閲嶇敓 content锛宲atchMessage 鏇挎崲锛涙棫 blob URL 鐢� store 鑷姩 revoke
+ * 4. 璧� sendRaw(existingClientMessageId) 澶嶇敤鍗犱綅鍙戦�佽姹傦紝閬垮厤閲嶅鎻掑叆涓ゆ潯
+ *
+ * 浠绘剰澶辫触鎶婃秷鎭姸鎬佺疆 FAILED锛汳essageItem 涓婄偣閲嶈瘯鍐嶈蛋涓�娆℃湰鍑芥暟锛坃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锛沬mage/file/voice 璧� uploadAndSendMedia 鍖呰锛寁ideo 鐩存帴鐢ㄦ湰鍑芥暟锛�
+   *
+   * 鐢� 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锛泂tore 鍐呴儴 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
+    })
+  }
+
+  /**
+   * 涓婁紶濯掍綋鏂囦欢骞跺彂閫佹秷鎭紙楂樺眰鍏ュ彛锛沬mage / 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 ''
+    }
+    // 浣撶Н涓婇檺鎷︽埅锛氬ぇ鏂囦欢娴忚鍣ㄥ唴鎴抚 / 瑙g爜鍙嚧 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
+  }
+}

--
Gitblit v1.9.3