409 lines
15 KiB
TypeScript
409 lines
15 KiB
TypeScript
import type { Conversation, Message } from '../types'
|
||
|
||
import type { AxiosProgressEvent } from '#/api/infra/file'
|
||
|
||
import { isOpenableUrl } from '@vben/utils'
|
||
|
||
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
|
||
}
|
||
}
|