feat(im): 优化发送中的能力 v0.2:简化各种 kind、复用各种逻辑

im
YunaiV 2026-05-06 08:46:33 +08:00
parent 957a63f8f4
commit b17f7a57e5
5 changed files with 258 additions and 165 deletions

View File

@ -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<P extends { quote?: QuoteMessage }> {
/** 单条媒体 payload 联合(覆盖 IMAGE / FILE / VOICE / VIDEO 四种) */
export type MediaPayload = ImageMessage | FileMessage | AudioMessage | VideoMessage
/**
* / type
*
* - voiceDuration VoiceRecorder AudioMessage.duration
* - videoProbe probeVideoFile VideoMessage
* - videoCoverUrl URL blobcommit 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 URLcommit 时是真实 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>> = {
[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<AudioMessage>(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<VideoMessage>(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 走 uploadAndSendMediavideo 走低层 helper 自行组装) */
export interface UploadAndSendMediaOptions {
file: File
type: number // 对齐 ImMessageType
kind: string // 文案:「图片」/「文件」/「语音」,仅日志用
/** 由 url 生成消息 payload占位阶段传 blob URL上传成功后再用真实 url 重生成 */
buildPayload: (url: string) => P
/** 对齐 ImMessageTypemediaTypeHandlers 必须有对应项 */
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 <P extends { quote?: QuoteMessage }>(
opts: UploadAndSendMediaOptions<P>
): Promise<string> => {
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 ''
}
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<P>(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<P>(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
}
}

View File

@ -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<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('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<ImageMessage>({
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<FileMessage>({
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 后走通用 uploadAndSendMediaduration 走 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<AudioMessage>({
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 <video> _localFile file
// payload mediaTypeHandlers[VIDEO].build commit
const videoHandler = mediaTypeHandlers[ImMessageType.VIDEO]!
const buildPlaceholderContent = (blobUrl: string): string =>
serializeMessage(
withQuotePayload<VideoMessage>(
{ url: blobUrl, coverUrl: blobUrl, size: file.size },
replyQuote
)
withQuotePayload(videoHandler.build(file, blobUrl, { videoCoverUrl: blobUrl }), replyQuote)
)
const { clientMessageId } = insertMediaPlaceholder({
file,
@ -1023,23 +1012,21 @@ async function uploadAndSendVideo(file: File) {
})
// 2. probe probe cover
// 2.1 patch uploadProgress catch url=undefined step 3 url
// 2.1 async IIFE await lint floating promise
// url=undefined step 3 url
const videoForm = new FormData()
videoForm.append('file', file)
const videoUploadPromise = (
updateFile(videoForm, (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 Promise<{ data?: string }>
).catch((e) => {
console.warn('[IM] 视频本体上传失败', e)
return { data: undefined as string | undefined }
})
const videoUploadPromise: Promise<{ data?: string }> = (async () => {
try {
return (await updateFile(
videoForm,
createUploadProgressHandler(conversation, clientMessageId)
)) as { data?: string }
} catch (e) {
console.warn('[IM] 视频本体上传失败', e)
return { data: undefined }
}
})()
// 2.2 probe + blob probe
const probePromise = probeVideoFile(file).catch((e): VideoProbe => {
console.warn('[IM] 视频元信息加载失败,降级为仅 url + size', e)
@ -1076,28 +1063,18 @@ async function uploadAndSendVideo(file: File) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return
}
// 3.3
if (!isStillSameConversation(startKey, '视频')) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return
}
// 3.4 muteOverlay
if (muteOverlay.value) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
// 3.3 + muteOverlay useMediaUploader.uploadAndSendMedia
if (!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId)) {
return
}
// 4. VideoMessage payloadpatch + sendRaw
const realContent = serializeMessage(
withQuotePayload<VideoMessage>(
{
url,
coverUrl,
duration: probe.duration,
width: probe.width,
height: probe.height,
size: file.size
},
withQuotePayload(
videoHandler.build(file, url, {
videoProbe: { duration: probe.duration, width: probe.width, height: probe.height },
videoCoverUrl: coverUrl
}),
replyQuote
)
)

View File

@ -131,10 +131,7 @@
<div
v-else-if="isVoice && voicePayload"
class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]'
]"
:class="bubbleClass('voice')"
@click="handleVoiceClick"
>
<Icon
@ -317,7 +314,7 @@ import {
} from '@/views/im/utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import { useMediaUploader } from '../../../../composables/useMediaUploader'
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue'
@ -873,62 +870,25 @@ async function handleResend() {
const message = props.message
const file = message._localFile
// + _localFile uploadAndSendMedia type buildPayload content
// + _localFile uploadAndSendMediatype + mediaTypeHandlers
if (isMediaMessageType(message.type) && file) {
const oldQuote = getQuoteFromMessage(message.content) ?? undefined
conversationStore.removeMessage(conversation.type, conversation.targetId, {
id: message.id,
clientMessageId: message.clientMessageId
})
if (message.type === ImMessageType.IMAGE) {
await uploadAndSendMedia<ImageMessage>({
const handler = mediaTypeHandlers[message.type]
if (handler) {
const oldQuote = getQuoteFromMessage(message.content) ?? undefined
const context = handler.extractResendContext(message.content)
conversationStore.removeMessage(conversation.type, conversation.targetId, {
id: message.id,
clientMessageId: message.clientMessageId
})
await uploadAndSendMedia({
file,
type: ImMessageType.IMAGE,
kind: '图片',
type: message.type,
quote: oldQuote,
conversation,
buildPayload: (url) => ({ url })
})
} else if (message.type === ImMessageType.FILE) {
await uploadAndSendMedia<FileMessage>({
file,
type: ImMessageType.FILE,
kind: '文件',
quote: oldQuote,
conversation,
buildPayload: (url) => ({ url, name: file.name, size: file.size })
})
} else if (message.type === ImMessageType.VOICE) {
const oldVoice = parseMessage<AudioMessage>(message.content)
await uploadAndSendMedia<AudioMessage>({
file,
type: ImMessageType.VOICE,
kind: '语音',
quote: oldQuote,
conversation,
buildPayload: (url) => ({ url, duration: oldVoice?.duration ?? 0 })
})
} else if (message.type === ImMessageType.VIDEO) {
const oldVideo = parseMessage<VideoMessage>(message.content)
// probe + cover沿 coverUrl coverUrl blob poster
const reuseCover = oldVideo?.coverUrl?.startsWith('blob:') ? undefined : oldVideo?.coverUrl
await uploadAndSendMedia<VideoMessage>({
file,
type: ImMessageType.VIDEO,
kind: '视频',
quote: oldQuote,
conversation,
buildPayload: (url) => ({
url,
coverUrl: reuseCover,
duration: oldVideo?.duration,
width: oldVideo?.width,
height: oldVideo?.height,
size: file.size
})
context
})
return
}
return
}
// / _localFile content sendRaw

View File

@ -605,6 +605,21 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!message) {
return
}
// 值未变就早返回onUploadProgress 高频回调里同 percent 重复 patch 时直接跳过响应式更新链
// createUploadProgressHandler 在源头已去重;这里是最后兜底,对 patch.uploadProgress / status 等字段都生效)
let changed = false
for (const key in patch) {
if (
Object.prototype.hasOwnProperty.call(patch, key)
&& (patch as Record<string, unknown>)[key] !== (message as unknown as Record<string, unknown>)[key]
) {
changed = true
break
}
}
if (!changed) {
return
}
// 替换 content 时 revoke 旧 blob URL与 ackMessage 同语义
if (patch.content && patch.content !== message.content) {
revokeBlobUrlsInContent(message.content)

View File

@ -106,19 +106,35 @@ export const parseMessage = <T>(content: string): T | null => {
export const serializeMessage = <T>(payload: T): string => JSON.stringify(payload)
/**
* content blob: URL
* payload blob URL /// url
*
* URL.createObjectURL(file) url content
* ack / / File MB
* ImageMessage / VideoMessage / FileMessage / AudioMessage interface
* - url blob URLack URL
* - coverUrl url blobcover URL
* - thumbnailUrl使 blob
*/
const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const
/**
* content payload blob URL
*
* url / coverUrl / thumbnailUrl regex grep quote.content blob URL
* document blob URL IndexedDB blob URL document
*/
export const revokeBlobUrlsInContent = (content: string): void => {
if (!content || !content.includes('blob:')) {
return
}
const matches = content.match(/blob:[^"'\s)]+/g)
matches?.forEach((url) => URL.revokeObjectURL(url))
const payload = parseMessage<Record<string, unknown>>(content)
if (!payload) {
return
}
for (const field of MEDIA_BLOB_URL_FIELDS) {
const value = payload[field]
if (typeof value === 'string' && value.startsWith('blob:')) {
URL.revokeObjectURL(value)
}
}
}
// ==================== 引用消息 helper ====================