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

im
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 IDBloadConversations voidload{Friends,Groups}
// 1.2 store IDB loadConversations / loadDrafts voidload{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 主壳:主动断 WebSocketdisconnect 内部已清掉 onclose 防自动重连) */
/** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() {
draftStore.flushPersist()
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:主动断 WebSocketdisconnect 内部已清掉 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 当前内容写到 draftStoreplain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
function syncDraftToStore(editor: HTMLDivElement) {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
// collectFromEditor trimplain 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'
/**
* 稿
* - htmleditor 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保证整桶写不带成员爆量 */