✨ feat(im): 增加发送草稿,切换对话的时候,不丢失。对齐微信
parent
be654bce50
commit
238862b572
|
|
@ -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)
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 '[有人@我]'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 面板等场景调用)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) // 不在底部期间累计的新消息数
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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),保证整桶写不带成员爆量 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue