✨ feat(im): 增加【消息引用】的功能

pull/881/head
YunaiV 2026-05-01 18:03:05 +08:00
parent 744229a02e
commit 1dfab43b8a
12 changed files with 405 additions and 50 deletions

View File

@ -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<TextMessage>({ content: text }), options)
const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
await sendRaw(ImMessageType.TEXT, serializeMessage(payload), options)
}
/**

View File

@ -28,6 +28,15 @@
@paste.prevent="onPaste"
></div>
<!-- 引用预览条 -->
<ReplyPreview
v-if="replyTarget"
:quote="replyTarget"
closable
class="mx-3 mb-1.5"
@close="clearReply"
/>
<!--
底部工具栏:左侧操作图标 + 右侧发送按钮(对齐微信 PC:操作图标统一放底部)
- relative 给 EmojiPicker 提供 absolute 锚点,picker 用 bottom-full 向上弹出
@ -115,11 +124,7 @@
/>
<!-- 语音录制面板:与表情面板同处工具栏,bottom-full 向上弹出,避免离触发的麦克风图标过远 -->
<VoiceRecorder
v-model="voiceVisible"
class="bottom-full left-3 mb-2"
@send="onVoiceSend"
/>
<VoiceRecorder v-model="voiceVisible" class="bottom-full left-3 mb-2" @send="onVoiceSend" />
</div>
</div>
@ -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<QuoteMessage | undefined>(() => {
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<ImageMessage>({ url }))
const payload = withQuotePayload<ImageMessage>({ 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<FileMessage>({ url, name: file.name, size: file.size })
const payload = withQuotePayload<FileMessage>(
{ 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<AudioMessage>({ url, duration: payload.duration })
const audioPayload = withQuotePayload<AudioMessage>(
{ 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<VideoMessage>({
const videoPayload = withQuotePayload<VideoMessage>(
{
url,
coverUrl,
duration: probe.duration,
width: probe.width,
height: probe.height,
size: file.size
})
},
replyQuote
)
await sendRaw(ImMessageType.VIDEO, serializeMessage(videoPayload))
}
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor,整体走 sendRaw) */

View File

@ -50,6 +50,14 @@
>
{{ senderDisplayName }}
</div>
<!-- 引用块:气泡正上方,与气泡同侧;点击触发 MessagePanel 滚定位 -->
<ReplyPreview
v-if="quote"
:quote="quote"
clickable
class="max-w-[280px]"
@locate="emit('locate', $event)"
/>
<div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
<!-- 消息内容:按 type 走 v-if 分支 -->
<!-- 文本消息 -->
@ -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 撤回了一条消息"

View File

