🐛 fix(im):codex 评审修复发送中状态边角
- recomputeConversationLast 空 messages 时一并清 lastSendTime, 避免刷新后被 drop 的媒体占位让空会话仍按旧时间排在列表前面 - 视频占位 coverUrl 不再赋 blob URL:<video poster> 期待图片资源, 传 video blob 在部分浏览器会退化成黑底,cover 等 probe 出真实 URL 后由 commit 阶段一起 patch - useMediaUploader 暴露 requireMediaHandler typed accessor, 消除 video 链路 mediaTypeHandlers[VIDEO]! 非空断言 - MessageItem 把外层 loading 的 (!isUploading || isVoice) 抽成 showSendingLoading computedim
parent
459eaa5428
commit
30d695d702
|
|
@ -35,7 +35,7 @@ export interface MediaTypeContext {
|
||||||
videoCoverUrl?: string
|
videoCoverUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaTypeHandler {
|
export interface MediaTypeHandler {
|
||||||
/** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
|
/** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
|
||||||
kind: string
|
kind: string
|
||||||
/** 由 file + url + context 生成 payload;占位时 url 是 blob URL,commit 时是真实 url */
|
/** 由 file + url + context 生成 payload;占位时 url 是 blob URL,commit 时是真实 url */
|
||||||
|
|
@ -201,6 +201,20 @@ export const useMediaUploader = () => {
|
||||||
/** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
|
/** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
|
||||||
const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
|
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
|
* 上传完成后的收口校验:会话仍是占位时锁定的那个 + 当前未被禁言;任一不满足 markMediaFailed + 返回 false
|
||||||
*
|
*
|
||||||
|
|
@ -317,6 +331,7 @@ export const useMediaUploader = () => {
|
||||||
commitMediaPlaceholder,
|
commitMediaPlaceholder,
|
||||||
createUploadProgressHandler,
|
createUploadProgressHandler,
|
||||||
verifyMediaUploadStillAllowed,
|
verifyMediaUploadStillAllowed,
|
||||||
getMediaKind
|
getMediaKind,
|
||||||
|
requireMediaHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,10 +170,7 @@ import { useFriendStore } from '@/views/im/home/store/friendStore'
|
||||||
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
||||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||||
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
|
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
|
||||||
import {
|
import { useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
|
||||||
mediaTypeHandlers,
|
|
||||||
useMediaUploader
|
|
||||||
} from '@/views/im/home/composables/useMediaUploader'
|
|
||||||
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
|
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
|
||||||
import { getConversationKey } from '@/views/im/utils/conversation'
|
import { getConversationKey } from '@/views/im/utils/conversation'
|
||||||
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
||||||
|
|
@ -203,7 +200,8 @@ const {
|
||||||
markMediaFailed,
|
markMediaFailed,
|
||||||
commitMediaPlaceholder,
|
commitMediaPlaceholder,
|
||||||
createUploadProgressHandler,
|
createUploadProgressHandler,
|
||||||
verifyMediaUploadStillAllowed
|
verifyMediaUploadStillAllowed,
|
||||||
|
requireMediaHandler
|
||||||
} = useMediaUploader()
|
} = useMediaUploader()
|
||||||
|
|
||||||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
||||||
|
|
@ -997,13 +995,13 @@ async function uploadAndSendVideo(file: File) {
|
||||||
const replyQuote = context.quote
|
const replyQuote = context.quote
|
||||||
const startKey = getConversationKey(conversation)
|
const startKey = getConversationKey(conversation)
|
||||||
|
|
||||||
// 1. 立即占位:blob URL 同时作 url + coverUrl 让 <video> 渲染首帧;_localFile 留 file 供失败重试
|
// 1. 立即占位:url 走 blob 让 <video src> 拉首字节渲染;coverUrl 不设 blob
|
||||||
|
// (<video poster> 期待图片资源,传 video blob 在部分浏览器会退化成黑底,不是稳定行为)
|
||||||
|
// cover 等 probe 异步出真实 URL 后由 commit 阶段一起 patch;_localFile 留 file 供失败重试
|
||||||
// payload 拼装走 mediaTypeHandlers[VIDEO].build 与 commit 阶段共享同一份逻辑
|
// payload 拼装走 mediaTypeHandlers[VIDEO].build 与 commit 阶段共享同一份逻辑
|
||||||
const videoHandler = mediaTypeHandlers[ImMessageType.VIDEO]!
|
const videoHandler = requireMediaHandler(ImMessageType.VIDEO)
|
||||||
const buildPlaceholderContent = (blobUrl: string): string =>
|
const buildPlaceholderContent = (blobUrl: string): string =>
|
||||||
serializeMessage(
|
serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote))
|
||||||
withQuotePayload(videoHandler.build(file, blobUrl, { videoCoverUrl: blobUrl }), replyQuote)
|
|
||||||
)
|
|
||||||
const { clientMessageId } = insertMediaPlaceholder({
|
const { clientMessageId } = insertMediaPlaceholder({
|
||||||
file,
|
file,
|
||||||
type: ImMessageType.VIDEO,
|
type: ImMessageType.VIDEO,
|
||||||
|
|
|
||||||
|
|
@ -193,10 +193,8 @@
|
||||||
<!-- 状态区:自己消息展示发送状态 + 已读/群回执;对方消息 + @自己时展示 @徽标 -->
|
<!-- 状态区:自己消息展示发送状态 + 已读/群回执;对方消息 + @自己时展示 @徽标 -->
|
||||||
<div class="flex gap-1.5 items-center text-base">
|
<div class="flex gap-1.5 items-center text-base">
|
||||||
<template v-if="message.selfSend">
|
<template v-if="message.selfSend">
|
||||||
<!-- SENDING 显示外层 loading;图片/视频/文件气泡自身有进度反馈则抑制;
|
|
||||||
语音气泡只有麦克风 + 时长,无内嵌进度条,必须保留外层 loading 让用户感知正在发送 -->
|
|
||||||
<Icon
|
<Icon
|
||||||
v-if="message.status === ImMessageStatus.SENDING && (!isUploading || isVoice)"
|
v-if="showSendingLoading"
|
||||||
icon="ant-design:loading-outlined"
|
icon="ant-design:loading-outlined"
|
||||||
class="im-loading-spin"
|
class="im-loading-spin"
|
||||||
/>
|
/>
|
||||||
|
|
@ -506,6 +504,16 @@ const uploadProgress = computed(() => props.message.uploadProgress ?? 0)
|
||||||
/** 上传进度文案;图片/视频遮罩、文件进度条尾巴共用 */
|
/** 上传进度文案;图片/视频遮罩、文件进度条尾巴共用 */
|
||||||
const uploadProgressText = computed(() => `${uploadProgress.value}%`)
|
const uploadProgressText = computed(() => `${uploadProgress.value}%`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否在气泡尾部显示「发送中」loading 转圈
|
||||||
|
*
|
||||||
|
* 图片 / 视频 / 文件气泡内嵌已有进度反馈(遮罩 / 进度条),外层 loading 多余则抑制;
|
||||||
|
* 语音气泡只有麦克风 + 时长,无内嵌进度,必须保留外层 loading 让用户感知正在发送
|
||||||
|
*/
|
||||||
|
const showSendingLoading = computed(
|
||||||
|
() => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value)
|
||||||
|
)
|
||||||
|
|
||||||
/** 文件类型图标 + 配色:按扩展名分发,跟 ReplyPreview 共用 getFileIconInfo */
|
/** 文件类型图标 + 配色:按扩展名分发,跟 ReplyPreview 共用 getFileIconInfo */
|
||||||
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
|
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ function recomputeConversationLast(conversation: Conversation): void {
|
||||||
conversation.lastMessageType = undefined
|
conversation.lastMessageType = undefined
|
||||||
conversation.lastSelfSend = undefined
|
conversation.lastSelfSend = undefined
|
||||||
conversation.lastSenderDisplayName = undefined
|
conversation.lastSenderDisplayName = undefined
|
||||||
|
// 排序时间也要清,否则空会话仍按旧 lastSendTime 排在前面(刷新后媒体占位被 drop 时容易踩到)
|
||||||
|
conversation.lastSendTime = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue