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

im
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 trimplain 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(() => {
/**
* 右键菜单项
* - 回复仅已落库(id0)且未撤回的消息可引用,引用块写入 draftStore.reply
* - 删除从本地消息列表移除不动后端
* - 撤回仅自己发送已送达 id的消息
*
@ -524,16 +546,24 @@ async function handleContextMenu(e: MouseEvent) {
return
}
// """" + id0+
const items: Array<{ key: string; name: string; disabled?: boolean }> = [
{ key: 'DELETE', name: '删除' }
]
const items: Array<{ key: string; name: string; disabled?: boolean }> = []
// "" (id0) + ;, 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: '撤回' })
}
// """" + id0+
// TODO @AI --- 线
items.push({ key: 'DELETE', name: '删除' })
// uiStore DOMcallback 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 改成 RECALLUI 自动切到"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 wrapperscrollIntoView({ 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 ""
// readCountreceiptStatus 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'
/**
* 稿
* - htmleditor 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 成功后置位loadFriendsIDB不置位否则后台 SWR 刷新会被短路
// 仅 fetchFriends 成功后置位loadFriendsIDB不置位否则后台 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 成功后置位loadGroupsIDB不置位否则后台 SWR 刷新会被短路
// 仅 fetchGroups 成功后置位loadGroupsIDB不置位否则后台 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 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 短路时误判整群已加载
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 truefetchGroupMember 单成员补齐不置位,避免 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对齐后端 AudioMessageImMessageType 保留 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 ====================
/**