diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index fa2a4fb64..89c625e97 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -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[] = [] 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) }) /** diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue index 639df2c2f..c1fac2e05 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue @@ -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 '[有人@我]' } diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 4687c53e2..e9128d7c4 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -129,15 +129,17 @@