✨ feat(im): 增加【消息引用】的功能
parent
744229a02e
commit
1dfab43b8a
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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 撤回了一条消息"
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);否则非免打扰响提示音
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 // 成员总数
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue