feat(im): 增加发送中的能力,针对图片、文件、视频等

im
YunaiV 2026-05-06 08:00:23 +08:00
parent f8cc9d14d9
commit 8f2eddea4a
6 changed files with 654 additions and 197 deletions

View File

@ -0,0 +1,197 @@
import { updateFile } from '@/api/infra/file'
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 { getConversationKey } from '../../utils/conversation'
import {
generateClientMessageId,
serializeMessage,
withQuotePayload,
type QuoteMessage
} from '../../utils/message'
import type { Conversation, Message } from '../types'
/** 单次媒体上传的入参image / file / voice 共用video 走低层 helper 自行组装) */
export interface UploadAndSendMediaOptions<P extends { quote?: QuoteMessage }> {
file: File
type: number // 对齐 ImMessageType
kind: string // 文案:「图片」/「文件」/「语音」,仅日志用
/** 由 url 生成消息 payload占位阶段传 blob URL上传成功后再用真实 url 重生成 */
buildPayload: (url: string) => P
/** 引用消息(若有),写进 payload.quote */
quote?: QuoteMessage
/** 锁定起始会话,上传期间会话切走则放弃发送 */
conversation: Conversation
}
/**
* + composableimage / file / voice / video helper
*
* useMessageSender.sendRaw ack
* 1. insertMessage status=SENDINGcontent blob URL_localFile File
* 2. updateFile onUploadProgress patchMessage uploadProgressUI
* 3. url contentpatchMessage blob URL store revoke
* 4. sendRaw(existingClientMessageId)
*
* FAILEDMessageItem _localFile
*/
export const useMediaUploader = () => {
const conversationStore = useConversationStore()
const userStore = useUserStore()
const muteOverlay = useMuteOverlay()
const { sendRaw } = useMessageSender()
/**
* helperimage/file/voice uploadAndSendMedia video
*
* createObjectURL(file) blob URL buildContent status=SENDING + uploadProgress=0
* file _localFile
*/
const insertMediaPlaceholder = (opts: {
file: File
type: number
conversation: Conversation
buildContent: (blobUrl: string) => string
}): { clientMessageId: string; blobUrl: string } => {
const { conversation } = opts
const blobUrl = URL.createObjectURL(opts.file)
const clientMessageId = generateClientMessageId()
const placeholder: Message = {
id: 0,
clientMessageId,
type: opts.type,
content: opts.buildContent(blobUrl),
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: Number(userStore.getUser?.id) || 0,
targetId: conversation.targetId,
selfSend: true,
uploadProgress: 0,
_localFile: opts.file
}
conversationStore.insertMessage(
{
type: conversation.type,
targetId: conversation.targetId,
name: conversation.name || String(conversation.targetId),
avatar: conversation.avatar || ''
},
placeholder
)
return { clientMessageId, blobUrl }
}
/**
* FAILED / /
*
* uploadProgress MessageItem isUploading / / loading
* _localFile uploadAndSendMedia
*/
const markMediaFailed = (
conversationType: number,
targetId: number,
clientMessageId: string
): void => {
conversationStore.patchMessage(conversationType, targetId, clientMessageId, {
status: ImMessageStatus.FAILED,
uploadProgress: undefined
})
}
/**
* url content sendRaw
*
* patch content sendRaw existingClientMessageIdstore revoke blob URL
*/
const commitMediaPlaceholder = async (opts: {
type: number
conversation: Conversation
clientMessageId: string
realContent: string
}): Promise<void> => {
conversationStore.patchMessage(
opts.conversation.type,
opts.conversation.targetId,
opts.clientMessageId,
{ content: opts.realContent }
)
await sendRaw(opts.type, opts.realContent, {
existingClientMessageId: opts.clientMessageId,
targetId: opts.conversation.targetId
})
}
/**
* image / file / voice
*
* @returns clientMessageId patch / FAILED
*/
const uploadAndSendMedia = async <P extends { quote?: QuoteMessage }>(
opts: UploadAndSendMediaOptions<P>
): Promise<string> => {
const { conversation } = opts
const startKey = getConversationKey(conversation)
// 1. 立即占位
const { clientMessageId } = insertMediaPlaceholder({
file: opts.file,
type: opts.type,
conversation,
buildContent: (blobUrl) =>
serializeMessage(withQuotePayload<P>(opts.buildPayload(blobUrl), opts.quote))
})
// 2. 上传:进度回调 patch uploadProgress失败保留 _localFile 供重试
let url: string | undefined
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 }
url = res?.data
} catch (e) {
console.error(`[IM] ${opts.kind}上传失败`, e)
}
if (!url) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return clientMessageId
}
// 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)
return clientMessageId
}
// 4. patch content + sendRaw 收尾
const realContent = serializeMessage(
withQuotePayload<P>(opts.buildPayload(url), opts.quote)
)
await commitMediaPlaceholder({
type: opts.type,
conversation,
clientMessageId,
realContent
})
return clientMessageId
}
return { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder }
}