@ -69,7 +69,7 @@
:data-message-id="msg.id || ''"
class="message-panel__message-anchor"
>
<MessageItem :message="msg" />
<MessageItem :message="msg" @locate="handleLocate" />
</div>
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
@ -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<HTMLElement>(`[data-message-id="${messageId}"]`)
if (!target) {
message.warning('原消息不在视野')
return
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' })

View File

@ -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({

View File

@ -0,0 +1,165 @@
<template>
<!--
引用消息预览块,对齐微信 PC:浅灰底块 + 大 padding + 文本可换行(line-clamp 2 行)
- clickable=true(气泡内): 点击触发 locate emit;撤回态禁用跳转
- closable=true(输入条): 显示右上 × 圆形按钮,hover 时显示圆形底
- 撤回降级:命中本地缓存且 type === RECALL 时显示「原消息已撤回」斜体灰字
- 富预览:type 为 IMAGE / VIDEO 时直接从 quote.content 取缩略图,不依赖本地缓存
-->
<div
class="im-reply-preview flex gap-2 items-start min-w-0 px-3 py-2 rounded text-13px bg-[var(--el-fill-color-light)]"
:class="{
'cursor-pointer hover:bg-[var(--el-fill-color)]': clickable && !isRecalled
}"
@click="onClick"
>
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
class="flex-shrink-0 object-cover w-8 h-8 rounded"
alt=""
/>
<div
class="im-reply-preview__text flex-1 min-w-0 leading-relaxed text-[var(--el-text-color-secondary)]"
:class="{ italic: isRecalled }"
>
<span>{{ senderName }}:</span>
<span class="ml-1">{{ snippetText }}</span>
</div>
<button
v-if="closable"
type="button"
class="im-reply-preview__close flex-shrink-0 inline-flex items-center justify-center w-5 h-5 mt-0.5 cursor-pointer rounded-full bg-transparent border-none text-[var(--el-text-color-secondary)] transition-colors hover:bg-[var(--el-fill-color-darker)] hover:text-[var(--el-text-color-primary)]"
@click.stop="emit('close')"
>
<Icon icon="ant-design:close-outlined" :size="12" />
</button>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../../../store/conversationStore'
import { getSenderDisplayName } from '../../../../../utils/user'
import { ImMessageType } from '../../../../../utils/constants'
import {
parseMessage,
type AudioMessage,
type FileMessage,
type ImageMessage,
type TextMessage,
type VideoMessage,
type QuoteMessage
} from '../../../../../utils/message'
defineOptions({ name: 'ImReplyPreview' })
/** 文本摘要在引用块里展示的最大字符数;后端 quote.content 已截断到 1000,这里再压一次给单行预览 */
const MAX_TEXT_PREVIEW_LEN = 60
const props = withDefaults(
defineProps<{
quote: QuoteMessage
/** 气泡内为 true 支持点击跳转,输入条为 false */
clickable?: boolean
/** 输入条为 true 显示 × 关闭按钮 */
closable?: boolean
}>(),
{
clickable: false,
closable: false
}
)
const emit = defineEmits<{
locate: [messageId: number]
close: []
}>()
const conversationStore = useConversationStore()
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
const liveMessage = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || !props.quote.messageId) {
return undefined
}
return conversation.messages.find((message) => message.id === props.quote.messageId)
})
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
const isRecalled = computed(() => liveMessage.value?.type === ImMessageType.RECALL)
/** 渲染时实时算,与气泡上方显示名走同一套规则,避免备注变更后引用块陈旧 */
const senderName = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation) {
return ''
}
return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId)
})
/** 摘要文案:已撤回降级,否则按 type 从 quote.content 派生(文本截断 / 非文本走类型 tag) */
const snippetText = computed(() => {
if (isRecalled.value) {
return '原消息已撤回'
}
const { type, content } = props.quote
if (type === ImMessageType.TEXT) {
const text = parseMessage<TextMessage>(content)?.content ?? ''
return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
}
if (type === ImMessageType.IMAGE) {
return '[图片]'
}
if (type === ImMessageType.FILE) {
const name = parseMessage<FileMessage>(content)?.name
return name ? `[文件 ${name}]` : '[文件]'
}
if (type === ImMessageType.VOICE) {
const duration = parseMessage<AudioMessage>(content)?.duration
return duration ? `[语音 ${duration}″]` : '[语音]'
}
if (type === ImMessageType.VIDEO) {
return '[视频]'
}
return ''
})
/** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => {
if (isRecalled.value) {
return undefined
}
const { type, content } = props.quote
if (type === ImMessageType.IMAGE) {
const payload = parseMessage<ImageMessage>(content)
return payload?.thumbnailUrl || payload?.url
}
if (type === ImMessageType.VIDEO) {
return parseMessage<VideoMessage>(content)?.coverUrl
}
return undefined
})
/** 仅 clickable 且未撤回时触发跳转 */
function onClick() {
if (!props.clickable || isRecalled.value) {
return
}
emit('locate', props.quote.messageId)
}
</script>
<style scoped>
/* 文字超过 2 行截断,避免长引用把输入条 / 气泡撑高;UnoCSS 的 line-clamp 工具类在本项目未启用,走 scoped CSS */
.im-reply-preview__text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
</style>

View File

@ -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 / <br>,回填编辑器用整段 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 为空(仅含 <br> / 空白)按 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()

View File

@ -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
}),

View File

@ -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<GroupMember[]> {
// 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)

View File

@ -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);否则非免打扰响提示音
*/

View File

@ -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 // 成员总数
}

View File

@ -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 = <T>(content: string): T | null => {
/** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */
export const serializeMessage = <T>(payload: T): string => JSON.stringify(payload)
// ==================== 引用消息 helper ====================
/** 把 quote 合进 payload(序列化前调用);quote 缺失时原样返回 */
export const withQuotePayload = <T extends Quotable>(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<Record<string, unknown>>(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<Quotable>(content)
return parsed?.quote ?? null
}
// ==================== TIP_TEXT ====================
/**