diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 51eb538fc..7b6a6b5f1 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -10,7 +10,13 @@ import { readGroupMessages as apiReadGroupMessages, recallGroupMessage as apiRecallGroupMessage } from '@/api/im/message/group' -import { generateClientMessageId, serializeMessage, type TextMessage } from '../../utils/message' +import { + generateClientMessageId, + serializeMessage, + withQuotePayload, + type QuoteMessage, + type TextMessage +} from '../../utils/message' import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants' import type { Message } from '../types' import { useUserStore } from '@/store/modules/user' @@ -20,6 +26,8 @@ interface SendExtOptions { atUserIds?: number[] // 群聊 @ 的用户编号列表 receipt?: boolean // 是否需要群回执(默认 false) targetId?: number // 覆盖默认的 targetId + /** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */ + quote?: QuoteMessage } /** @@ -102,7 +110,8 @@ export const useMessageSender = () => { conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { id: data.id, sendTime: new Date(data.sendTime).getTime(), - status: data.status + status: data.status, + content: data.content }) } else if (conversation.type === ImConversationType.GROUP) { const data = await apiSendGroupMessage({ @@ -118,7 +127,8 @@ export const useMessageSender = () => { sendTime: new Date(data.sendTime).getTime(), status: data.status, receiptStatus: data.receiptStatus, - readCount: data.readCount + readCount: data.readCount, + content: data.content }) } } catch (e) { @@ -134,7 +144,8 @@ export const useMessageSender = () => { if (!text.trim()) { return } - await sendRaw(ImMessageType.TEXT, serializeMessage({ content: text }), options) + const payload = withQuotePayload({ content: text }, options?.quote) + await sendRaw(ImMessageType.TEXT, serializeMessage(payload), options) } /** diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 36b2bb060..28be4595c 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -28,6 +28,15 @@ @paste.prevent="onPaste" > + + + - + @@ -159,12 +164,15 @@ import { type ImageMessage, type FileMessage, type AudioMessage, - type VideoMessage + type VideoMessage, + type QuoteMessage, + withQuotePayload } from '@/views/im/utils/message' import EmojiPicker from './EmojiPicker.vue' import MentionPicker from './MentionPicker.vue' import VoiceRecorder from './VoiceRecorder.vue' +import ReplyPreview from '../message/ReplyPreview.vue' import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' defineOptions({ name: 'ImMessageInput' }) @@ -216,8 +224,14 @@ function syncDraftToStore(editor: HTMLDivElement) { return } // collectFromEditor 已 trim,plain 为空时 store 内部按 clearDraft 处理 + // reply 透传当前快照:setDraft 是整对象替换,不读旧 reply 会让用户每敲一个键就把引用条擦掉 const { text } = collectFromEditor(editor) - draftStore.setDraft(conversation, { html: editor.innerHTML, plain: text }) + const existing = draftStore.getDraft(conversation) + draftStore.setDraft(conversation, { + html: editor.innerHTML, + plain: text, + reply: existing?.reply + }) } /** 切会话时把 store 里的草稿还原到 editor;只更 UI 不回写草稿,避免 store→editor→store 回流 */ @@ -313,7 +327,7 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number * 3. 二次防御:collectFromEditor 走 trim,可能比 syncEditorState 更严格(例如全 ZWSP),仍空就 return * 4. 清空 + 同步状态:先清 innerHTML 再 syncEditorState 让 placeholder / canSend 一起回归 * (顺序很重要:先清后 sync,否则 sync 看到旧内容会误判) - * 5. 上送:atUserIds 非空才传,避免发空数组 + * 5. 上送:atUserIds 非空才传,避免发空数组;quote 由 clearDraft 前抓取,确保引用条立即消失 */ async function handleSend(options?: { receipt?: boolean }) { const editor = editorRef.value @@ -324,8 +338,9 @@ async function handleSend(options?: { receipt?: boolean }) { if (!text) { return } - // 1. 清空 editor + 当前会话草稿;syncEditorState 后 plain 已为空,store 内部会自动清, - // 但显式 clearDraft 能立即同步、不依赖 debounce 时序,列表上的 [草稿] 立即消失 + // 1. 抓 quote 后清空 editor + 当前会话草稿(包含 reply);syncEditorState 后 plain / reply 都为空, + // store 内部会自动清,但显式 clearDraft 能立即同步、不依赖 debounce 时序,列表上的 [草稿] 立即消失 + const replyQuote = replyTarget.value editor.innerHTML = '' if (conversationStore.activeConversation) { draftStore.clearDraft(conversationStore.activeConversation) @@ -334,7 +349,8 @@ async function handleSend(options?: { receipt?: boolean }) { // 2. 发送 await send(text, { atUserIds: atUserIds.length > 0 ? atUserIds : undefined, - receipt: options?.receipt + receipt: options?.receipt, + quote: replyQuote }) } @@ -510,6 +526,26 @@ function onInput() { detectAtMention() } +// ==================== 引用 / 回复 ==================== + +/** 当前会话的「正在回复」对象,从 draftStore 派生(MessageItem 写、MessageInput 读) */ +const replyTarget = computed(() => { + const conversation = conversationStore.activeConversation + if (!conversation) { + return undefined + } + return draftStore.getDraft(conversation)?.reply +}) + +/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */ +function clearReply() { + const conversation = conversationStore.activeConversation + if (!conversation) { + return + } + draftStore.clearReply(conversation) +} + // ==================== 表情 ==================== const emojiVisible = ref(false) /** 切换表情面板;打开时互斥关掉语音面板 */ @@ -729,29 +765,35 @@ function onKeydown(e: KeyboardEvent) { } // ==================== 图片 / 文件上传 ==================== -/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */ +/** 上传并发送 IMAGE 消息;quote 抓取后立即清 draft.reply 让顶部引用条同步消失 */ async function uploadAndSendImage(file: File) { + const replyQuote = replyTarget.value + clearReply() const form = new FormData() form.append('file', file) const url = ((await updateFile(form)) as { data?: string })?.data if (!url) { return } - await sendRaw(ImMessageType.IMAGE, serializeMessage({ url })) + const payload = withQuotePayload({ url }, replyQuote) + await sendRaw(ImMessageType.IMAGE, serializeMessage(payload)) } /** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */ async function uploadAndSendFile(file: File) { + const replyQuote = replyTarget.value + clearReply() const form = new FormData() form.append('file', file) const url = ((await updateFile(form)) as { data?: string })?.data if (!url) { return } - await sendRaw( - ImMessageType.FILE, - serializeMessage({ url, name: file.name, size: file.size }) + const payload = withQuotePayload( + { url, name: file.name, size: file.size }, + replyQuote ) + await sendRaw(ImMessageType.FILE, serializeMessage(payload)) } /** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,整体走 sendRaw) */ @@ -783,6 +825,8 @@ function openVoice() { } /** VoiceRecorder 录完后回传 blob,包成 webm 文件上传,发送 VOICE 消息 */ async function onVoiceSend(payload: { blob: Blob; duration: number }) { + const replyQuote = replyTarget.value + clearReply() const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type }) const form = new FormData() form.append('file', file) @@ -791,10 +835,11 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) { if (!url) { return } - await sendRaw( - ImMessageType.VOICE, - serializeMessage({ url, duration: payload.duration }) + const audioPayload = withQuotePayload( + { url, duration: payload.duration }, + replyQuote ) + await sendRaw(ImMessageType.VOICE, serializeMessage(audioPayload)) } // ==================== 视频 ==================== @@ -909,6 +954,9 @@ async function uploadAndSendVideo(file: File) { return } const startKey = getConversationKey(startConversation) + // 1.2 quote 抓取后立即清 draft.reply,与图片 / 文件 / 语音上传链路一致 + const replyQuote = replyTarget.value + clearReply() // 2. 三路并行起跑(probe 与两条上传无依赖,封面上传等 probe 出 cover 后立即接力) // 2.1 视频本体上传:立即 catch 兜底为 url=undefined,由 step 3.2 拿不到 url 时放弃;同时让 promise 不再 floating @@ -961,17 +1009,18 @@ async function uploadAndSendVideo(file: File) { } // 4. 拼 VideoMessage payload 走通用 sendRaw(与图片 / 文件 / 语音同链路) - await sendRaw( - ImMessageType.VIDEO, - serializeMessage({ + const videoPayload = withQuotePayload( + { url, coverUrl, duration: probe.duration, width: probe.width, height: probe.height, size: file.size - }) + }, + replyQuote ) + await sendRaw(ImMessageType.VIDEO, serializeMessage(videoPayload)) } /** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor,整体走 sendRaw) */ diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 55f9b6b76..dafe75584 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -50,6 +50,14 @@ > {{ senderDisplayName }} + +
@@ -213,6 +221,8 @@ import { ImConversationType } from '../../../../../utils/constants' import { + buildQuoteFromMessage, + getQuoteFromMessage, parseMessage, resolveTipText, type TextMessage, @@ -228,6 +238,7 @@ import { useUserStore } from '@/store/modules/user' import { useConversationStore } from '../../../../store/conversationStore' import { useGroupStore } from '../../../../store/groupStore' import { useFriendStore } from '../../../../store/friendStore' +import { useDraftStore } from '../../../../store/draftStore' import { getMemberDisplayName, getSenderDisplayName, @@ -237,6 +248,7 @@ import { useImUiStore } from '../../../../store/uiStore' import { useMessageSender } from '../../../../composables/useMessageSender' import type { Message } from '../../../../types' import MessageReadStatus from './MessageReadStatus.vue' +import ReplyPreview from './ReplyPreview.vue' import UserAvatar from '../../../../components/user/UserAvatar.vue' import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' @@ -246,10 +258,16 @@ const props = defineProps<{ message: Message }>() +const emit = defineEmits<{ + /** 引用块点击 → MessagePanel 滚定位 + 高亮 */ + locate: [messageId: number] +}>() + const userStore = useUserStore() const conversationStore = useConversationStore() const groupStore = useGroupStore() const friendStore = useFriendStore() +const draftStore = useDraftStore() const uiStore = useImUiStore() const { recall, sendRaw } = useMessageSender() // 仅用 confirm,避免 message 跟 props.message 同名冲突(vue/no-dupe-keys) @@ -269,6 +287,9 @@ const isFile = computed(() => props.message.type === ImMessageType.FILE) const isVoice = computed(() => props.message.type === ImMessageType.VOICE) const isVideo = computed(() => props.message.type === ImMessageType.VIDEO) +/** 引用对象:气泡内嵌入展示;非引用消息返回 null,模板 v-if 不渲染 */ +const quote = computed(() => getQuoteFromMessage(props.message.content)) + /** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */ const showSenderName = computed(() => { if (props.message.selfSend) { @@ -514,6 +535,7 @@ const isAtMe = computed(() => { /** * 右键菜单项: + * - 回复:仅已落库(id≠0)且未撤回的消息可引用,引用块写入 draftStore.reply * - 删除:从本地消息列表移除(不动后端) * - 撤回:仅自己发送、已送达(有 id)的消息 * @@ -524,16 +546,24 @@ async function handleContextMenu(e: MouseEvent) { return } - // "删除"对所有消息开放(纯本地清理,无后端影响);"撤回"必须满足 自己发 + 已落库(id≠0)+ 未撤回 - const items: Array<{ key: string; name: string; disabled?: boolean }> = [ - { key: 'DELETE', name: '删除' } - ] + const items: Array<{ key: string; name: string; disabled?: boolean }> = [] + // "回复"必须满足 已落库(id≠0) + 未撤回;本地占位消息不允许引用,避免引用一条还没拿到 id 的消息 + // TODO @AI:应该是“引用”。你看看注释,中文,是不是都要调整下。 + if (!!props.message.id && !isRecall.value) { + items.push({ key: 'REPLY', name: '回复' }) + } + // TODO @AI:这里加个注释; if (props.message.selfSend && !!props.message.id && !isRecall.value) { items.push({ key: 'RECALL', name: '撤回' }) } + // "删除"对所有消息开放(纯本地清理,无后端影响);"撤回"必须满足 自己发 + 已落库(id≠0)+ 未撤回 + // TODO @AI:删除应该有个 --- 横线;然后是红色的,对齐微信; + items.push({ key: 'DELETE', name: '删除' }) // 把菜单渲染交给全局 uiStore(单例,避免每条消息都挂一份菜单 DOM);callback 按 key 分发 uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => { - if (item.key === 'RECALL') { + if (item.key === 'REPLY') { + handleReply() + } else if (item.key === 'RECALL') { await handleRecall() } else if (item.key === 'DELETE') { handleDelete() @@ -541,6 +571,15 @@ async function handleContextMenu(e: MouseEvent) { }) } +/** 进入回复模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */ +function handleReply() { + const conversation = conversationStore.activeConversation + if (!conversation) { + return + } + draftStore.setReply(conversation, buildQuoteFromMessage(props.message)) +} + /** * 撤回消息:弹确认框 → 调 useMessageSender.recall → 后端通过 WS RECALL 事件推回, * websocketStore 把对应 message 的 type 改成 RECALL,UI 自动切到"XX 撤回了一条消息" diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 95ca9936f..818a12841 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -69,7 +69,7 @@ :data-message-id="msg.id || ''" class="message-panel__message-anchor" > - +
@@ -242,7 +242,7 @@ async function ensureGroupData(groupId: number) { console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error) return null }) - // 再从远程异步拉成员,强刷以跳过 in-memory 短路,每次进群都能拿到最新成员状态 + // 再从远程异步拉成员,强刷以跳过 in-memory 缓存,每次进群都能拿到最新成员状态 groupStore.fetchGroupMembers(groupId, true).catch((error) => { console.warn('[IM MessagePanel] fetchGroupMembers 失败', { groupId }, error) }) @@ -325,12 +325,13 @@ function scrollToBottom(smooth = false) { } /** - * 定位到聊天位置:MessageHistory 行上"定位"按钮触发 + * 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发 * * 1. 先关掉历史弹窗(避免 scroll 时遮挡 + dialog 关闭后让聊天面板拿回焦点) * 2. nextTick 等弹窗 leave 动画 / 列表渲染稳定后再查 DOM * 3. 按 data-message-id 找 wrapper,scrollIntoView({ block: center }) 让消息落到视口中部 * 4. 加 --highlight class 短暂高亮,提示用户"就是这条" + * 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致 */ async function handleLocate(messageId: number) { if (!messageId) { @@ -342,6 +343,7 @@ async function handleLocate(messageId: number) { } const target = listRef.value.querySelector(`[data-message-id="${messageId}"]`) if (!target) { + message.warning('原消息不在视野') return } target.scrollIntoView({ behavior: 'smooth', block: 'center' }) diff --git a/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue b/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue index 678debd26..1a79fa1ef 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue @@ -147,7 +147,7 @@ async function loadReadUsers() { }) readUserIds.value = userIds || [] const readCount = readUserIds.value.length - // 全可见成员都已读 → flip 到 DONE,让外面 label 直接走"全部已读"短路; + // 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支; // 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING) const allRead = readCount > 0 && readCount >= visibleMembers.value.length conversationStore.applyReadReceipt({ diff --git a/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue new file mode 100644 index 000000000..00c9215eb --- /dev/null +++ b/src/views/im/home/pages/conversation/components/message/ReplyPreview.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/src/views/im/home/store/draftStore.ts b/src/views/im/home/store/draftStore.ts index 167b2df9a..e269b7567 100644 --- a/src/views/im/home/store/draftStore.ts +++ b/src/views/im/home/store/draftStore.ts @@ -5,15 +5,18 @@ import { store } from '@/store' import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage' import { getConversationKey } from '../../utils/conversation' +import type { QuoteMessage } from '../../utils/message' /** * 草稿快照 * - html:editor 是 contenteditable,含 @-token /
,回填编辑器用整段 innerHTML 才能还原 * - plain:纯文本预览,给会话列表 [草稿] 文案展示用,避免列表渲染时再 strip HTML + * - reply:当前会话的「正在引用」对象,跟随草稿走;切会话保留,发送后清空 */ export interface DraftSnapshot { html: string plain: string + reply?: QuoteMessage } /** 草稿持久化整桶结构:Record<会话 key, 快照>;草稿量级小(每会话至多几百字节),整桶整写够用 */ @@ -63,13 +66,13 @@ export const useDraftStore = defineStore('imDraft', { /** * 写草稿 + debounce 落盘 * - * plain 为空(仅含
/ 空白)按 clear 处理:避免列表残留 [草稿] 与编辑器实际为空对不上 + * plain 空且 reply 也空才按 clear 处理:用户只点了回复未输入正文时,切会话回来引用条仍要在 */ setDraft( conversation: { type: number; targetId: number }, snapshot: DraftSnapshot ): void { - if (!snapshot.plain.trim()) { + if (!snapshot.plain.trim() && !snapshot.reply) { this.clearDraft(conversation) return } @@ -87,6 +90,28 @@ export const useDraftStore = defineStore('imDraft', { this.schedulePersist() }, + /** 进入回复模式:把 quote 写到当前草稿,正文 html / plain 保留 */ + setReply( + conversation: { type: number; targetId: number }, + quote: QuoteMessage + ): void { + const existing = this.getDraft(conversation) + this.setDraft(conversation, { + html: existing?.html ?? '', + plain: existing?.plain ?? '', + reply: quote + }) + }, + + /** 退出回复模式:仅清掉 reply,正文草稿保留;无 reply 时直接返回 */ + clearReply(conversation: { type: number; targetId: number }): void { + const existing = this.getDraft(conversation) + if (!existing?.reply) { + return + } + this.setDraft(conversation, { ...existing, reply: undefined }) + }, + /** 调度 debounce 写盘;未登录时直接跳过(无主 key 不写) */ schedulePersist(): void { const userId = getCurrentUserId() diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index 4d7816421..b243c73e8 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -27,7 +27,7 @@ import type { Friend } from '../types' export const useFriendStore = defineStore('imFriendStore', { state: () => ({ friends: [] as Friend[], - // 仅 fetchFriends 成功后置位;loadFriends(IDB)不置位,否则后台 SWR 刷新会被短路 + // 仅 fetchFriends 成功后置位;loadFriends(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过 loaded: false }), diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index 90a009e96..e9511c0fa 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -52,7 +52,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n export const useGroupStore = defineStore('imGroupStore', { state: () => ({ groups: [] as Group[], - // 仅 fetchGroups 成功后置位;loadGroups(IDB)不置位,否则后台 SWR 刷新会被短路 + // 仅 fetchGroups 成功后置位;loadGroups(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过 loaded: false }), @@ -107,7 +107,7 @@ export const useGroupStore = defineStore('imGroupStore', { return null } // in-memory 已"完整"加载(fetchGroupMembers 跑过或上次冷启动从 IDB 整桶恢复过):直接复用; - // 单成员补齐(fetchGroupMember)写进的 partial members 不在此短路——其 membersLoaded=false + // 单成员补齐(fetchGroupMember)写进的 partial members 不在此返回缓存——其 membersLoaded=false const cachedGroup = this.getGroup(groupId) if (cachedGroup?.members && cachedGroup.membersLoaded) { return cachedGroup.members @@ -213,7 +213,7 @@ export const useGroupStore = defineStore('imGroupStore', { /** 按群拉取成员(in-memory 缓存 + 并发去重,force=true 强刷)+ 落 IDB */ fetchGroupMembers(groupId: number, force = false): Promise { - // in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此短路(membersLoaded=false) + // in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此返回(membersLoaded=false) const cached = this.getGroup(groupId) if (cached && cached.members && cached.membersLoaded && !force) { return Promise.resolve(cached.members) diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 81b587a6b..82ea0dffe 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -264,7 +264,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 流程: * 1. 离线加载期缓冲(避开与 pull 回填的竞态) * 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段 - * 3. 撤回 TIP 短路:转走 recallMessage,不进消息列表 + * 3. 撤回 TIP 直接转走 recallMessage,不进消息列表 * 4. 构造前端 Message,插入到对应私聊会话 * 5. 当前会话激活时自动上报已读;否则非免打扰响提示音 */ @@ -372,7 +372,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 流程: * 1. 离线加载期缓冲 * 2. 未知群时拉群详情兜底 - * 3. 撤回 TIP 短路 + * 3. 撤回 TIP 直接转走 * 4. 构造 Message + at 字段,插入到对应群聊会话(发送人名渲染时实时算) * 5. 当前会话激活时自动上报已读(带 lastMessageId);否则非免打扰响提示音 */ diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 3e546aabf..30de75344 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -118,7 +118,7 @@ export interface Group { muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) displayGroupName?: string // 群显示备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) members?: GroupMember[] // 群成员缓存(按需懒加载) - membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 短路时误判整群已加载 + membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 命中缓存时误判整群已加载 memberCount?: number // 成员总数 } diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index 94a6bfda3..971194662 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -1,4 +1,5 @@ import { generateUUID } from '@/utils' +import type { Message } from '../home/types' // ==================================================================== // IM 消息 content 编解码 & 展示工具 @@ -19,15 +20,30 @@ export const generateClientMessageId = (): string => { return generateUUID() } +// ==================== 引用消息 ==================== + +/** 引用消息 payload(对齐后端 QuoteMessage) */ +export interface QuoteMessage { + messageId: number + senderId: number + type: number + content: string +} + +/** 引用容器:5 种普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */ +interface Quotable { + quote?: QuoteMessage +} + // ==================== 消息 payload ==================== /** 文本消息 payload(对齐后端 TextMessage) */ -export interface TextMessage { +export interface TextMessage extends Quotable { content: string } /** 图片消息 payload(对齐后端 ImageMessage) */ -export interface ImageMessage { +export interface ImageMessage extends Quotable { url: string /** 缩略图 URL */ thumbnailUrl?: string @@ -40,7 +56,7 @@ export interface ImageMessage { } /** 语音消息 payload(对齐后端 AudioMessage;ImMessageType 保留 VOICE 命名) */ -export interface AudioMessage { +export interface AudioMessage extends Quotable { url: string /** 时长(秒) */ duration: number @@ -49,7 +65,7 @@ export interface AudioMessage { } /** 文件消息 payload(对齐后端 FileMessage) */ -export interface FileMessage { +export interface FileMessage extends Quotable { url: string name: string size: number @@ -58,7 +74,7 @@ export interface FileMessage { } /** 视频消息 payload(对齐后端 VideoMessage;暂未接入渲染) */ -export interface VideoMessage { +export interface VideoMessage extends Quotable { url: string /** 封面 URL */ coverUrl?: string @@ -82,6 +98,54 @@ export const parseMessage = (content: string): T | null => { /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ export const serializeMessage = (payload: T): string => JSON.stringify(payload) +// ==================== 引用消息 helper ==================== + +/** 把 quote 合进 payload(序列化前调用);quote 缺失时原样返回 */ +export const withQuotePayload = (payload: T, quote?: QuoteMessage): T => { + if (!quote) { + return payload + } + return { ...payload, quote } +} + +/** + * 从 content JSON 字符串里清掉 quote 字段 + * + * 客户端乐观渲染构造 quote 时调用,避免"回复一条带引用的消息"造成 quote 嵌套滚雪球; + * 与后端 ImMessageUtils.removeQuote 对齐 + */ +export const removeQuotePayload = (content: string): string => { + if (!content || !content.includes('"quote"')) { + return content + } + const parsed = parseMessage>(content) + if (!parsed || !('quote' in parsed)) { + return content + } + delete parsed.quote + return JSON.stringify(parsed) +} + +/** 由 Message 派生 QuoteMessage 用于乐观渲染;ack 后会被服务端权威版本覆盖 */ +export const buildQuoteFromMessage = (message: Message): QuoteMessage => { + return { + messageId: message.id, + senderId: message.senderId, + type: message.type, + content: removeQuotePayload(message.content) + } +} + +/** 从已序列化 message.content 中解出 quote;非 JSON / 无 quote 返回 null */ +export const getQuoteFromMessage = (content: string): QuoteMessage | null => { + // 长会话每条消息渲染都走 quote computed,非引用消息字符串预扫直接返回,免一次 JSON.parse + if (!content || !content.includes('"quote"')) { + return null + } + const parsed = parseMessage(content) + return parsed?.quote ?? null +} + // ==================== TIP_TEXT ==================== /**