View File

@ -28,6 +28,13 @@ interface SendExtOptions {
targetId?: number // 覆盖默认的 targetId
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
quote?: QuoteMessage
/**
* clientMessageId
*
* insertMessage blob URL +
* buildLocalMessage / insertMessage id ackMessage
*/
existingClientMessageId?: string
}
/**
@ -81,22 +88,36 @@ export const useMessageSender = () => {
return
}
// 2. 构造本地消息并乐观插入会话;状态先置 SENDING请求结果回来由 ackMessage 更新
const clientMessageId = generateClientMessageId()
const message = buildLocalMessage({
clientMessageId,
content,
targetId: realTarget,
type,
atUserIds: options?.atUserIds
})
const conversationInfo = {
type: conversation.type,
targetId: realTarget,
name: conversation.name || String(realTarget),
avatar: conversation.avatar || ''
// 2. 准备 clientMessageId媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id其余场景走默认乐观插入
let clientMessageId: string
if (options?.existingClientMessageId) {
clientMessageId = options.existingClientMessageId
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
const targetConversation = conversationStore.getConversation(conversation.type, realTarget)
const stillExists = targetConversation?.messages.some(
(m) => m.clientMessageId === clientMessageId
)
if (!stillExists) {
return
}
} else {
clientMessageId = generateClientMessageId()
const message = buildLocalMessage({
clientMessageId,
content,
targetId: realTarget,
type,
atUserIds: options?.atUserIds
})
const conversationInfo = {
type: conversation.type,
targetId: realTarget,
name: conversation.name || String(realTarget),
avatar: conversation.avatar || ''
}
conversationStore.insertMessage(conversationInfo, message)
}
conversationStore.insertMessage(conversationInfo, message)
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD失败更新为 FAILED
try {

View File

@ -8,7 +8,9 @@
<div
v-if="muteOverlay"
class="message-input__mute-overlay"
:class="{ 'message-input__mute-overlay--banned': muteOverlay.icon === 'ant-design:stop-outlined' }"
:class="{
'message-input__mute-overlay--banned': muteOverlay.icon === 'ant-design:stop-outlined'
}"
>
<Icon :icon="muteOverlay.icon" :size="18" />
<span>{{ muteOverlay.text }}</span>
@ -17,7 +19,9 @@
内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信"输入框"边界
避免之前"无框 Web 输入"的散开感border scoped CSSUnoCSS 不带 border-style preflight
-->
<div class="message-input__card relative flex flex-col bg-[var(--el-bg-color)] rounded-lg">
<div
class="relative flex flex-col bg-[var(--el-bg-color)] rounded-lg border border-[var(--el-border-color-lighter)]"
>
<!--
输入区在上contenteditable div取代 textarea对齐微信 PC输入区在上操作在下
- @ 浮层能拿到真实光标 recttextarea 拿不到
@ -26,7 +30,7 @@
-->
<div
ref="editorRef"
class="message-input__editor"
class="message-input__editor relative min-h-[120px] max-h-[200px] overflow-y-auto py-3.5 px-4 text-sm leading-normal outline-none whitespace-pre-wrap break-words"
contenteditable="true"
data-placeholder="按 Enter 发送Shift+Enter 换行"
data-empty=""
@ -53,7 +57,7 @@
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线scoped CSS 避绕 UnoCSS preflight 缺失
-->
<div
class="message-input__toolbar relative flex items-center justify-between gap-2 px-3 py-2"
class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-[var(--el-border-color-lighter)]"
>
<div class="flex items-center gap-1">
<!--
@ -166,6 +170,7 @@ 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 { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
@ -184,6 +189,7 @@ import MentionPicker from './MentionPicker.vue'
import VoiceRecorder from './VoiceRecorder.vue'
import ReplyPreview from '../message/ReplyPreview.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import type { Conversation } from '@/views/im/home/types'
defineOptions({ name: 'ImMessageInput' })
@ -191,7 +197,9 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const { send, sendRaw } = useMessageSender()
const { send } = useMessageSender()
const { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder } =
useMediaUploader()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
@ -565,12 +573,6 @@ function consumeReply(): QuoteMessage | undefined {
return quote
}
/** 抓当前激活会话的 key媒体上传开始前调无激活会话返回 undefined */
function getActiveConversationKey(): string | undefined {
const conversation = conversationStore.activeConversation
return conversation ? getConversationKey(conversation) : undefined
}
/** 校验当前激活会话仍是 startKey切走了记日志 + 返回 false调用方放弃发送 */
function isStillSameConversation(startKey: string, kind: string): boolean {
const conversation = conversationStore.activeConversation
@ -806,63 +808,51 @@ function onKeydown(e: KeyboardEvent) {
// ==================== / / ====================
/**
* 媒体上传 发送的公共骨架image / file / voice 共用video probe + 双上传链路保留独立
*
* 禁言态直接返回锁会话 key + 消费 reply 上传 校验仍在原会话 payload + quote sendRaw
*/
async function uploadAndSendMedia<P extends { quote?: QuoteMessage }>(opts: {
file: File
type: number
kind: string
buildPayload: (url: string) => P
}) {
/** 上传前的统一拦截:禁言态 / 无激活会话直接放弃;返回当前 conversation 与抓走的 quote */
function prepareMediaUpload(): { conversation: Conversation; quote?: QuoteMessage } | undefined {
if (muteOverlay.value) {
return
return undefined
}
const startKey = getActiveConversationKey()
if (!startKey) {
return
const conversation = conversationStore.activeConversation
if (!conversation) {
return undefined
}
const replyQuote = consumeReply()
const form = new FormData()
form.append('file', opts.file)
const url = ((await updateFile(form)) as { data?: string })?.data
if (!url) {
return
}
if (!isStillSameConversation(startKey, opts.kind)) {
return
}
// muteOverlay
if (muteOverlay.value) {
return
}
const payload = withQuotePayload<P>(opts.buildPayload(url), replyQuote)
await sendRaw(opts.type, serializeMessage(payload))
return { conversation, quote: consumeReply() }
}
/** 上传并发送 IMAGE 消息 */
/** 上传并发送 IMAGE 消息(占位 + 进度 + 真实 url ack 由 useMediaUploader 处理) */
async function uploadAndSendImage(file: File) {
const context = prepareMediaUpload()
if (!context) {
return
}
await uploadAndSendMedia<ImageMessage>({
file,
type: ImMessageType.IMAGE,
kind: '图片',
quote: context.quote,
conversation: context.conversation,
buildPayload: (url) => ({ url })
})
}
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
async function uploadAndSendFile(file: File) {
const context = prepareMediaUpload()
if (!context) {
return
}
await uploadAndSendMedia<FileMessage>({
file,
type: ImMessageType.FILE,
kind: '文件',
quote: context.quote,
conversation: context.conversation,
buildPayload: (url) => ({ url, name: file.name, size: file.size })
})
}
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor整体走 sendRaw */
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor由 useMediaUploader 接管占位 / 进度 / ack */
async function onImagePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
@ -891,11 +881,17 @@ function openVoice() {
}
/** VoiceRecorder 录完回传 blob包成 webm File 后走通用 uploadAndSendMedia */
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>({
file,
type: ImMessageType.VOICE,
kind: '语音',
quote: context.quote,
conversation: context.conversation,
buildPayload: (url) => ({ url, duration: payload.duration })
})
}
@ -964,10 +960,10 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
canvas.width = Math.round(video.videoWidth * ratio)
canvas.height = Math.round(video.videoHeight * ratio)
const ctx = canvas.getContext('2d')
if (ctx && canvas.width && canvas.height) {
const context = canvas.getContext('2d')
if (context && canvas.width && canvas.height) {
// 2.4 canvas toBlob jpeg0.8
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
context.drawImage(video, 0, 0, canvas.width, canvas.height)
cover =
(await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
@ -996,31 +992,51 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
/**
* 上传并发送 VIDEO 消息
*
* 1. probe 与视频上传同步起跑封面上传等 probe cover 后与视频上传竞速
* 1. 立即占位blob URL 既当 video src 也当 poster浏览器会拿首帧绘制 coverstatus=SENDING + 进度条
* 2. probe 与视频上传同步起跑封面上传等 probe cover 后与视频上传竞速
* probe 解码 + 封面上传通常被视频上传时长完全遮蔽体感节省几百 ms 起步
* 2. 视频本体上传必须成功拿不到 url 就直接 return
* 3. 封面是锦上添花上传失败仅日志coverUrl 留空气泡 <video> 自带黑底播放按钮兜底
*
* 视频链路耗时长probe + 双上传上传期间用户切会话则放弃发送
* 否则会落到错误的会话里切走再切回来不算变化key 仍相等
* 3. 视频本体上传必须成功拿不到 url 就把占位置 FAILED封面是锦上添花失败仅日志
* 4. 视频链路耗时长上传期间用户切会话则放弃发送避免落到错误会话里切走再切回来不算变化key 仍相等
*/
async function uploadAndSendVideo(file: File) {
if (muteOverlay.value) {
const context = prepareMediaUpload()
if (!context) {
return
}
// 1. key key
const startKey = getActiveConversationKey()
if (!startKey) {
return
}
// quote draft.reply / /
const replyQuote = consumeReply()
const { conversation } = context
const replyQuote = context.quote
const startKey = getConversationKey(conversation)
// 1. blob URL url + coverUrl <video> _localFile file
const buildPlaceholderContent = (blobUrl: string): string =>
serializeMessage(
withQuotePayload<VideoMessage>(
{ url: blobUrl, coverUrl: blobUrl, size: file.size },
replyQuote
)
)
const { clientMessageId } = insertMediaPlaceholder({
file,
type: ImMessageType.VIDEO,
conversation,
buildContent: buildPlaceholderContent
})
// 2. probe probe cover
// 2.1 catch url=undefined step 3.2 url promise floating
// 2.1 patch uploadProgress catch url=undefined step 3 url
const videoForm = new FormData()
videoForm.append('file', file)
const videoUploadPromise = (updateFile(videoForm) as Promise<{ data?: string }>).catch((e) => {
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 }
})
@ -1054,36 +1070,46 @@ async function uploadAndSendVideo(file: File) {
videoUploadPromise,
coverUploadPromise
])
// 3.2 url
// 3.2 url FAILED / _localFile
const url = videoRes?.data
if (!url) {
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)
return
}
// 4. VideoMessage payload sendRaw / /
const videoPayload = withQuotePayload<VideoMessage>(
{
url,
coverUrl,
duration: probe.duration,
width: probe.width,
height: probe.height,
size: file.size
},
replyQuote
// 4. VideoMessage payloadpatch + sendRaw
const realContent = serializeMessage(
withQuotePayload<VideoMessage>(
{
url,
coverUrl,
duration: probe.duration,
width: probe.width,
height: probe.height,
size: file.size
},
replyQuote
)
)
await sendRaw(ImMessageType.VIDEO, serializeMessage(videoPayload))
await commitMediaPlaceholder({
type: ImMessageType.VIDEO,
conversation,
clientMessageId,
realContent
})
}
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor整体走 sendRaw */
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor独立链路probe + 双上传,最终走 commitMediaPlaceholder 收尾 */
async function onVideoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
@ -1095,15 +1121,6 @@ async function onVideoPicked(e: Event) {
</script>
<style scoped>
/* + 线UnoCSS border-style preflight
border-* 类只设色 / 宽不出线统一走 scoped 显式 shorthand 兜底 */
.message-input__card {
border: 1px solid var(--el-border-color-lighter);
}
.message-input__toolbar {
border-top: 1px solid var(--el-border-color-lighter);
}
/* el-icon .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em}
会盖过 UnoCSS 原子类用字面选择器 + !important 兜底
颜色取 Element Plus 主题变量暗色自动切到浅灰 */
@ -1118,21 +1135,6 @@ async function onVideoPicked(e: Event) {
color: var(--el-color-primary) !important;
}
/* 输入区在上、工具栏在下时,编辑区视觉上承担"主体"min-height / padding 都比早期版本撑大
贴近微信 PC "大输入框"观感max-height 限内部滚动避免聊天列表被挤太短 */
.message-input__editor {
position: relative;
min-height: 120px;
max-height: 200px;
overflow-y: auto;
padding: 14px 16px;
font-size: 14px;
line-height: 1.5;
outline: none;
white-space: pre-wrap;
word-break: break-word;
}
/* 用 data-empty 而非 :empty浏览器在删空后会留下 <br>:empty 不命中data-empty 由 syncEditorState 维护 */
.message-input__editor[data-empty]::before {
content: attr(data-placeholder);

View File

@ -69,24 +69,32 @@
>
{{ textContent }}
</div>
<!-- 图片消息点击大图预览 <el-image> 自身承接 -->
<el-image
v-else-if="isImage && imagePayload"
class="max-w-[220px] rounded cursor-zoom-in"
:src="imagePayload.thumbnailUrl || imagePayload.url"
:preview-src-list="[imagePayload.url]"
:preview-teleported="true"
fit="contain"
/>
<!-- 文件消息对齐微信观感 文件名 + 大小靠左按扩展名分配的大彩色图标贴右 -->
<!-- 图片消息点击大图预览 <el-image> 自身承接上传中时套一层半透明遮罩显示进度 -->
<div v-else-if="isImage && imagePayload" class="relative inline-block">
<el-image
class="max-w-[220px] rounded cursor-zoom-in"
:src="imagePayload.thumbnailUrl || imagePayload.url"
:preview-src-list="isUploading ? [] : [imagePayload.url]"
:preview-teleported="true"
fit="contain"
/>
<div
v-if="isUploading"
class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
>
{{ uploadProgressText }}
</div>
</div>
<!-- 文件消息对齐微信观感 文件名 + 大小靠左按扩展名分配的大彩色图标贴右上传中文件名下方插一条进度条 -->
<div
v-else-if="isFile && filePayload"
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded cursor-pointer transition-colors"
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded transition-colors"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]',
isUploading ? 'cursor-default' : 'cursor-pointer'
]"
@click="handleFileClick"
>
@ -99,6 +107,18 @@
<div class="mt-1 text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(filePayload.size) }}
</div>
<!-- 上传中薄进度条 + 百分比文案 -->
<div v-if="isUploading" class="flex gap-2 items-center mt-1.5">
<div class="overflow-hidden flex-1 h-1 rounded bg-[var(--el-fill-color-dark)]">
<div
class="h-full bg-[var(--el-color-primary)] transition-[width] duration-150"
:style="{ width: uploadProgress + '%' }"
></div>
</div>
<span class="text-11px text-[var(--el-text-color-secondary)] tabular-nums">
{{ uploadProgressText }}
</span>
</div>
</div>
<Icon
:icon="fileIconInfo.icon"
@ -128,15 +148,22 @@
</span>
</div>
<!-- 视频消息直接用原生 <video controls> 内嵌播放poster 取后端给的封面图
不接入第三方播放器不重写 UI保持和图片 / 文件分支一样的轻量观感 -->
<video
v-else-if="isVideo && videoPayload?.url"
class="max-w-[280px] max-h-[320px] rounded bg-black"
:src="videoPayload.url"
:poster="videoPayload.coverUrl"
controls
preload="metadata"
></video>
不接入第三方播放器不重写 UI保持和图片 / 文件分支一样的轻量观感上传中半透明遮罩显示进度 -->
<div v-else-if="isVideo && videoPayload?.url" class="relative inline-block">
<video
class="max-w-[280px] max-h-[320px] rounded bg-black"
:src="videoPayload.url"
:poster="videoPayload.coverUrl"
:controls="!isUploading"
preload="metadata"
></video>
<div
v-if="isUploading"
class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
>
{{ uploadProgressText }}
</div>
</div>
<!-- 视频消息但 payload 解析失败 / url降级展示避免出现裸的 [视频消息] -->
<div
v-else-if="isVideo"
@ -144,6 +171,42 @@
>
[视频消息]
</div>
<!-- 名片消息头像 + 昵称 + 个人名片标签点击气泡弹被推荐用户的名片浮层 -->
<!-- TODO @AI卡片样式/Users/yunai/Downloads/iShot_2026-05-06_00.00.04.png
TODO @AImessagepreview 是不是也要加下管理后台的manager
-->
<div
v-else-if="isCard && cardPayload"
class="flex flex-col min-w-[220px] max-w-[260px] rounded cursor-pointer overflow-hidden"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'bg-[#95ec69]'
: 'bg-[var(--el-bg-color)] border border-[var(--el-border-color-light)]'
]"
@click="handleCardClick"
>
<div class="flex gap-2.5 items-center px-3 py-2.5">
<UserAvatar
:id="cardPayload.userId"
:url="cardPayload.avatar"
:name="cardPayload.nickname"
:size="40"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-[var(--el-text-color-primary)]">
{{ cardPayload.nickname }}
</div>
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
:class="message.selfSend ? 'bg-[#86d65f]' : 'bg-[var(--el-fill-color-lighter)]'"
>
个人名片
</div>
</div>
<!-- 未知类型降级展示 -->
<div
v-else
@ -155,8 +218,9 @@
<!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 -->
<div class="flex gap-1.5 items-center text-base">
<template v-if="message.selfSend">
<!-- 媒体消息 SENDING 时气泡自身已显示进度遮罩/进度条外层 loading 多余其它消息含语音保留 loading 表达 -->
<Icon
v-if="message.status === ImMessageStatus.SENDING"
v-if="message.status === ImMessageStatus.SENDING && !isUploading"
icon="ant-design:loading-outlined"
class="im-loading-spin"
/>
@ -225,10 +289,12 @@ import {
ImMessageStatus,
ImGroupReceiptStatus,
ImConversationType,
ImFriendAddSource,
ImGroupMemberRole,
TIME_TIP_GAP_MS,
isFriendChatTip,
isGroupNotification,
isMediaMessageType,
isNormalMessage
} from '@/views/im/utils/constants'
import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/im/group'
@ -242,7 +308,8 @@ import {
type ImageMessage,
type FileMessage,
type AudioMessage,
type VideoMessage
type VideoMessage,
type CardMessage
} from '@/views/im/utils/message'
import { buildRecallTip } from '../../../../../utils/conversation'
import { formatSeconds } from '@/utils/formatTime'
@ -261,6 +328,7 @@ import {
} from '@/views/im/utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import { useMediaUploader } from '../../../../composables/useMediaUploader'
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue'
@ -294,18 +362,13 @@ const friendStore = useFriendStore()
const draftStore = useDraftStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
const { uploadAndSendMedia } = useMediaUploader()
const muteOverlay = useMuteOverlay()
// confirm message props.message vue/no-dupe-keys
const { confirm: confirmDialog, success: successMessage } = useMessage()
// ==================== ====================
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
/** 是否会话内好友事件气泡FRIEND_ADD / FRIEND_DELETE */
const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type))
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染 */
const shouldShowTimeTip = computed(() => {
if (!props.message.sendTime) {
@ -323,31 +386,9 @@ const isImage = computed(() => props.message.type === ImMessageType.IMAGE)
const isFile = computed(() => props.message.type === ImMessageType.FILE)
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
const isCard = computed(() => props.message.type === ImMessageType.CARD)
/** 引用对象:气泡内嵌入展示;非引用消息返回 null模板 v-if 不渲染 */
const quote = computed(() => getQuoteFromMessage(props.message.content))
/** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */
const showSenderName = computed(() => {
if (props.message.selfSend) {
return false
}
return conversationStore.activeConversation?.type === ImConversationType.GROUP
})
// conversation.avatar openConversation
const senderAvatar = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || props.message.selfSend) {
return ''
}
if (conversation.type === ImConversationType.GROUP) {
const group = groupStore.getGroup(conversation.targetId)
return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
}
return conversation.avatar || ''
})
// TODO @AI message.ts
/**
* 时间分隔线文案
* - 今天HH:mm
@ -382,17 +423,43 @@ function formatTipTime(timestamp: number): string {
return `${pad(messageDate.getMonth() + 1)}-${pad(messageDate.getDate())} ${hourMinute}`
}
// ==================== / / 广 ====================
// tip +
/** 是否已撤回pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
/** 撤回提示文案buildRecallTip 实时算 sender 名(按 conversation 上下文走 WeChat 优先级) */
const recallTip = computed(() => {
const conversation = conversationStore.activeConversation
return buildRecallTip(
props.message.senderId,
props.message.selfSend,
conversation?.type ?? 0,
conversation?.targetId ?? 0
)
})
/** 是否会话内好友事件气泡FRIEND_ADD / FRIEND_DELETE */
const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type))
/** 好友事件文案FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示,文案固定 */
const friendChatTipText = computed(() => resolveFriendNotificationText(props.message))
/** 是否群广播事件GROUP_CREATE..GROUP_BANNED 段位,排除 GROUP_MEMBER_SETTING_UPDATE 个人信号) */
const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))
/** 群广播事件文案:按 type 拼装(成员加入 / 退群 / 公告变更等) */
const groupNotificationText = computed(() => resolveGroupNotificationText(props.message))
// ==================== / payload ====================
/** 文本内容 */
const textContent = computed(() => parseMessage<TextMessage>(props.message.content)?.content ?? '')
/** 好友会话事件文案FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示,文案固定 */
const friendChatTipText = computed(() => resolveFriendNotificationText(props.message))
/** 引用对象:气泡内嵌入展示;非引用消息返回 null模板 v-if 不渲染 */
const quote = computed(() => getQuoteFromMessage(props.message.content))
/** 群广播事件 */
const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))
const groupNotificationText = computed(() => resolveGroupNotificationText(props.message))
const imagePayload = computed(() =>
isImage.value ? parseMessage<ImageMessage>(props.message.content) : null
)
@ -405,13 +472,42 @@ const voicePayload = computed(() =>
const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.message.content) : null
)
const cardPayload = computed(() =>
isCard.value ? parseMessage<CardMessage>(props.message.content) : null
)
/** 名片点击:弹被推荐用户的 UserInfoCard陌生人名片走「名片」加好友来源 */
function handleCardClick(e: MouseEvent) {
const card = cardPayload.value
if (!card?.userId) {
return
}
uiStore.openUserInfoCard(
{
id: card.userId,
nickname: card.nickname,
avatar: card.avatar
},
{ x: e.clientX + 20, y: e.clientY },
ImFriendAddSource.CARD
)
}
/** 媒体消息上传中:插入占位时 uploadProgress=0ack 成功后被清成 undefined */
const isUploading = computed(() => props.message.uploadProgress != null)
/** 上传进度0-100undefined 兜底为 0 避免遮罩文案出现 NaN */
const uploadProgress = computed(() => props.message.uploadProgress ?? 0)
/** 上传进度文案;图片/视频遮罩、文件进度条尾巴共用 */
const uploadProgressText = computed(() => `${uploadProgress.value}%`)
/** 文件类型图标 + 配色:按扩展名分发,跟 ReplyPreview 共用 getFileIconInfo */
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
/** 文件点击 → 新窗口下载 */
/** 文件点击 → 新窗口下载上传中点击无意义url 还是 blob直接跳过 */
function handleFileClick() {
if (!filePayload.value?.url) {
if (isUploading.value || !filePayload.value?.url) {
return
}
window.open(filePayload.value.url, '_blank')
@ -451,15 +547,25 @@ onBeforeUnmount(() => {
// ==================== / / @ ====================
// buildRecallTip sender conversation WeChat
const recallTip = computed(() => {
/** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */
const showSenderName = computed(() => {
if (props.message.selfSend) {
return false
}
return conversationStore.activeConversation?.type === ImConversationType.GROUP
})
/** 发送者头像;私聊的 conversation.avatar 就是对方头像openConversation 入参约定) */
const senderAvatar = computed(() => {
const conversation = conversationStore.activeConversation
return buildRecallTip(
props.message.senderId,
props.message.selfSend,
conversation?.type ?? 0,
conversation?.targetId ?? 0
)
if (!conversation || props.message.selfSend) {
return ''
}
if (conversation.type === ImConversationType.GROUP) {
const group = groupStore.getGroup(conversation.targetId)
return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
}
return conversation.avatar || ''
})
/** 头像色卡 fallback 文本:永远是真实昵称,不掺备注 */
@ -598,7 +704,9 @@ async function handleContextMenu(e: MouseEvent) {
}
// / / + +
if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
const senderMember = currentGroup.value.members?.find(
(m) => m.userId === props.message.senderId
)
const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date()
if (isMuted) {
items.push({
@ -750,8 +858,13 @@ async function handleRecall() {
}
/**
* 失败消息点击重试先把 FAILED 的本地占位消息从列表里去掉再用同样的 type + content 走一遍 sendRaw
* 后者会新建 clientMessageId 并重新跑乐观更新流程
* 失败消息点击重试
*
* - 媒体消息image / file / voice / video_localFile 在内存就重走 uploadAndSendMedia重新上传 + 占位 + 进度
* - 文本消息移除 FAILED 占位 + 用原 content 走一遍 sendRaw 新建占位
*
* 媒体类型若 _localFile 已丢理论上 IDB 恢复阶段就被 drop进不到这里保险起见仍走文本兜底则按 sendRaw 重发
* 后端拒绝失效 blob URL 时再次 FAILED用户可右键删除
*
* 不还原原 receipt群回执是发送时的扩展选项不会持久化到 message强行猜测可能与原意不符
* 默认按"无回执"重发绝大多数场景符合预期要回执就重新发一次更直观
@ -768,12 +881,74 @@ async function handleResend() {
if (muteOverlay.value) {
return
}
const message = props.message
const file = message._localFile
// + _localFile uploadAndSendMedia type buildPayload content
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>({
file,
type: ImMessageType.IMAGE,
kind: '图片',
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
})
})
}
return
}
// / _localFile content sendRaw
conversationStore.removeMessage(conversation.type, conversation.targetId, {
id: props.message.id,
clientMessageId: props.message.clientMessageId
id: message.id,
clientMessageId: message.clientMessageId
})
await sendRaw(props.message.type, props.message.content, {
atUserIds: props.message.atUserIds
await sendRaw(message.type, message.content, {
atUserIds: message.atUserIds
})
}

View File

@ -8,10 +8,11 @@ import {
ImMessageStatus,
IM_AT_ALL_USER_ID,
isGroupNotification,
isMediaMessageType,
isNormalMessage
} from '../../utils/constants'
import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage'
import { parseRecallMessageId } from '../../utils/message'
import { parseRecallMessageId, revokeBlobUrlsInContent } from '../../utils/message'
import { resolveConversationLastContent } from '../../utils/conversation'
import { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
@ -140,15 +141,23 @@ export const useConversationStore = defineStore('imConversationStore', {
// 单会话失败时退化为空消息列表 + 打印日志,避免拖垮整体加载
const tasks = meta.conversations.map(async (conversation): Promise<Conversation> => {
try {
const messages =
const rawMessages =
(await imStorage.getItem<Message[]>(
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId)
)) || []
// 发送中状态的消息标记为失败:重启后不可能仍处在发送中
messages.forEach((message) => {
// 【媒体消息】IMAGE / FILE / VOICE / VIDEOSENDING 或 FAILED 都直接 drop
// _localFile 持久化时已被剥掉,刷新后重传必拿不到 filecontent 里又可能是失效的 blob URL留着只会让用户点重试时把 blob URL 当真实 url 发到服务端
// 【文本类】SENDING 转 FAILED 仍可重发content 里就是 plain text
const messages = rawMessages.filter((message) => {
const isMedia = isMediaMessageType(message.type)
if (message.status === ImMessageStatus.SENDING) {
if (isMedia) {
return false
}
message.status = ImMessageStatus.FAILED
return true
}
return !(message.status === ImMessageStatus.FAILED && isMedia);
})
return { ...conversation, messages }
} catch (e) {
@ -200,11 +209,19 @@ export const useConversationStore = defineStore('imConversationStore', {
Array.isArray(target) ? target : target ? [target] : []
).filter((c) => !c.deleted)
for (const conversation of conversationsToFlush) {
// toRaw 拆掉 Vue reactive ProxyIDB 的 structuredClone 不接受 Proxy不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去messages 永远丢)
// ① toRaw 拆掉 Vue reactive ProxyIDB 的 structuredClone 不接受 Proxy不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去messages 永远丢)
// ② 剥掉 _localFileIDB 能 structuredClone File 对象,但视频几百 MB 落盘没意义,刷新后媒体 SENDING / FAILED 重传也走不通
const messagesForFlush = toRaw(conversation.messages).map((message) => {
if (message._localFile == null) {
return message
}
const { _localFile: _omitted, ...rest } = message
return rest
})
tasks.push(
imStorage.setItem(
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
toRaw(conversation.messages)
messagesForFlush
)
)
}
@ -498,13 +515,52 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!message) {
return
}
// 媒体消息 ack 服务端返回真实 url 时,旧 content 里的 blob URL 不再被渲染,立即释放对应 File 内存
if (updates.content && updates.content !== message.content) {
revokeBlobUrlsInContent(message.content)
}
Object.assign(message, updates)
// ① 状态离开 SENDING 后进度条没意义成功UNREAD/READ和失败FAILED都清掉 uploadProgress
// ② _localFile 区别对待FAILED 留着供重试 uploadAndSendMedia非 FAILED 终态可清
if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
message.uploadProgress = undefined
if (updates.status !== ImMessageStatus.FAILED) {
message._localFile = undefined
}
}
if (updates.id) {
this.updateMaxId(conversationType, updates.id)
}
this.saveConversations(conversation)
},
/**
*
*
* onUploadProgress patch uploadProgress patch content blob url
* IDB progress UI sendRaw ackMessage
*/
patchMessage(
conversationType: number,
targetId: number,
clientMessageId: string,
patch: Partial<Message>
) {
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return
}
const message = conversation.messages.find((item) => item.clientMessageId === clientMessageId)
if (!message) {
return
}
// 替换 content 时 revoke 旧 blob URL与 ackMessage 同语义
if (patch.content && patch.content !== message.content) {
revokeBlobUrlsInContent(message.content)
}
Object.assign(message, patch)
},
/**
* content`{"messageId": xxx}` RECALL +
* ConversationItem / MessageItem buildRecallTip
@ -643,6 +699,8 @@ export const useConversationStore = defineStore('imConversationStore', {
if (index < 0) {
return
}
// 媒体消息占位 / FAILED 删除时释放 content 里的 blob URL避免 File 对象内存泄漏
revokeBlobUrlsInContent(conversation.messages[index].content)
conversation.messages.splice(index, 1)
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引
if (index === conversation.messages.length) {

View File

@ -84,6 +84,10 @@ export interface Message {
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId与 Conversation.targetId 一致
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
uploadProgress?: number // 媒体消息上传进度0-100status=SENDING 期间持续更新ack 后置 undefined
// 媒体消息内存中保留的原始 File下划线前缀表示不进 JSON / 不持久化IDB 恢复后必为 undefined
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
_localFile?: File
}
/**