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