✨ feat(im): 增加发送草稿,切换对话的时候,不丢失。对齐微信

pull/881/head
YunaiV 2026-05-01 07:52:18 +08:00
parent be654bce50
commit 238862b572
7 changed files with 243 additions and 34 deletions

View File

@ -31,6 +31,7 @@ import { useConversationStore } from './store/conversationStore'
import { useImWebSocketStore } from './store/websocketStore'
import { useFriendStore } from './store/friendStore'
import { useGroupStore } from './store/groupStore'
import { useDraftStore } from './store/draftStore'
import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { ImConversationType } from '../utils/constants'
@ -44,6 +45,7 @@ const conversationStore = useConversationStore()
const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const draftStore = useDraftStore()
const { pullOnce } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
@ -52,15 +54,17 @@ onMounted(async () => {
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
conversationStore.loading = true
try {
// 1.2 三个 store 并发吃 IDB(loadConversations 返回 void;load{Friends,Groups} 返回是否命中缓存)
// 1.2 四个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups} 返回是否命中缓存)
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
conversationStore.loadConversations(),
friendStore.loadFriends(),
groupStore.loadGroups()
groupStore.loadGroups(),
draftStore.loadDrafts()
])
// 2.1 有缓存:异步背景刷新,失败仅记日志(IDB 数据已经够撑首屏,pullOnce 也能正常入库)
// 2.2 无缓存(首登 / 切账号回切):必须 await + 失败抛出中断本轮 onMounted,否则 pullOnce 会用 senderId 数字给会话起名落到 IDB 后续基本无法自愈;无缓存分支两个 fetch 并发 Promise.all 省一个 RTT
// 2.2 无缓存(首登 / 切账号回切):必须 await + 失败抛出中断本轮 onMounted,
// 否则 pullOnce 会用 senderId 数字给会话起名落到 IDB 后续基本无法自愈;无缓存分支两个 fetch 并发 Promise.all 省一个 RTT
const requiredFetches: Promise<unknown>[] = []
if (hasCachedFriends) {
void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e))
@ -76,7 +80,7 @@ onMounted(async () => {
await Promise.all(requiredFetches)
}
// 3. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位);上一步无缓存 fetch 失败会被外层 catch 提前 return 不到这里,避免 WebSocket 已连但 friend/group store 是空的、handle*Message 用 senderId 数字落库
// 3. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位)
webSocketStore.connect()
await pullOnce()
@ -93,9 +97,17 @@ onMounted(async () => {
}
})
/** 离开 IM 主壳:主动断 WebSocket(disconnect 内部已清掉 onclose 防自动重连) */
/** 标签关闭前 flush 草稿队列;debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() {
draftStore.flushPersist()
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:主动断 WebSocket(disconnect 内部已清掉 onclose 防自动重连)+ flush 草稿 + 解绑 unload */
onUnmounted(() => {
webSocketStore.disconnect()
draftStore.flushPersist()
window.removeEventListener('beforeunload', onBeforeUnload)
})
/**

View File

@ -79,6 +79,7 @@ import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
import { useDraftStore } from '../../../../store/draftStore'
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
@ -98,8 +99,12 @@ const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const uiStore = useImUiStore()
const draftStore = useDraftStore()
const message = useMessage()
/** 当前会话的草稿快照:存在时列表显示 [草稿] 前缀 + plain 文本,盖掉 sender 前缀和 @我 红字 */
const draft = computed(() => draftStore.getDraft(props.conversation))
const isActive = computed(
() =>
conversationStore.activeConversation?.targetId === props.conversation.targetId &&
@ -122,8 +127,11 @@ const lastSenderDisplayName = computed(() => {
)
})
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(TIP_TIME / TIP_TEXT / RECALL 不带前缀) */
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(TIP_TIME / TIP_TEXT / RECALL / 草稿态不带前缀) */
const showSendName = computed(() => {
if (draft.value) {
return false
}
if (!isGroup.value) {
return false
}
@ -134,8 +142,11 @@ const showSendName = computed(() => {
return lastType != null && isNormalMessage(lastType)
})
/** 列表展示文案:撤回类型实时算(避免改备注后老 lastContent 过期),其余直接用 lastContent */
/** 列表展示文案:草稿优先(对齐微信 PC:有草稿时盖掉最后一条预览)→ 撤回实时算 → lastContent 兜底 */
const lastContentDisplay = computed(() => {
if (draft.value) {
return draft.value.plain
}
if (
props.conversation.lastMessageType === ImMessageType.RECALL &&
props.conversation.lastSenderId != null
@ -151,8 +162,11 @@ const lastContentDisplay = computed(() => {
return props.conversation.lastContent
})
/** 会话列表 "@ 我" / "@ 全体成员" 红字提示 */
/** 会话列表 "[草稿]" / "@ 我" / "@ 全体成员" 红字提示;草稿优先(对齐微信 PC) */
const atText = computed(() => {
if (draft.value) {
return '[草稿]'
}
if (props.conversation.atMe) {
return '[有人@我]'
}

View File

@ -129,15 +129,17 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { updateFile } from '@/api/infra/file'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
serializeMessage,
@ -156,6 +158,7 @@ defineOptions({ name: 'ImMessageInput' })
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const { send, sendRaw } = useMessageSender()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
@ -164,15 +167,10 @@ const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
// ==================== 文本 / 发送 ====================
/** editor 是否有可发送内容;contenteditable 没 v-model,靠 input 事件主动同步 */
const canSend = ref(false)
const canSend = ref(false) // editor 是否有可发送内容;contenteditable 没 v-model,靠 input 事件主动同步
/** 维护 canSend + data-empty(撑起 placeholder) */
function syncEditorState() {
const editor = editorRef.value
if (!editor) {
return
}
/** 维护 canSend + data-empty(撑起 placeholder);不写草稿,restoreDraftToEditor 复用避免回流 */
function applyEditorUiState(editor: HTMLDivElement) {
const raw = editor.textContent || ''
// canSend 按 trim 后判断(空格 / 换行不算可发送内容)
canSend.value = !!raw.trim() && !!conversationStore.activeConversation
@ -186,6 +184,56 @@ function syncEditorState() {
}
}
/** 用户编辑入口的统一收尾:UI 状态同步 + 草稿写回 store(列表立即出 [草稿] 前缀) */
function syncEditorState() {
const editor = editorRef.value
if (!editor) {
return
}
applyEditorUiState(editor)
syncDraftToStore(editor)
}
/** 把 editor 当前内容写到 draftStore;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
function syncDraftToStore(editor: HTMLDivElement) {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
// collectFromEditor 已 trim,plain 为空时 store 内部按 clearDraft 处理
const { text } = collectFromEditor(editor)
draftStore.setDraft(conversation, { html: editor.innerHTML, plain: text })
}
/** 切会话时把 store 里的草稿还原到 editor;只更 UI 不回写草稿,避免 store→editor→store 回流 */
function restoreDraftToEditor() {
const editor = editorRef.value
if (!editor) {
return
}
const conversation = conversationStore.activeConversation
const draft = conversation ? draftStore.getDraft(conversation) : undefined
editor.innerHTML = draft?.html || ''
applyEditorUiState(editor)
// 把光标移到末尾,让用户接着输入;空内容直接 focus 即可
if (draft?.html) {
placeCaretAtEnd(editor)
}
}
/** 把光标放到 contenteditable 元素的末尾——切回有草稿的会话时光标自然落在尾部,对齐微信 */
function placeCaretAtEnd(el: HTMLElement) {
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
if (!sel) {
return
}
sel.removeAllRanges()
sel.addRange(range)
}
/**
* 走 DOM 把 editor 内容拼回 plain text + atUserIds
*
@ -261,8 +309,12 @@ async function handleSend(options?: { receipt?: boolean }) {
if (!text) {
return
}
// 1. 清空 + 同步状态
// 1. 清空 editor + 当前会话草稿;syncEditorState 后 plain 已为空,store 内部会自动清,
// 但显式 clearDraft 能立即同步、不依赖 debounce 时序,列表上的 [草稿] 立即消失
editor.innerHTML = ''
if (conversationStore.activeConversation) {
draftStore.clearDraft(conversationStore.activeConversation)
}
syncEditorState()
// 2. 发送
await send(text, {
@ -341,6 +393,7 @@ function onDocMousedown(e: MouseEvent) {
onMounted(() => {
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('mousedown', onDocMousedown)
restoreDraftToEditor()
})
onBeforeUnmount(() => {
@ -348,6 +401,27 @@ onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocMousedown)
})
/**
* 切会话时还原对方的草稿到 editor
*
* 同步关 @ / 表情 / 语音浮层并清 savedRange:
* - mentionRange / savedRange 旧引用还指向上一会话的 DOM 节点,不清下次插 token 会落错位置
* - 语音录制弹窗保留时,录完触发的 onVoiceSend 会读当前 activeConversation,把语音发到新会话
*/
watch(
() =>
conversationStore.activeConversation
? getConversationKey(conversationStore.activeConversation)
: null,
() => {
closeMention()
emojiVisible.value = false
voiceVisible.value = false
savedRange = null
restoreDraftToEditor()
}
)
/**
* 把字符串插入光标处(emoji 面板等场景调用)
*

View File

@ -80,12 +80,8 @@
</transition>
</div>
<!-- 底部:输入框
:key 绑会话标识,切换 A → B 时强制重建组件 → editor / mention range / pendingAtUserIds
全部清零,避免上一会话的草稿和 @ 被发到新会话 -->
<!-- TODO @AI:切换时,之前的要被保留! -->
<!-- TODO @AI:切换时,用户如果有输入信息,需要把 lastContent 变成输入信息; -->
<MessageInput :key="messageInputKey" />
<!-- 底部:输入框 -->
<MessageInput />
<!-- 右侧信息抽屉:群聊 / 私聊各自一份 -->
<ConversationGroupSide
@ -128,7 +124,6 @@ import { useFriendStore } from '../../../../store/friendStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '@/views/im/utils/constants'
import { getConversationKey } from '@/views/im/utils/conversation'
import { CommonStatusEnum } from '@/utils/constants'
import MessageItem from './MessageItem.vue'
import MessageInput from '../input/MessageInput.vue'
@ -165,15 +160,6 @@ const headerMemberCount = computed(() => {
return group?.memberCount ?? group?.members?.length ?? 0
})
/**
* MessageInput 的 :key —— 切群时强制 unmount + remount,让 editor / mention range /
* 上一会话草稿全部归零;用 fallback 'none' 避开 activeConversation 短暂为 null 的窗口
*/
const messageInputKey = computed(() => {
const conv = conversationStore.activeConversation
return conv ? getConversationKey(conv) : 'none'
})
const BOTTOM_THRESHOLD = 80 // "是否停留在底部"的阈值:距离底部 < 80px 视为底部
const showJumpToBottom = ref(false) // 当前是否已不在底部(显示"回到底部"按钮)
const newMessageCount = ref(0) // 不在底部期间累计的新消息数

View File

@ -14,6 +14,7 @@ import { generateClientMessageId, parseRecallMessageId } from '../../utils/messa
import { resolveConversationLastContent } from '../../utils/conversation'
import { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import { useDraftStore } from './draftStore'
import type { Conversation, ConversationStoreMeta, Message } from '../types'
/**
@ -333,6 +334,8 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.deleted = true
// 软删后会话的消息文件不再有用,物理删除该 key
this.removeConversationMessages(type, targetId)
// 同步清掉该会话的草稿,避免重建同 key 会话时残留 [草稿]
useDraftStore().clearDraft({ type, targetId })
this.saveConversations()
},

View File

@ -0,0 +1,113 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import { debounce } from 'lodash-es'
import { store } from '@/store'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getConversationKey } from '../../utils/conversation'
/**
* 草稿快照
* - html:editor 是 contenteditable,含 @-token / <br>,回填编辑器用整段 innerHTML 才能还原
* - plain:纯文本预览,给会话列表 [草稿] 文案展示用,避免列表渲染时再 strip HTML
*/
export interface DraftSnapshot {
html: string
plain: string
}
/** 草稿持久化整桶结构:Record<会话 key, 快照>;草稿量级小(每会话至多几百字节),整桶整写够用 */
type DraftBucket = Record<string, DraftSnapshot>
/** 写盘 debounce:用户连续敲键盘时合并写入,避免高频 IDB 写放大 */
const PERSIST_DEBOUNCE_MS = 500
/** 合并连续输入的 IDB 写;setQuietly 内部 toRaw 拆 reactive proxy,避免 structuredClone 抛错 */
const persistBucket = debounce((userId: number, bucket: DraftBucket) => {
setQuietly(StorageKeys.drafts(userId), bucket, '[IM draftStore] persist 失败')
}, PERSIST_DEBOUNCE_MS)
export const useDraftStore = defineStore('imDraft', {
state: () => ({
/** 内存草稿表:key = getConversationKey */
drafts: {} as DraftBucket
}),
actions: {
/**
* 启动时从 IDB 加载已有草稿;失败仅打日志(草稿丢失可以接受,不阻断 IM 初始化)
*
* 进入即清空内存:账号切换 / 重进 IM 时 store 实例可能保留上一次的 drafts,
* 同 targetId 的不同账号会话会串显示 [草稿]
*/
async loadDrafts(): Promise<void> {
this.drafts = {}
const userId = getCurrentUserId()
if (!userId) {
return
}
try {
const bucket = await imStorage.getItem<DraftBucket>(StorageKeys.drafts(userId))
if (bucket && typeof bucket === 'object') {
this.drafts = bucket
}
} catch (e) {
console.warn('[IM draftStore] loadDrafts 失败', e)
}
},
/** 取草稿;返回 undefined 表示该会话无草稿 */
getDraft(conversation: { type: number; targetId: number }): DraftSnapshot | undefined {
return this.drafts[getConversationKey(conversation)]
},
/**
* 写草稿 + debounce 落盘
*
* plain 为空(仅含 <br> / 空白)按 clear 处理:避免列表残留 [草稿] 与编辑器实际为空对不上
*/
setDraft(
conversation: { type: number; targetId: number },
snapshot: DraftSnapshot
): void {
if (!snapshot.plain.trim()) {
this.clearDraft(conversation)
return
}
this.drafts[getConversationKey(conversation)] = snapshot
this.schedulePersist()
},
/** 清草稿:发送成功 / 编辑器清空 / 会话被软删除时调用 */
clearDraft(conversation: { type: number; targetId: number }): void {
const key = getConversationKey(conversation)
if (!(key in this.drafts)) {
return
}
delete this.drafts[key]
this.schedulePersist()
},
/** 调度 debounce 写盘;未登录时直接跳过(无主 key 不写) */
schedulePersist(): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
persistBucket(userId, this.drafts)
},
/** 立即落盘待写的草稿;beforeunload 时调,避免最后一次输入卡在 debounce 队列里丢失 */
flushPersist(): void {
persistBucket.flush()
}
}
})
export const useDraftStoreWithOut = () => {
return useDraftStore(store)
}
// dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useDraftStore, import.meta.hot))
}

View File

@ -45,6 +45,13 @@ export const StorageKeys = {
conversationMessages: (userId: number | string, type: number, targetId: number) =>
`conversation:messages:${userId}:${type}:${targetId}`,
/**
* 输入框草稿整桶:Record<`${type}:${targetId}`, DraftSnapshot>
*
* 草稿端本地、量级小(每会话至多几百字节),整桶整写够用;持久化按 userId 分桶与其它业务一致
*/
drafts: (userId: number | string) => `drafts:${userId}`,
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
friends: (userId: number | string) => `friends:${userId}`,
/** 群列表整桶(不含 members,剥离到独立 key),保证整桶写不带成员爆量 */