✨ 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
|
targetId?: number // 覆盖默认的 targetId
|
||||||
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
|
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
|
||||||
quote?: QuoteMessage
|
quote?: QuoteMessage
|
||||||
|
/**
|
||||||
|
* 复用已存在的本地占位消息 clientMessageId(媒体上传场景)
|
||||||
|
*
|
||||||
|
* 媒体上传链路在请求服务端前已经 insertMessage 了占位(带 blob URL + 进度条),
|
||||||
|
* 这里跳过 buildLocalMessage / insertMessage,直接拿这个 id 走 ackMessage 收尾,避免重复插入两条
|
||||||
|
*/
|
||||||
|
existingClientMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,22 +88,36 @@ export const useMessageSender = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 构造本地消息并乐观插入会话;状态先置 SENDING,请求结果回来由 ackMessage 更新
|
// 2. 准备 clientMessageId:媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id;其余场景走默认乐观插入
|
||||||
const clientMessageId = generateClientMessageId()
|
let clientMessageId: string
|
||||||
const message = buildLocalMessage({
|
if (options?.existingClientMessageId) {
|
||||||
clientMessageId,
|
clientMessageId = options.existingClientMessageId
|
||||||
content,
|
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
||||||
targetId: realTarget,
|
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
||||||
type,
|
const targetConversation = conversationStore.getConversation(conversation.type, realTarget)
|
||||||
atUserIds: options?.atUserIds
|
const stillExists = targetConversation?.messages.some(
|
||||||
})
|
(m) => m.clientMessageId === clientMessageId
|
||||||
const conversationInfo = {
|
)
|
||||||
type: conversation.type,
|
if (!stillExists) {
|
||||||
targetId: realTarget,
|
return
|
||||||
name: conversation.name || String(realTarget),
|
}
|
||||||
avatar: conversation.avatar || ''
|
} 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
|
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
<div
|
<div
|
||||||
v-if="muteOverlay"
|
v-if="muteOverlay"
|
||||||
class="message-input__mute-overlay"
|
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" />
|
<Icon :icon="muteOverlay.icon" :size="18" />
|
||||||
<span>{{ muteOverlay.text }}</span>
|
<span>{{ muteOverlay.text }}</span>
|
||||||
|
|
@ -17,7 +19,9 @@
|
||||||
内层白色圆角卡片 = editor + 工具栏;border + rounded 模拟微信"输入框"边界,
|
内层白色圆角卡片 = editor + 工具栏;border + rounded 模拟微信"输入框"边界,
|
||||||
避免之前"无框 Web 输入"的散开感;border 走 scoped CSS(UnoCSS 不带 border-style preflight)
|
避免之前"无框 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:输入区在上,操作在下)
|
输入区在上:contenteditable div(取代 textarea,对齐微信 PC:输入区在上,操作在下)
|
||||||
- 让 @ 浮层能拿到真实光标 rect(textarea 拿不到)
|
- 让 @ 浮层能拿到真实光标 rect(textarea 拿不到)
|
||||||
|
|
@ -26,7 +30,7 @@
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
ref="editorRef"
|
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"
|
contenteditable="true"
|
||||||
data-placeholder="按 Enter 发送,Shift+Enter 换行"
|
data-placeholder="按 Enter 发送,Shift+Enter 换行"
|
||||||
data-empty=""
|
data-empty=""
|
||||||
|
|
@ -53,7 +57,7 @@
|
||||||
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线(scoped CSS 避绕 UnoCSS preflight 缺失)
|
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线(scoped CSS 避绕 UnoCSS preflight 缺失)
|
||||||
-->
|
-->
|
||||||
<div
|
<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">
|
<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 { 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 { 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'
|
||||||
|
|
@ -184,6 +189,7 @@ import MentionPicker from './MentionPicker.vue'
|
||||||
import VoiceRecorder from './VoiceRecorder.vue'
|
import VoiceRecorder from './VoiceRecorder.vue'
|
||||||
import ReplyPreview from '../message/ReplyPreview.vue'
|
import ReplyPreview from '../message/ReplyPreview.vue'
|
||||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||||
|
import type { Conversation } from '@/views/im/home/types'
|
||||||
|
|
||||||
defineOptions({ name: 'ImMessageInput' })
|
defineOptions({ name: 'ImMessageInput' })
|
||||||
|
|
||||||
|
|
@ -191,7 +197,9 @@ const conversationStore = useConversationStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const draftStore = useDraftStore()
|
const draftStore = useDraftStore()
|
||||||
const { send, sendRaw } = useMessageSender()
|
const { send } = useMessageSender()
|
||||||
|
const { uploadAndSendMedia, insertMediaPlaceholder, markMediaFailed, commitMediaPlaceholder } =
|
||||||
|
useMediaUploader()
|
||||||
|
|
||||||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
||||||
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
|
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
|
||||||
|
|
@ -565,12 +573,6 @@ function consumeReply(): QuoteMessage | undefined {
|
||||||
return quote
|
return quote
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 抓当前激活会话的 key,媒体上传开始前调;无激活会话返回 undefined */
|
|
||||||
function getActiveConversationKey(): string | undefined {
|
|
||||||
const conversation = conversationStore.activeConversation
|
|
||||||
return conversation ? getConversationKey(conversation) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 校验当前激活会话仍是 startKey;切走了记日志 + 返回 false,调用方放弃发送 */
|
/** 校验当前激活会话仍是 startKey;切走了记日志 + 返回 false,调用方放弃发送 */
|
||||||
function isStillSameConversation(startKey: string, kind: string): boolean {
|
function isStillSameConversation(startKey: string, kind: string): boolean {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
|
|
@ -806,63 +808,51 @@ function onKeydown(e: KeyboardEvent) {
|
||||||
|
|
||||||
// ==================== 图片 / 文件 / 语音 上传 ====================
|
// ==================== 图片 / 文件 / 语音 上传 ====================
|
||||||
|
|
||||||
/**
|
/** 上传前的统一拦截:禁言态 / 无激活会话直接放弃;返回当前 conversation 与抓走的 quote */
|
||||||
* 媒体上传 → 发送的公共骨架(image / file / voice 共用;video 因 probe + 双上传链路保留独立)
|
function prepareMediaUpload(): { conversation: Conversation; quote?: QuoteMessage } | undefined {
|
||||||
*
|
|
||||||
* 禁言态直接返回;锁会话 key + 消费 reply → 上传 → 校验仍在原会话 → 拼 payload + quote → sendRaw
|
|
||||||
*/
|
|
||||||
async function uploadAndSendMedia<P extends { quote?: QuoteMessage }>(opts: {
|
|
||||||
file: File
|
|
||||||
type: number
|
|
||||||
kind: string
|
|
||||||
buildPayload: (url: string) => P
|
|
||||||
}) {
|
|
||||||
if (muteOverlay.value) {
|
if (muteOverlay.value) {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
const startKey = getActiveConversationKey()
|
const conversation = conversationStore.activeConversation
|
||||||
if (!startKey) {
|
if (!conversation) {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
const replyQuote = consumeReply()
|
return { conversation, quote: 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传并发送 IMAGE 消息 */
|
/** 上传并发送 IMAGE 消息(占位 + 进度 + 真实 url ack 由 useMediaUploader 处理) */
|
||||||
async function uploadAndSendImage(file: File) {
|
async function uploadAndSendImage(file: File) {
|
||||||
|
const context = prepareMediaUpload()
|
||||||
|
if (!context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await uploadAndSendMedia<ImageMessage>({
|
await uploadAndSendMedia<ImageMessage>({
|
||||||
file,
|
file,
|
||||||
type: ImMessageType.IMAGE,
|
type: ImMessageType.IMAGE,
|
||||||
kind: '图片',
|
kind: '图片',
|
||||||
|
quote: context.quote,
|
||||||
|
conversation: context.conversation,
|
||||||
buildPayload: (url) => ({ url })
|
buildPayload: (url) => ({ url })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
||||||
async function uploadAndSendFile(file: File) {
|
async function uploadAndSendFile(file: File) {
|
||||||
|
const context = prepareMediaUpload()
|
||||||
|
if (!context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await uploadAndSendMedia<FileMessage>({
|
await uploadAndSendMedia<FileMessage>({
|
||||||
file,
|
file,
|
||||||
type: ImMessageType.FILE,
|
type: ImMessageType.FILE,
|
||||||
kind: '文件',
|
kind: '文件',
|
||||||
|
quote: context.quote,
|
||||||
|
conversation: context.conversation,
|
||||||
buildPayload: (url) => ({ url, name: file.name, size: file.size })
|
buildPayload: (url) => ({ url, name: file.name, size: file.size })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,整体走 sendRaw) */
|
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,由 useMediaUploader 接管占位 / 进度 / ack) */
|
||||||
async function onImagePicked(e: Event) {
|
async function onImagePicked(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
|
|
@ -891,11 +881,17 @@ function openVoice() {
|
||||||
}
|
}
|
||||||
/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia */
|
/** VoiceRecorder 录完回传 blob,包成 webm File 后走通用 uploadAndSendMedia */
|
||||||
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
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 })
|
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||||
await uploadAndSendMedia<AudioMessage>({
|
await uploadAndSendMedia<AudioMessage>({
|
||||||
file,
|
file,
|
||||||
type: ImMessageType.VOICE,
|
type: ImMessageType.VOICE,
|
||||||
kind: '语音',
|
kind: '语音',
|
||||||
|
quote: context.quote,
|
||||||
|
conversation: context.conversation,
|
||||||
buildPayload: (url) => ({ url, duration: payload.duration })
|
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))
|
const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
|
||||||
canvas.width = Math.round(video.videoWidth * ratio)
|
canvas.width = Math.round(video.videoWidth * ratio)
|
||||||
canvas.height = Math.round(video.videoHeight * ratio)
|
canvas.height = Math.round(video.videoHeight * ratio)
|
||||||
const ctx = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
if (ctx && canvas.width && canvas.height) {
|
if (context && canvas.width && canvas.height) {
|
||||||
// 2.4 当前帧绘到 canvas → toBlob 拿 jpeg;0.8 质量是聊天封面常用甜点
|
// 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 =
|
cover =
|
||||||
(await new Promise<Blob | null>((resolve) =>
|
(await new Promise<Blob | null>((resolve) =>
|
||||||
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
|
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
|
||||||
|
|
@ -996,31 +992,51 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
|
||||||
/**
|
/**
|
||||||
* 上传并发送 VIDEO 消息
|
* 上传并发送 VIDEO 消息
|
||||||
*
|
*
|
||||||
* 1. probe 与视频上传同步起跑;封面上传等 probe 出 cover 后与视频上传竞速
|
* 1. 立即占位:blob URL 既当 video src 也当 poster,浏览器会拿首帧绘制 cover;status=SENDING + 进度条
|
||||||
|
* 2. probe 与视频上传同步起跑;封面上传等 probe 出 cover 后与视频上传竞速
|
||||||
* (probe 解码 + 封面上传通常被视频上传时长完全遮蔽,体感节省几百 ms 起步)
|
* (probe 解码 + 封面上传通常被视频上传时长完全遮蔽,体感节省几百 ms 起步)
|
||||||
* 2. 视频本体上传必须成功,拿不到 url 就直接 return
|
* 3. 视频本体上传必须成功,拿不到 url 就把占位置 FAILED;封面是锦上添花,失败仅日志
|
||||||
* 3. 封面是锦上添花:上传失败仅日志,coverUrl 留空,气泡 <video> 自带黑底播放按钮兜底
|
* 4. 视频链路耗时长,上传期间用户切会话则放弃发送(避免落到错误会话里);切走再切回来不算变化(key 仍相等)
|
||||||
*
|
|
||||||
* 视频链路耗时长(probe + 双上传),上传期间用户切会话则放弃发送,
|
|
||||||
* 否则会落到错误的会话里;切走再切回来不算变化(key 仍相等)。
|
|
||||||
*/
|
*/
|
||||||
async function uploadAndSendVideo(file: File) {
|
async function uploadAndSendVideo(file: File) {
|
||||||
if (muteOverlay.value) {
|
const context = prepareMediaUpload()
|
||||||
|
if (!context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 1. 锁定起始会话 key(上传期间用户切走则不发到错误目标;切走再切回来 key 仍相等,不算变化)
|
const { conversation } = context
|
||||||
const startKey = getActiveConversationKey()
|
const replyQuote = context.quote
|
||||||
if (!startKey) {
|
const startKey = getConversationKey(conversation)
|
||||||
return
|
|
||||||
}
|
// 1. 立即占位:blob URL 同时作 url + coverUrl 让 <video> 渲染首帧;_localFile 留 file 供失败重试
|
||||||
// quote 抓取后立即清 draft.reply,与图片 / 文件 / 语音上传链路一致
|
const buildPlaceholderContent = (blobUrl: string): string =>
|
||||||
const replyQuote = consumeReply()
|
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. 三路并行起跑(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()
|
const videoForm = new FormData()
|
||||||
videoForm.append('file', file)
|
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)
|
console.warn('[IM] 视频本体上传失败', e)
|
||||||
return { data: undefined as string | undefined }
|
return { data: undefined as string | undefined }
|
||||||
})
|
})
|
||||||
|
|
@ -1054,36 +1070,46 @@ async function uploadAndSendVideo(file: File) {
|
||||||
videoUploadPromise,
|
videoUploadPromise,
|
||||||
coverUploadPromise
|
coverUploadPromise
|
||||||
])
|
])
|
||||||
// 3.2 视频本体没 url 直接放弃(封面也不再有意义)
|
// 3.2 视频本体没 url:占位置 FAILED,让用户决定重试 / 删除(_localFile 在内存里)
|
||||||
const url = videoRes?.data
|
const url = videoRes?.data
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 3.3 校验会话仍是发送时锁定的那个(视频链路耗时长,这个窗口很实际)
|
// 3.3 校验会话仍是发送时锁定的那个(视频链路耗时长,这个窗口很实际)
|
||||||
if (!isStillSameConversation(startKey, '视频')) {
|
if (!isStillSameConversation(startKey, '视频')) {
|
||||||
|
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 3.4 视频上传期间被禁言也要拦:链路最长,最容易踩到 muteOverlay 期间触发
|
// 3.4 视频上传期间被禁言也要拦:链路最长,最容易踩到 muteOverlay 期间触发
|
||||||
if (muteOverlay.value) {
|
if (muteOverlay.value) {
|
||||||
|
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 拼 VideoMessage payload 走通用 sendRaw(与图片 / 文件 / 语音同链路)
|
// 4. 拼真实 VideoMessage payload,patch 进占位 + 走 sendRaw 复用占位发送
|
||||||
const videoPayload = withQuotePayload<VideoMessage>(
|
const realContent = serializeMessage(
|
||||||
{
|
withQuotePayload<VideoMessage>(
|
||||||
url,
|
{
|
||||||
coverUrl,
|
url,
|
||||||
duration: probe.duration,
|
coverUrl,
|
||||||
width: probe.width,
|
duration: probe.duration,
|
||||||
height: probe.height,
|
width: probe.width,
|
||||||
size: file.size
|
height: probe.height,
|
||||||
},
|
size: file.size
|
||||||
replyQuote
|
},
|
||||||
|
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) {
|
async function onVideoPicked(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
|
|
@ -1095,15 +1121,6 @@ async function onVideoPicked(e: Event) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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}
|
/* el-icon 全局规则 .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em}
|
||||||
会盖过 UnoCSS 原子类;用字面选择器 + !important 兜底。
|
会盖过 UnoCSS 原子类;用字面选择器 + !important 兜底。
|
||||||
颜色取 Element Plus 主题变量,暗色自动切到浅灰 */
|
颜色取 Element Plus 主题变量,暗色自动切到浅灰 */
|
||||||
|
|
@ -1118,21 +1135,6 @@ async function onVideoPicked(e: Event) {
|
||||||
color: var(--el-color-primary) !important;
|
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 维护 */
|
/* 用 data-empty 而非 :empty:浏览器在删空后会留下 <br>,:empty 不命中;data-empty 由 syncEditorState 维护 */
|
||||||
.message-input__editor[data-empty]::before {
|
.message-input__editor[data-empty]::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
|
|
|
||||||
|
|
@ -69,24 +69,32 @@
|
||||||
>
|
>
|
||||||
{{ textContent }}
|
{{ textContent }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 图片消息:点击大图预览,由 <el-image> 自身承接 -->
|
<!-- 图片消息:点击大图预览,由 <el-image> 自身承接;上传中时套一层半透明遮罩显示进度 -->
|
||||||
<el-image
|
<div v-else-if="isImage && imagePayload" class="relative inline-block">
|
||||||
v-else-if="isImage && imagePayload"
|
<el-image
|
||||||
class="max-w-[220px] rounded cursor-zoom-in"
|
class="max-w-[220px] rounded cursor-zoom-in"
|
||||||
:src="imagePayload.thumbnailUrl || imagePayload.url"
|
:src="imagePayload.thumbnailUrl || imagePayload.url"
|
||||||
:preview-src-list="[imagePayload.url]"
|
:preview-src-list="isUploading ? [] : [imagePayload.url]"
|
||||||
:preview-teleported="true"
|
:preview-teleported="true"
|
||||||
fit="contain"
|
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
|
<div
|
||||||
v-else-if="isFile && filePayload"
|
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="[
|
:class="[
|
||||||
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||||
message.selfSend
|
message.selfSend
|
||||||
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
|
? '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"
|
@click="handleFileClick"
|
||||||
>
|
>
|
||||||
|
|
@ -99,6 +107,18 @@
|
||||||
<div class="mt-1 text-12px text-[var(--el-text-color-secondary)]">
|
<div class="mt-1 text-12px text-[var(--el-text-color-secondary)]">
|
||||||
{{ formatFileSize(filePayload.size) }}
|
{{ formatFileSize(filePayload.size) }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Icon
|
<Icon
|
||||||
:icon="fileIconInfo.icon"
|
:icon="fileIconInfo.icon"
|
||||||
|
|
@ -128,15 +148,22 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 视频消息:直接用原生 <video controls> 内嵌播放,poster 取后端给的封面图;
|
<!-- 视频消息:直接用原生 <video controls> 内嵌播放,poster 取后端给的封面图;
|
||||||
不接入第三方播放器、不重写 UI,保持和图片 / 文件分支一样的轻量观感 -->
|
不接入第三方播放器、不重写 UI,保持和图片 / 文件分支一样的轻量观感;上传中半透明遮罩显示进度 -->
|
||||||
<video
|
<div v-else-if="isVideo && videoPayload?.url" class="relative inline-block">
|
||||||
v-else-if="isVideo && videoPayload?.url"
|
<video
|
||||||
class="max-w-[280px] max-h-[320px] rounded bg-black"
|
class="max-w-[280px] max-h-[320px] rounded bg-black"
|
||||||
:src="videoPayload.url"
|
:src="videoPayload.url"
|
||||||
:poster="videoPayload.coverUrl"
|
:poster="videoPayload.coverUrl"
|
||||||
controls
|
:controls="!isUploading"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
></video>
|
></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:降级展示,避免出现裸的 [视频消息] -->
|
<!-- 视频消息但 payload 解析失败 / 没 url:降级展示,避免出现裸的 [视频消息] -->
|
||||||
<div
|
<div
|
||||||
v-else-if="isVideo"
|
v-else-if="isVideo"
|
||||||
|
|
@ -144,6 +171,42 @@
|
||||||
>
|
>
|
||||||
[视频消息]
|
[视频消息]
|
||||||
</div>
|
</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
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -155,8 +218,9 @@
|
||||||
<!-- 状态区:自己消息展示发送状态 + 已读/群回执;对方消息 + @自己时展示 @徽标 -->
|
<!-- 状态区:自己消息展示发送状态 + 已读/群回执;对方消息 + @自己时展示 @徽标 -->
|
||||||
<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"
|
v-if="message.status === ImMessageStatus.SENDING && !isUploading"
|
||||||
icon="ant-design:loading-outlined"
|
icon="ant-design:loading-outlined"
|
||||||
class="im-loading-spin"
|
class="im-loading-spin"
|
||||||
/>
|
/>
|
||||||
|
|
@ -225,10 +289,12 @@ import {
|
||||||
ImMessageStatus,
|
ImMessageStatus,
|
||||||
ImGroupReceiptStatus,
|
ImGroupReceiptStatus,
|
||||||
ImConversationType,
|
ImConversationType,
|
||||||
|
ImFriendAddSource,
|
||||||
ImGroupMemberRole,
|
ImGroupMemberRole,
|
||||||
TIME_TIP_GAP_MS,
|
TIME_TIP_GAP_MS,
|
||||||
isFriendChatTip,
|
isFriendChatTip,
|
||||||
isGroupNotification,
|
isGroupNotification,
|
||||||
|
isMediaMessageType,
|
||||||
isNormalMessage
|
isNormalMessage
|
||||||
} from '@/views/im/utils/constants'
|
} from '@/views/im/utils/constants'
|
||||||
import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/im/group'
|
import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/im/group'
|
||||||
|
|
@ -242,7 +308,8 @@ import {
|
||||||
type ImageMessage,
|
type ImageMessage,
|
||||||
type FileMessage,
|
type FileMessage,
|
||||||
type AudioMessage,
|
type AudioMessage,
|
||||||
type VideoMessage
|
type VideoMessage,
|
||||||
|
type CardMessage
|
||||||
} from '@/views/im/utils/message'
|
} from '@/views/im/utils/message'
|
||||||
import { buildRecallTip } from '../../../../../utils/conversation'
|
import { buildRecallTip } from '../../../../../utils/conversation'
|
||||||
import { formatSeconds } from '@/utils/formatTime'
|
import { formatSeconds } from '@/utils/formatTime'
|
||||||
|
|
@ -261,6 +328,7 @@ import {
|
||||||
} from '@/views/im/utils/user'
|
} from '@/views/im/utils/user'
|
||||||
import { useImUiStore } from '../../../../store/uiStore'
|
import { useImUiStore } from '../../../../store/uiStore'
|
||||||
import { useMessageSender } from '../../../../composables/useMessageSender'
|
import { useMessageSender } from '../../../../composables/useMessageSender'
|
||||||
|
import { useMediaUploader } from '../../../../composables/useMediaUploader'
|
||||||
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
||||||
import type { Message } from '../../../../types'
|
import type { Message } from '../../../../types'
|
||||||
import MessageReadStatus from './MessageReadStatus.vue'
|
import MessageReadStatus from './MessageReadStatus.vue'
|
||||||
|
|
@ -294,18 +362,13 @@ const friendStore = useFriendStore()
|
||||||
const draftStore = useDraftStore()
|
const draftStore = useDraftStore()
|
||||||
const uiStore = useImUiStore()
|
const uiStore = useImUiStore()
|
||||||
const { recall, sendRaw } = useMessageSender()
|
const { recall, sendRaw } = useMessageSender()
|
||||||
|
const { uploadAndSendMedia } = useMediaUploader()
|
||||||
const muteOverlay = useMuteOverlay()
|
const muteOverlay = useMuteOverlay()
|
||||||
// 仅用 confirm,避免 message 跟 props.message 同名冲突(vue/no-dupe-keys)
|
// 仅用 confirm,避免 message 跟 props.message 同名冲突(vue/no-dupe-keys)
|
||||||
const { confirm: confirmDialog, success: successMessage } = useMessage()
|
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 不渲染 */
|
/** 是否在当前消息上方渲染时间分隔条:列表第一条 / 距上一条超过阈值;缺 sendTime 不渲染 */
|
||||||
const shouldShowTimeTip = computed(() => {
|
const shouldShowTimeTip = computed(() => {
|
||||||
if (!props.message.sendTime) {
|
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 isFile = computed(() => props.message.type === ImMessageType.FILE)
|
||||||
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
|
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
|
||||||
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
|
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
|
||||||
|
const isCard = computed(() => props.message.type === ImMessageType.CARD)
|
||||||
|
|
||||||
/** 引用对象:气泡内嵌入展示;非引用消息返回 null,模板 v-if 不渲染 */
|
// TODO @AI:抽到 message.ts 里?作为一个工具方法?
|
||||||
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 || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 时间分隔线文案:
|
* 时间分隔线文案:
|
||||||
* - 今天:HH:mm
|
* - 今天:HH:mm
|
||||||
|
|
@ -382,17 +423,43 @@ function formatTipTime(timestamp: number): string {
|
||||||
return `${pad(messageDate.getMonth() + 1)}-${pad(messageDate.getDate())} ${hourMinute}`
|
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 ====================
|
// ==================== 消息内容解析 / payload ====================
|
||||||
|
|
||||||
/** 文本内容 */
|
/** 文本内容 */
|
||||||
const textContent = computed(() => parseMessage<TextMessage>(props.message.content)?.content ?? '')
|
const textContent = computed(() => parseMessage<TextMessage>(props.message.content)?.content ?? '')
|
||||||
|
|
||||||
/** 好友会话事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示,文案固定 */
|
/** 引用对象:气泡内嵌入展示;非引用消息返回 null,模板 v-if 不渲染 */
|
||||||
const friendChatTipText = computed(() => resolveFriendNotificationText(props.message))
|
const quote = computed(() => getQuoteFromMessage(props.message.content))
|
||||||
|
|
||||||
/** 群广播事件 */
|
|
||||||
const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))
|
|
||||||
const groupNotificationText = computed(() => resolveGroupNotificationText(props.message))
|
|
||||||
const imagePayload = computed(() =>
|
const imagePayload = computed(() =>
|
||||||
isImage.value ? parseMessage<ImageMessage>(props.message.content) : null
|
isImage.value ? parseMessage<ImageMessage>(props.message.content) : null
|
||||||
)
|
)
|
||||||
|
|
@ -405,13 +472,42 @@ const voicePayload = computed(() =>
|
||||||
const videoPayload = computed(() =>
|
const videoPayload = computed(() =>
|
||||||
isVideo.value ? parseMessage<VideoMessage>(props.message.content) : null
|
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 */
|
/** 文件类型图标 + 配色:按扩展名分发,跟 ReplyPreview 共用 getFileIconInfo */
|
||||||
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
|
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
|
||||||
|
|
||||||
/** 文件点击 → 新窗口下载 */
|
/** 文件点击 → 新窗口下载;上传中点击无意义(url 还是 blob),直接跳过 */
|
||||||
function handleFileClick() {
|
function handleFileClick() {
|
||||||
if (!filePayload.value?.url) {
|
if (isUploading.value || !filePayload.value?.url) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.open(filePayload.value.url, '_blank')
|
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
|
const conversation = conversationStore.activeConversation
|
||||||
return buildRecallTip(
|
if (!conversation || props.message.selfSend) {
|
||||||
props.message.senderId,
|
return ''
|
||||||
props.message.selfSend,
|
}
|
||||||
conversation?.type ?? 0,
|
if (conversation.type === ImConversationType.GROUP) {
|
||||||
conversation?.targetId ?? 0
|
const group = groupStore.getGroup(conversation.targetId)
|
||||||
)
|
return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
|
||||||
|
}
|
||||||
|
return conversation.avatar || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 头像色卡 fallback 文本:永远是真实昵称,不掺备注 */
|
/** 头像色卡 fallback 文本:永远是真实昵称,不掺备注 */
|
||||||
|
|
@ -598,7 +704,9 @@ async function handleContextMenu(e: MouseEvent) {
|
||||||
}
|
}
|
||||||
// 「禁言 / 解禁 / 移除」:群聊 + 非自己消息 + 我是群主或管理员
|
// 「禁言 / 解禁 / 移除」:群聊 + 非自己消息 + 我是群主或管理员
|
||||||
if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
|
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()
|
const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date()
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
items.push({
|
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,强行猜测可能与原意不符;
|
* 不还原原 receipt:群回执是发送时的扩展选项、不会持久化到 message,强行猜测可能与原意不符;
|
||||||
* 默认按"无回执"重发,绝大多数场景符合预期,要回执就重新发一次更直观
|
* 默认按"无回执"重发,绝大多数场景符合预期,要回执就重新发一次更直观
|
||||||
|
|
@ -768,12 +881,74 @@ async function handleResend() {
|
||||||
if (muteOverlay.value) {
|
if (muteOverlay.value) {
|
||||||
return
|
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, {
|
conversationStore.removeMessage(conversation.type, conversation.targetId, {
|
||||||
id: props.message.id,
|
id: message.id,
|
||||||
clientMessageId: props.message.clientMessageId
|
clientMessageId: message.clientMessageId
|
||||||
})
|
})
|
||||||
await sendRaw(props.message.type, props.message.content, {
|
await sendRaw(message.type, message.content, {
|
||||||
atUserIds: props.message.atUserIds
|
atUserIds: message.atUserIds
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ import {
|
||||||
ImMessageStatus,
|
ImMessageStatus,
|
||||||
IM_AT_ALL_USER_ID,
|
IM_AT_ALL_USER_ID,
|
||||||
isGroupNotification,
|
isGroupNotification,
|
||||||
|
isMediaMessageType,
|
||||||
isNormalMessage
|
isNormalMessage
|
||||||
} from '../../utils/constants'
|
} from '../../utils/constants'
|
||||||
import { getCurrentUserId, imStorage, removeQuietly, StorageKeys } from '../../utils/storage'
|
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 { resolveConversationLastContent } from '../../utils/conversation'
|
||||||
import { tryGetSenderDisplayName } from '../../utils/user'
|
import { tryGetSenderDisplayName } from '../../utils/user'
|
||||||
import { useGroupStore } from './groupStore'
|
import { useGroupStore } from './groupStore'
|
||||||
|
|
@ -140,15 +141,23 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
// 单会话失败时退化为空消息列表 + 打印日志,避免拖垮整体加载
|
// 单会话失败时退化为空消息列表 + 打印日志,避免拖垮整体加载
|
||||||
const tasks = meta.conversations.map(async (conversation): Promise<Conversation> => {
|
const tasks = meta.conversations.map(async (conversation): Promise<Conversation> => {
|
||||||
try {
|
try {
|
||||||
const messages =
|
const rawMessages =
|
||||||
(await imStorage.getItem<Message[]>(
|
(await imStorage.getItem<Message[]>(
|
||||||
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId)
|
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId)
|
||||||
)) || []
|
)) || []
|
||||||
// 发送中状态的消息标记为失败:重启后不可能仍处在发送中
|
// 【媒体消息】(IMAGE / FILE / VOICE / VIDEO)SENDING 或 FAILED 都直接 drop:
|
||||||
messages.forEach((message) => {
|
// _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 (message.status === ImMessageStatus.SENDING) {
|
||||||
|
if (isMedia) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
message.status = ImMessageStatus.FAILED
|
message.status = ImMessageStatus.FAILED
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return !(message.status === ImMessageStatus.FAILED && isMedia);
|
||||||
})
|
})
|
||||||
return { ...conversation, messages }
|
return { ...conversation, messages }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -200,11 +209,19 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
Array.isArray(target) ? target : target ? [target] : []
|
Array.isArray(target) ? target : target ? [target] : []
|
||||||
).filter((c) => !c.deleted)
|
).filter((c) => !c.deleted)
|
||||||
for (const conversation of conversationsToFlush) {
|
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(
|
tasks.push(
|
||||||
imStorage.setItem(
|
imStorage.setItem(
|
||||||
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
|
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
|
||||||
toRaw(conversation.messages)
|
messagesForFlush
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -498,13 +515,52 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 媒体消息 ack 服务端返回真实 url 时,旧 content 里的 blob URL 不再被渲染,立即释放对应 File 内存
|
||||||
|
if (updates.content && updates.content !== message.content) {
|
||||||
|
revokeBlobUrlsInContent(message.content)
|
||||||
|
}
|
||||||
Object.assign(message, updates)
|
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) {
|
if (updates.id) {
|
||||||
this.updateMaxId(conversationType, updates.id)
|
this.updateMaxId(conversationType, updates.id)
|
||||||
}
|
}
|
||||||
this.saveConversations(conversation)
|
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 态 + 刷新会话摘要
|
* 撤回消息:解析撤回信号 content(`{"messageId": xxx}`),找到原消息更新为 RECALL 态 + 刷新会话摘要
|
||||||
* 撤回提示文案不固化,由 ConversationItem / MessageItem 渲染时调 buildRecallTip 实时算
|
* 撤回提示文案不固化,由 ConversationItem / MessageItem 渲染时调 buildRecallTip 实时算
|
||||||
|
|
@ -643,6 +699,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 媒体消息占位 / FAILED 删除时释放 content 里的 blob URL,避免 File 对象内存泄漏
|
||||||
|
revokeBlobUrlsInContent(conversation.messages[index].content)
|
||||||
conversation.messages.splice(index, 1)
|
conversation.messages.splice(index, 1)
|
||||||
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引
|
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引
|
||||||
if (index === conversation.messages.length) {
|
if (index === conversation.messages.length) {
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@ export interface Message {
|
||||||
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
|
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
|
||||||
targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId),与 Conversation.targetId 一致
|
targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId),与 Conversation.targetId 一致
|
||||||
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
|
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
|
||||||
|
uploadProgress?: number // 媒体消息上传进度(0-100);status=SENDING 期间持续更新;ack 后置 undefined
|
||||||
|
// 媒体消息内存中保留的原始 File;下划线前缀表示不进 JSON / 不持久化(IDB 恢复后必为 undefined)
|
||||||
|
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
|
||||||
|
_localFile?: File
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue