✨ feat(im): 增加发送中的能力,针对图片、文件、视频等
parent
f8cc9d14d9
commit
8f2eddea4a
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体上传 + 发送 composable(image / file / voice / video 共用底层 helper)
|
||||
*
|
||||
* 与 useMessageSender.sendRaw 的「先发请求再 ack」不同,媒体链路必须「先占位再上传」:
|
||||
* 1. 立即 insertMessage 写入占位消息(status=SENDING、content 用 blob URL、_localFile 内存留 File)
|
||||
* 2. updateFile 上传,onUploadProgress 回调 patchMessage 更新 uploadProgress;UI 实时显示进度条
|
||||
* 3. 上传成功后用真实 url 重生 content,patchMessage 替换;旧 blob URL 由 store 自动 revoke
|
||||
* 4. 走 sendRaw(existingClientMessageId) 复用占位发送请求,避免重复插入两条
|
||||
*
|
||||
* 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_localFile 还在内存就行)
|
||||
*/
|
||||
export const useMediaUploader = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const userStore = useUserStore()
|
||||
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: {
|
||||
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 复用 existingClientMessageId;store 内部 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 }
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 CSS(UnoCSS 不带 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:输入区在上,操作在下)
|
||||
- 让 @ 浮层能拿到真实光标 rect(textarea 拿不到)
|
||||
|
|
@ -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 拿 jpeg;0.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,浏览器会拿首帧绘制 cover;status=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 payload,patch 进占位 + 走 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);
|
||||
|
|
|
|||
|
|
@ -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 @AI:messagepreview 是不是也要加下?管理后台的;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=0,ack 成功后被清成 undefined */
|
||||
const isUploading = computed(() => props.message.uploadProgress != null)
|
||||
|
||||
/** 上传进度(0-100);undefined 兜底为 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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 / VIDEO)SENDING 或 FAILED 都直接 drop:
|
||||
// _localFile 持久化时已被剥掉,刷新后重传必拿不到 file,content 里又可能是失效的 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 Proxy:IDB 的 structuredClone 不接受 Proxy,不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢)
|
||||
// ① toRaw 拆掉 Vue reactive Proxy:IDB 的 structuredClone 不接受 Proxy,不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢)
|
||||
// ② 剥掉 _localFile:IDB 能 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) {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ export interface Message {
|
|||
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
|
||||
targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId),与 Conversation.targetId 一致
|
||||
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
|
||||
uploadProgress?: number // 媒体消息上传进度(0-100);status=SENDING 期间持续更新;ack 后置 undefined
|
||||
// 媒体消息内存中保留的原始 File;下划线前缀表示不进 JSON / 不持久化(IDB 恢复后必为 undefined)
|
||||
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
|
||||
_localFile?: File
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue