✨ feat(im): 增加 friend、group 相关的本地存储
parent
de39bc7fc1
commit
e90f9e5237
|
|
@ -41,6 +41,7 @@ import ContextMenu from './components/ContextMenu.vue'
|
|||
defineOptions({ name: 'ImIndex' })
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
// TODO @AI:webSocketStore 全称更合适。
|
||||
const wsStore = useImWebSocketStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
|
@ -48,32 +49,61 @@ const { pullOnce } = useMessagePuller()
|
|||
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
||||
|
||||
/** 初始化:本地缓存恢复 → 远端通信/同步 → 默认视图 */
|
||||
// TODO @AI:上面的“初始化:本地缓存恢复 → 远端通信/同步 → 默认视图”,有点不好理解。
|
||||
onMounted(async () => {
|
||||
// ========== 1. 本地状态准备 ==========
|
||||
// 1.1 整段初始化期间 loading=true:阻断 saveConversations 抖动写盘 + 让 WS 普通消息进缓冲区,
|
||||
// 避免 connect 后到 pullOnce 之间到达的实时消息推进 maxId,导致 pull 跳过断线积压消息
|
||||
// TODO @AI:WS 全称 WebSocket,不要缩写。其他地方也是
|
||||
// loading=true 整段阻断 saveConversations 抖动写盘 + WS 普通消息进缓冲,
|
||||
// 避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
conversationStore.loading = true
|
||||
// 1.2 从 IndexedDB 恢复本地会话数据(按会话分桶,需 await 以保证后续步骤拿到完整列表)
|
||||
await conversationStore.loadConversations()
|
||||
|
||||
// ========== 2. 远端通信 + 数据同步 ==========
|
||||
// 2.1 建立 WebSocket 长连接(跨 Tab 持续保持,不因路由切换断开)
|
||||
wsStore.connect()
|
||||
// 2.2 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 friendStore / groupStore 补会话 name/avatar;
|
||||
// 发送人名渲染时再走 utils/user 实时算,不依赖这里的 store 数据,但避免冷启动期间 ConversationItem 显示 senderId 数字
|
||||
await Promise.all([
|
||||
friendStore.loadFriends().catch((e) => console.warn('[IM] 预拉好友失败', e)),
|
||||
groupStore.loadGroups().catch((e) => console.warn('[IM] 预拉群列表失败', e))
|
||||
try {
|
||||
// TODO @AI:1 和 2,是不是改成 1.1 1.2;先拉取本地缓存。拉不到在拉远端数据。感觉更清晰一些。
|
||||
// 1. IDB 并发恢复(loadConversations 返回 void;load{Friends,Groups} 返回是否有缓存)
|
||||
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
|
||||
conversationStore.loadConversations(),
|
||||
friendStore.loadFriends(),
|
||||
groupStore.loadGroups()
|
||||
])
|
||||
// 2.3 增量拉取离线消息(私聊 + 群聊,使用各自 minId 游标);pullOnce finally 里把 loading 归位
|
||||
|
||||
// TODO @AI:SWR 这个注释,看看怎么更好的理解。
|
||||
// TODO @AI:下面这个注释,感觉没啥层次感。
|
||||
// 2. SWR 刷新:有缓存背景刷;无缓存必须 await + 抛错中断——否则 pullOnce 会用 senderId
|
||||
// 数字给会话起名落到 IDB 后续很难自愈。无缓存分支两个 fetch 并发 Promise.all 省一个 RTT
|
||||
const requiredFetches: Promise<unknown>[] = []
|
||||
if (hasCachedFriends) {
|
||||
void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e))
|
||||
} else {
|
||||
requiredFetches.push(friendStore.fetchFriends())
|
||||
}
|
||||
if (hasCachedGroups) {
|
||||
void groupStore.fetchGroups().catch((e) => console.warn('[IM] 后台刷群列表失败', e))
|
||||
} else {
|
||||
requiredFetches.push(groupStore.fetchGroups())
|
||||
}
|
||||
if (requiredFetches.length > 0) {
|
||||
await Promise.all(requiredFetches)
|
||||
}
|
||||
// TODO @AI:3.1 3.2 是不是一起。一个是 websocket 加载数据;一个是加载离线消息。本质是解决实时通信;
|
||||
// 3. 数据就绪后再 connect——无缓存 fetch 失败会走外层 catch 提前 return,避免 WS 已连
|
||||
// 但 friend/group store 空,handle*Message 用 senderId 数字落库
|
||||
wsStore.connect()
|
||||
// 4. 拉离线消息;pullOnce finally 里把 loading 归位
|
||||
await pullOnce()
|
||||
|
||||
// ========== 3. 默认视图 ==========
|
||||
// 3.1 默认选中第一个会话(仅在消息 Tab 可见)
|
||||
// 5. 默认选中第一个会话
|
||||
const sorted = conversationStore.getSortedConversations
|
||||
if (sorted.length > 0 && !conversationStore.activeConversation) {
|
||||
conversationStore.setActiveConversation(sorted[0])
|
||||
}
|
||||
} catch (e) {
|
||||
// TODO @AI:注释可以写的超过一行;尽量换行的时候,是一个事情写完,不然读起来很累。【其他地方也是!!!】例子如下:
|
||||
// TODO !首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断。
|
||||
// TODO WS 不在这里 disconnect —— 路由离开走 onUnmounted 自然清理,用户也可以刷新重试
|
||||
// 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),
|
||||
// 否则后续 saveConversations 全被早 return 阻断。WS 不在这里 disconnect——
|
||||
// 路由离开走 onUnmounted 自然清理,用户也可以刷新重试
|
||||
conversationStore.loading = false
|
||||
console.error('[IM] 初始化失败', e)
|
||||
}
|
||||
})
|
||||
|
||||
/** 离开 IM 主壳:主动断 WS(disconnect 内部已清掉 onclose 防自动重连) */
|
||||
|
|
@ -81,6 +111,7 @@ onUnmounted(() => {
|
|||
wsStore.disconnect()
|
||||
})
|
||||
|
||||
// TODO @AI:要说下,当前对话的处理。因为不涉及其他对话呀。
|
||||
/**
|
||||
* 会话切换时自动标记为已读 + 私聊下拉对方已读位置:
|
||||
* - 立刻清零本地未读
|
||||
|
|
|
|||
|
|
@ -108,13 +108,18 @@ const isActive = computed(
|
|||
|
||||
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
|
||||
|
||||
/** 最后一条消息发送者的展示名:按 conversation 上下文走 WeChat 优先级实时算 */
|
||||
/** 最后一条消息发送者的展示名:实时算 + 快照 fallback(getSenderDisplayName 算不出时兜底) */
|
||||
const lastSenderDisplayName = computed(() => {
|
||||
const senderId = props.conversation.lastSenderId
|
||||
if (!senderId) {
|
||||
return ''
|
||||
}
|
||||
return getSenderDisplayName(senderId, props.conversation.type, props.conversation.targetId)
|
||||
return getSenderDisplayName(
|
||||
senderId,
|
||||
props.conversation.type,
|
||||
props.conversation.targetId,
|
||||
props.conversation.lastSenderDisplayName
|
||||
)
|
||||
})
|
||||
|
||||
/** 群聊 + 有最后发送者 + 最后一条是普通消息 时,显示发送者前缀 */
|
||||
|
|
@ -146,7 +151,8 @@ const lastContentDisplay = computed(() => {
|
|||
props.conversation.lastSenderId,
|
||||
!!props.conversation.lastSelfSend,
|
||||
props.conversation.type,
|
||||
props.conversation.targetId
|
||||
props.conversation.targetId,
|
||||
props.conversation.lastSenderDisplayName
|
||||
)
|
||||
}
|
||||
return props.conversation.lastContent
|
||||
|
|
@ -177,6 +183,7 @@ function handleTop() {
|
|||
)
|
||||
}
|
||||
|
||||
// TODO @AI:这块注释,会不会台复杂了。
|
||||
/**
|
||||
* 切换免打扰:乐观 UI(先落本地,再异步推后端),失败回滚 + 提示
|
||||
*
|
||||
|
|
|
|||
|
|
@ -493,7 +493,8 @@ const showGroupReadStatus = computed(() => {
|
|||
/**
|
||||
* 当前群成员(供 MessageReadStatus 计算未读列表用)
|
||||
*
|
||||
* 群成员是按需懒加载到 groupStore(loadGroupMembers),未加载完 group?.members 为 undefined →
|
||||
* // TODO @AI:下面的注释,会不会台复杂了。是不是拿到方法体里?
|
||||
* 群成员是按需懒加载到 groupStore(loadGroupMembers / fetchGroupMembers),未加载完 group?.members 为 undefined →
|
||||
* 兜底空数组,MessageReadStatus 拿空数组就不渲染未读名单,不会出错
|
||||
*/
|
||||
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
|
||||
|
|
|
|||
|
|
@ -212,25 +212,33 @@ const groupFriends = computed<FriendLite[]>(() =>
|
|||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* 切换到群会话时,自动从后端拉取 group / members / 好友(store 内自带缓存)
|
||||
*
|
||||
* 三件事各自 fire-and-forget + 各自 catch:之前用 Promise.all 时任意一项失败会让其它
|
||||
* 已成功的结果只记一条笼统日志,丢掉具体出错点。这里拆开,谁挂谁单独记,不互相牵连。
|
||||
* 错误日志把 groupId 一起带上,多群环境下排查问题能直接定位
|
||||
*/
|
||||
function ensureGroupData(groupId: number) {
|
||||
groupStore.loadGroupInfo(groupId).catch((error) => {
|
||||
console.warn('[IM MessagePanel] loadGroupInfo 失败', { groupId }, error)
|
||||
// TODO @AI:SWR 这个缩写,大家不一定看的懂。
|
||||
/** 切换到群会话时按 SWR 同步群 / 成员 / 好友;各自 fire-and-forget + catch,任何一项失败不牵连其它 */
|
||||
async function ensureGroupData(groupId: number) {
|
||||
// TODO @AI:从远程异步拉取群信息,保证数据是最新的
|
||||
groupStore.fetchGroupInfo(groupId).catch((error) => {
|
||||
console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error)
|
||||
})
|
||||
groupStore.loadGroupMembers(groupId).catch((error) => {
|
||||
|
||||
// TODO @AI:先从 IDB 同步加载群成员,保证首帧就有成员名 / 头像;这样注释更好?
|
||||
// 先吃 IDB 让首帧立即出成员名 / 头像
|
||||
await groupStore.loadGroupMembers(groupId).catch((error) => {
|
||||
console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error)
|
||||
return null
|
||||
})
|
||||
friendStore.loadFriends().catch((error) => {
|
||||
console.warn('[IM MessagePanel] loadFriends 失败', { groupId }, error)
|
||||
// TODO @AI:再从远程异步拉取群成员信息,保证数据是最新的
|
||||
// force=true 跳过上一行刚塞进 in-memory 的短路,保证每次进群拿到最新成员状态
|
||||
groupStore.fetchGroupMembers(groupId, true).catch((error) => {
|
||||
console.warn('[IM MessagePanel] fetchGroupMembers 失败', { groupId }, error)
|
||||
})
|
||||
|
||||
// TODO @AI:每次切换群,不用拉取 friend 吧?开销太大了。只要首屏拉取就行了呀。
|
||||
friendStore.fetchFriends().catch((error) => {
|
||||
console.warn('[IM MessagePanel] fetchFriends 失败', { groupId }, error)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO @AI:是不是只要说,刷新就好了。然后下面的 await 相关注释,写到方法体里。
|
||||
/**
|
||||
* 群信息抽屉里点"刷新"等触发:强拉一次最新群元数据 + 群成员(force=true 跳过缓存)
|
||||
*
|
||||
|
|
@ -241,8 +249,8 @@ function reloadGroupData() {
|
|||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return
|
||||
}
|
||||
groupStore.loadGroupInfo(conversation.targetId)
|
||||
groupStore.loadGroupMembers(conversation.targetId, true)
|
||||
groupStore.fetchGroupInfo(conversation.targetId)
|
||||
groupStore.fetchGroupMembers(conversation.targetId, true)
|
||||
}
|
||||
|
||||
const historyVisible = ref(false)
|
||||
|
|
|
|||
|
|
@ -112,12 +112,14 @@ const friends = computed<FriendLite[]>(() =>
|
|||
|
||||
/** 加好友成功后强制刷新好友列表,让群聊弹窗的勾选项也能看到新好友 */
|
||||
async function handleFriendAdded() {
|
||||
await friendStore.loadFriends(true)
|
||||
// TODO @AI:添加完后,不要重新啥新,成本太高了。。。
|
||||
await friendStore.fetchFriends(true)
|
||||
}
|
||||
|
||||
/** 建群成功后刷新群列表,并直接打开新群会话(自动选中并渲染到右侧 MessagePanel) */
|
||||
async function handleGroupCreated(groupId: number) {
|
||||
await groupStore.loadGroups(true)
|
||||
// TODO @AI:建群成功后,是不是可以不加载 group;按道理说,新建完,直接写入 groups 里面就好了。这里只负责 get 下;
|
||||
await groupStore.fetchGroups(true)
|
||||
const group = groupStore.getGroup(groupId)
|
||||
if (!group) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { toRaw } from 'vue'
|
||||
import { store } from '@/store'
|
||||
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
||||
|
||||
import {
|
||||
ImConversationType,
|
||||
|
|
@ -10,21 +9,50 @@ import {
|
|||
IM_AT_ALL_USER_ID,
|
||||
TIME_TIP_GAP_MS
|
||||
} from '../../utils/constants'
|
||||
import { imStorage, StorageKeys } from '../../utils/storage'
|
||||
import { getCurrentUserId, imStorage, safeImRemove, StorageKeys } from '../../utils/storage'
|
||||
import { generateClientMessageId, parseRecallMessageId } from '../../utils/message'
|
||||
import { resolveConversationLastContent } from '../../utils/conversation'
|
||||
import { getSenderDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from './groupStore'
|
||||
import type { Conversation, ConversationStoreMeta, Message } from '../types'
|
||||
|
||||
// TODO @AI:这个是不是 user.ts 增加一个类似的方法。只有解析到,才返回,没解析到,就返回 undefined 的。然后需要的地方,自己按需 set 到 conversation 里?
|
||||
/**
|
||||
* 刷新 lastSenderDisplayName 快照——必须在调用方更新 conversation.lastSenderId
|
||||
* **之前**执行,靠它判断是否"同一发送人"决定旧快照保留 / 清空
|
||||
*/
|
||||
function refreshLastSenderDisplayName(conversation: Conversation, senderId: number): void {
|
||||
const liveSenderName = getSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
||||
const isRealName = liveSenderName !== String(senderId)
|
||||
const isSameSender = conversation.lastSenderId === senderId
|
||||
if (isRealName) {
|
||||
conversation.lastSenderDisplayName = liveSenderName
|
||||
return
|
||||
}
|
||||
|
||||
// 群聊算不出真名:单靠快照覆盖不了"换发送人 + members 没加载",主动补成员(store 内部已单飞)
|
||||
// TODO @AI:是不是可以增加一个,补齐单个人?这样,改造这个方法。支持传递 groupId + memberUserId;
|
||||
if (conversation.type === ImConversationType.GROUP) {
|
||||
useGroupStore()
|
||||
.fetchGroupMembers(conversation.targetId, true)
|
||||
.catch((e) =>
|
||||
console.warn(
|
||||
'[IM conversationStore] 兜底拉群成员失败',
|
||||
{ groupId: conversation.targetId },
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 同发送人沿用旧快照(冷拉期间常见),换人则清掉避免显示成上一个人
|
||||
if (!isSameSender) {
|
||||
conversation.lastSenderDisplayName = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
|
||||
// TODO @芋艿:首次拉取消息时,如果消息过多,可能导致渲染卡顿。(1% 场景)
|
||||
|
||||
/** 获取当前登录用户编号 */
|
||||
function getCurrentUserId(): number {
|
||||
const { wsCache } = useCache()
|
||||
const user = wsCache.get(CACHE_KEY.USER)?.user
|
||||
return Number(user?.id) || 0
|
||||
}
|
||||
|
||||
export const useConversationStore = defineStore('imConversationStore', {
|
||||
state: () => ({
|
||||
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
|
||||
|
|
@ -104,7 +132,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
try {
|
||||
const messages =
|
||||
(await imStorage.getItem<Message[]>(
|
||||
StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId)
|
||||
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId)
|
||||
)) || []
|
||||
// 发送中状态的消息标记为失败:重启后不可能仍处在发送中
|
||||
messages.forEach((message) => {
|
||||
|
|
@ -167,7 +195,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
// 不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢)
|
||||
tasks.push(
|
||||
imStorage.setItem(
|
||||
StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId),
|
||||
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
|
||||
toRaw(conversation.messages)
|
||||
)
|
||||
)
|
||||
|
|
@ -182,9 +210,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
if (!userId) {
|
||||
return
|
||||
}
|
||||
void imStorage
|
||||
.removeItem(StorageKeys.conversationMessage(userId, type, targetId))
|
||||
.catch((e) => console.error('[IM] 本地消息缓存删除失败', e))
|
||||
safeImRemove(
|
||||
StorageKeys.conversationMessages(userId, type, targetId),
|
||||
'[IM] 本地消息缓存删除失败'
|
||||
)
|
||||
},
|
||||
|
||||
// ==================== 会话查找 / 打开 ====================
|
||||
|
|
@ -361,12 +390,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return
|
||||
}
|
||||
|
||||
// 2.1 更新会话摘要(lastContent / lastSendTime + 事实索引 lastSenderId / lastMessageType / lastSelfSend);
|
||||
// 发送人名不存快照,由 ConversationItem 渲染时通过 utils/user.getSenderDisplayName 实时算
|
||||
// 2.1 更新会话摘要 + 事实索引 + 发送人名快照
|
||||
refreshLastSenderDisplayName(conversation, messageInfo.senderId)
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
messageInfo,
|
||||
conversation.type,
|
||||
conversation.targetId
|
||||
conversation.targetId,
|
||||
conversation.lastSenderDisplayName
|
||||
)
|
||||
conversation.lastSendTime = messageInfo.sendTime || Date.now()
|
||||
conversation.lastSenderId = messageInfo.senderId
|
||||
|
|
@ -496,12 +526,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
// content 不再写撤回文案:渲染层走 buildRecallTip(senderId, selfSend, ...) 实时算
|
||||
// 这里清空,避免老 content 被误认为有效消息文本
|
||||
message.content = ''
|
||||
// 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引
|
||||
// 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引(lastSenderId 不变,沿用快照)
|
||||
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
message,
|
||||
conversation.type,
|
||||
conversation.targetId
|
||||
conversation.targetId,
|
||||
conversation.lastSenderDisplayName
|
||||
)
|
||||
conversation.lastMessageType = ImMessageType.RECALL
|
||||
}
|
||||
|
|
@ -613,16 +644,28 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return
|
||||
}
|
||||
conversation.messages.splice(index, 1)
|
||||
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引
|
||||
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引 + 发送人名快照
|
||||
if (index === conversation.messages.length) {
|
||||
const last = conversation.messages[conversation.messages.length - 1]
|
||||
conversation.lastContent = last
|
||||
? resolveConversationLastContent(last, conversation.type, conversation.targetId)
|
||||
: ''
|
||||
conversation.lastSendTime = last?.sendTime || conversation.lastSendTime
|
||||
conversation.lastSenderId = last?.senderId
|
||||
conversation.lastMessageType = last?.type
|
||||
conversation.lastSelfSend = last?.selfSend
|
||||
if (last) {
|
||||
refreshLastSenderDisplayName(conversation, last.senderId)
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
last,
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
conversation.lastSenderDisplayName
|
||||
)
|
||||
conversation.lastSendTime = last.sendTime || conversation.lastSendTime
|
||||
conversation.lastSenderId = last.senderId
|
||||
conversation.lastMessageType = last.type
|
||||
conversation.lastSelfSend = last.selfSend
|
||||
} else {
|
||||
conversation.lastContent = ''
|
||||
conversation.lastSenderDisplayName = undefined
|
||||
conversation.lastSenderId = undefined
|
||||
conversation.lastMessageType = undefined
|
||||
conversation.lastSelfSend = undefined
|
||||
}
|
||||
}
|
||||
this.saveConversations(conversation)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@/api/im/friend'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import { getCurrentUserId, imStorage, safeImSet, StorageKeys } from '../../utils/storage'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
import type { Friend } from '../types'
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ import type { Friend } from '../types'
|
|||
export const useFriendStore = defineStore('imFriendStore', {
|
||||
state: () => ({
|
||||
friends: [] as Friend[],
|
||||
// 仅 fetchFriends 成功后置位;loadFriends(IDB)不置位,否则后台 SWR 刷新会被短路
|
||||
loaded: false
|
||||
}),
|
||||
|
||||
|
|
@ -50,8 +52,45 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
},
|
||||
|
||||
actions: {
|
||||
/** 从后端拉取并覆盖本地列表;同步刷新对应私聊会话的展示名 / 头像 */
|
||||
async loadFriends(force = false) {
|
||||
// ==================== 本地缓存 ====================
|
||||
|
||||
// TODO @AI:是不是不用 “不更新 conversationStore——会话缓存和好友缓存是同一会话写入的,名字头像天然一致” 注释。只要说明 boolean 是啥就行了把。
|
||||
/**
|
||||
* 从 IDB 恢复好友列表,返回 boolean 给首屏 SWR 决策用
|
||||
*
|
||||
* 不更新 conversationStore——会话缓存和好友缓存是同一会话写入的,名字头像天然一致
|
||||
*/
|
||||
async loadFriends(): Promise<boolean> {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const cached = await imStorage.getItem<Friend[]>(StorageKeys.friends(userId))
|
||||
if (!cached || cached.length === 0) {
|
||||
return false
|
||||
}
|
||||
this.friends = cached
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn('[IM friendStore] 本地好友缓存读取失败', e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/** 整桶持久化好友列表(量级有限,不维护增量) */
|
||||
saveFriends(): void {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
safeImSet(StorageKeys.friends(userId), this.friends, '[IM friendStore] 本地好友缓存写入失败')
|
||||
},
|
||||
|
||||
// ==================== 远端拉取 ====================
|
||||
|
||||
/** 从后端拉取并覆盖本地列表;同步刷新对应私聊会话的展示名 / 头像 + 落 IDB */
|
||||
async fetchFriends(force = false) {
|
||||
if (this.loaded && !force) {
|
||||
return
|
||||
}
|
||||
|
|
@ -60,13 +99,14 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
this.loaded = true
|
||||
// 同步 conversationStore 私聊会话的展示名 / 头像 / 免打扰
|
||||
const conversationStore = useConversationStore()
|
||||
for (const f of this.friends) {
|
||||
conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, {
|
||||
name: getFriendDisplayName(f),
|
||||
avatar: f.avatar,
|
||||
muted: f.muted
|
||||
for (const friend of this.friends) {
|
||||
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
|
||||
name: getFriendDisplayName(friend),
|
||||
avatar: friend.avatar,
|
||||
muted: friend.muted
|
||||
})
|
||||
}
|
||||
this.saveFriends()
|
||||
},
|
||||
|
||||
/** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */
|
||||
|
|
@ -82,7 +122,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 loadFriends 刷新) */
|
||||
/** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 fetchFriends 刷新) */
|
||||
async addFriend(friendUserId: number, preview?: Partial<Friend>) {
|
||||
await apiAddFriend(friendUserId)
|
||||
if (preview) {
|
||||
|
|
@ -127,6 +167,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
avatar: friend.avatar,
|
||||
muted: friend.muted
|
||||
})
|
||||
this.saveFriends()
|
||||
},
|
||||
|
||||
/** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */
|
||||
|
|
@ -140,6 +181,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
// 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.removePrivateConversation(friendUserId)
|
||||
this.saveFriends()
|
||||
},
|
||||
|
||||
/** 切换免打扰 */
|
||||
|
|
@ -148,6 +190,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
const friend = this.getFriend(friendUserId)
|
||||
if (friend) {
|
||||
friend.muted = muted
|
||||
this.saveFriends()
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -168,10 +211,11 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
|
||||
name: getFriendDisplayName(friend)
|
||||
})
|
||||
this.saveFriends()
|
||||
}
|
||||
},
|
||||
|
||||
/** 切换用户时清空 */
|
||||
/** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
|
||||
clear() {
|
||||
this.friends = []
|
||||
this.loaded = false
|
||||
|
|
|
|||
|
|
@ -11,11 +11,26 @@ import {
|
|||
updateGroupMember as apiUpdateGroupMember,
|
||||
type ImGroupMemberRespVO
|
||||
} from '@/api/im/group/member'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import {
|
||||
getCurrentUserId,
|
||||
imStorage,
|
||||
safeImRemove,
|
||||
safeImSet,
|
||||
StorageKeys
|
||||
} from '../../utils/storage'
|
||||
import type { Group, GroupMember } from '../types'
|
||||
|
||||
/**
|
||||
* fetchGroupMembers 单飞表:同 groupId 并发只打一次接口
|
||||
*
|
||||
* key 必须带 userId——账号切换时 A 的 in-flight 不能被 B 复用,否则 IIFE 内部
|
||||
* 的 saveGroupMembers 会把 A 的成员数据写进 B 的 IDB 桶
|
||||
*/
|
||||
const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
|
||||
const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}`
|
||||
|
||||
/**
|
||||
* IM 群 Store
|
||||
*
|
||||
|
|
@ -27,6 +42,7 @@ import type { Group, GroupMember } from '../types'
|
|||
export const useGroupStore = defineStore('imGroupStore', {
|
||||
state: () => ({
|
||||
groups: [] as Group[],
|
||||
// 仅 fetchGroups 成功后置位;loadGroups(IDB)不置位,否则后台 SWR 刷新会被短路
|
||||
loaded: false
|
||||
}),
|
||||
|
||||
|
|
@ -39,15 +55,115 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
},
|
||||
|
||||
actions: {
|
||||
/** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 */
|
||||
async loadGroups(force = false) {
|
||||
// ==================== 本地缓存 ====================
|
||||
|
||||
// TODO @AI:简化注释,参考 friendStore;
|
||||
/**
|
||||
* 从 IDB 恢复群列表(不带 members),返回 boolean 给首屏 SWR 决策用
|
||||
*
|
||||
* 不更新 conversationStore——会话缓存和群缓存是同一会话写入的,名字头像天然一致
|
||||
*/
|
||||
async loadGroups(): Promise<boolean> {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const cached = await imStorage.getItem<Group[]>(StorageKeys.groups(userId))
|
||||
if (!cached || cached.length === 0) {
|
||||
return false
|
||||
}
|
||||
this.groups = cached
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn('[IM groupStore] 本地群缓存读取失败', e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/** 整桶持久化群列表;剥离 members 字段,成员另走 groupMembers:${groupId} 分桶 */
|
||||
saveGroups(): void {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest)
|
||||
safeImSet(
|
||||
StorageKeys.groups(userId),
|
||||
groupsWithoutMembers,
|
||||
'[IM groupStore] 本地群缓存写入失败'
|
||||
)
|
||||
},
|
||||
|
||||
// TODO @AI:命中返回数组(caller 紧接渲染省一次二次访问),未命中返回 null 是不是没必要注释?只是说返回结果而已。。。
|
||||
/** 从 IDB 恢复指定群成员;命中返回数组(caller 紧接渲染省一次二次访问),未命中返回 null */
|
||||
async loadGroupMembers(groupId: number): Promise<GroupMember[] | null> {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return null
|
||||
}
|
||||
// in-memory 已就位(同会话二次进群 / fetchGroupMembers 已跑过):直接复用
|
||||
const cachedInMemory = this.getGroup(groupId)?.members
|
||||
if (cachedInMemory && cachedInMemory.length > 0) {
|
||||
return cachedInMemory
|
||||
}
|
||||
try {
|
||||
const cached = await imStorage.getItem<GroupMember[]>(
|
||||
StorageKeys.groupMembers(userId, groupId)
|
||||
)
|
||||
if (!cached || cached.length === 0) {
|
||||
return null
|
||||
}
|
||||
// 把 IDB 拿到的成员落到对应 group
|
||||
const group = this.getGroup(groupId)
|
||||
if (!group) {
|
||||
// group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup 。
|
||||
// 原因:避免把假名灌进 conversation.name + groups IDB 桶;等 fetchGroups 浅合并时被真名覆盖
|
||||
this.groups.push({
|
||||
id: groupId,
|
||||
name: '',
|
||||
members: cached,
|
||||
memberCount: cached.length
|
||||
})
|
||||
} else {
|
||||
group.members = cached
|
||||
group.memberCount = cached.length
|
||||
}
|
||||
return cached
|
||||
} catch (e) {
|
||||
console.warn('[IM groupStore] 本地群成员缓存读取失败', { groupId }, e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/** 整桶持久化指定群成员 */
|
||||
saveGroupMembers(groupId: number): void {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
const members = this.getGroup(groupId)?.members
|
||||
if (!members) {
|
||||
return
|
||||
}
|
||||
safeImSet(
|
||||
StorageKeys.groupMembers(userId, groupId),
|
||||
members,
|
||||
`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`
|
||||
)
|
||||
},
|
||||
|
||||
// ==================== 远端拉取 ====================
|
||||
|
||||
/** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */
|
||||
async fetchGroups(force = false) {
|
||||
if (this.loaded && !force) {
|
||||
return
|
||||
}
|
||||
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers)
|
||||
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers)
|
||||
const list = await apiGetMyGroupList()
|
||||
const fresh = (list || []).map(convertGroup)
|
||||
// 合并而非全量替换:保留 loadGroupMembers 已经写入的 members / memberCount / muted
|
||||
// 合并而非全量替换:保留 loadGroupMembers / fetchGroupMembers 已经写入的 members / memberCount / muted
|
||||
// (这些字段不在 ImGroupRespVO 里,全量替换会把成员级数据全冲掉)
|
||||
const groupMap = new Map(this.groups.map((group) => [group.id, group]))
|
||||
this.groups = fresh.map((group) => {
|
||||
|
|
@ -71,10 +187,11 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
muted: group.muted
|
||||
})
|
||||
}
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
|
||||
async loadGroupInfo(groupId: number) {
|
||||
async fetchGroupInfo(groupId: number) {
|
||||
try {
|
||||
const data = await apiGetGroup(groupId)
|
||||
if (!data) {
|
||||
|
|
@ -82,47 +199,51 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
}
|
||||
this.upsertGroup(convertGroup(data))
|
||||
} catch (e) {
|
||||
console.warn('[IM groupStore] loadGroupInfo 失败', e)
|
||||
console.warn('[IM groupStore] fetchGroupInfo 失败', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 按群拉取成员(带缓存,force=true 强制刷新)
|
||||
*
|
||||
* 1. 缓存:group 已加载且 members 就绪 → 直接返回
|
||||
* 2. 拉取 + 转换:调 /im/group-member/list 后映射成本地 GroupMember
|
||||
* 3. 回填当前用户的 muted:
|
||||
* 后端只在成员维度返回 muted(apiGetMyGroupList 不带),借这次拉成员把它落到 group / conversation;
|
||||
* 否则冷启动 / 清缓存后,服务端已免打扰的群在会话列表里仍显示为未免打扰
|
||||
* 4. 落地(关键:race-safe 重新 getGroup):
|
||||
* apiGetGroupMemberList 期间 loadGroups 可能已经把真实 group 填进 store,
|
||||
* 沿用入口快照会让我们错走 4.1 分支、把真实 name 覆盖成 String(groupId)
|
||||
* 4.1 group 还没就位(loadGroupMembers 跑在 loadGroups 之前)→ 占位 upsertGroup
|
||||
* 4.2 group 已就位 → 直接写 members 字段,并把 muted 单独推回 conversation
|
||||
*/
|
||||
async loadGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
|
||||
// 1. 缓存
|
||||
// TODO @AI:in-flight 单飞;这个注释有点奇怪
|
||||
/** 按群拉取成员(in-memory 缓存 + in-flight 单飞,force=true 强刷)+ 落 IDB */
|
||||
fetchGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
|
||||
const cached = this.getGroup(groupId)
|
||||
if (cached && cached.members && !force) {
|
||||
return cached.members
|
||||
return Promise.resolve(cached.members)
|
||||
}
|
||||
|
||||
// 2. 拉取 + 转换
|
||||
const requestUserId = getCurrentUserId()
|
||||
if (!requestUserId) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
// TODO @AI:最好这里注释下。
|
||||
const key = pendingMemberKey(requestUserId, groupId)
|
||||
const inflight = pendingMemberFetches.get(key)
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
const promise = (async () => {
|
||||
// TODO @AI:这里是不是要注释下
|
||||
const list = await apiGetGroupMemberList(groupId)
|
||||
const members = (list || []).map((member) => convertGroupMember(member, groupId))
|
||||
// 网络往返期间用户可能已切——A 的数据写到 B 的 store / IDB 是数据互串,丢弃
|
||||
// TODO @AI:这个应该不存在把?有点过度设计了。
|
||||
if (getCurrentUserId() !== requestUserId) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 3. 回填 muted
|
||||
const userStore = useUserStore()
|
||||
const currentUserId = Number(userStore.getUser?.id) || 0
|
||||
const me = members.find((m) => m.userId === currentUserId)
|
||||
// muted 是成员维度字段(apiGetMyGroupList 不带),借这次拉成员回填到 group / conversation
|
||||
const me = members.find((m) => m.userId === requestUserId)
|
||||
const muted = !!me?.muted
|
||||
|
||||
// 4. 落地(必须 await 之后重新 getGroup,避免踩 race)
|
||||
// 必须 await 之后重新 getGroup,避免 fetchGroups 已并发写入真实 group 的 race
|
||||
const group = this.getGroup(groupId)
|
||||
const isPlaceholder = !group
|
||||
let mutedChanged = false
|
||||
if (!group) {
|
||||
this.upsertGroup({
|
||||
// group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup
|
||||
// 避免把假名灌进 conversation.name + groups IDB 桶。等 fetchGroups 浅合并时被真名覆盖
|
||||
this.groups.push({
|
||||
id: groupId,
|
||||
name: String(groupId),
|
||||
name: '',
|
||||
members,
|
||||
memberCount: members.length,
|
||||
muted
|
||||
|
|
@ -130,11 +251,28 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
} else {
|
||||
group.members = members
|
||||
group.memberCount = members.length
|
||||
// TODO @AI:这里最好注释下。
|
||||
if (group.muted !== muted) {
|
||||
group.muted = muted
|
||||
mutedChanged = true
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, groupId, { muted })
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @AI:“避免批量进群 fan-out 时重复重写整桶”调整下。fan-out 不太好理解。
|
||||
// groups 桶仅在 muted 实际变化时写——避免批量进群 fan-out 时重复重写整桶
|
||||
this.saveGroupMembers(groupId)
|
||||
if (!isPlaceholder && mutedChanged) {
|
||||
this.saveGroups()
|
||||
}
|
||||
return members
|
||||
// TODO @AI:finally 最注释下,好理解;
|
||||
})().finally(() => pendingMemberFetches.delete(key))
|
||||
|
||||
// TODO @AI:这里是不是要注释下
|
||||
pendingMemberFetches.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
|
||||
/** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把 name / avatar / muted 推到对应会话 */
|
||||
|
|
@ -145,12 +283,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
} else {
|
||||
this.groups.push(group)
|
||||
}
|
||||
// TODO @AI:这里注释下
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
|
||||
name: group.name,
|
||||
avatar: group.avatar,
|
||||
muted: group.muted
|
||||
})
|
||||
// TODO @AI:这里注释下
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 本地移除(由 WebSocket GROUP_DEL 事件触发) */
|
||||
|
|
@ -159,21 +300,36 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
this.groups = this.groups.filter((g) => g.id !== id)
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.removeGroupConversation(id)
|
||||
this.saveGroups()
|
||||
// TODO @AI:避免 IDB 留 orphan;注释调整下,orphan 有点不好理解。
|
||||
// 把对应的群成员桶物理删掉,避免 IDB 留 orphan
|
||||
const userId = getCurrentUserId()
|
||||
if (userId) {
|
||||
safeImRemove(
|
||||
StorageKeys.groupMembers(userId, id),
|
||||
`[IM groupStore] 群成员缓存删除失败 (groupId=${id})`
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
/** 切换免打扰:推后端 + 落本地 */
|
||||
async setMuted(id: number, muted: boolean) {
|
||||
await apiUpdateGroupMember({ groupId: id, muted })
|
||||
const group = this.getGroup(id)
|
||||
if (group) {
|
||||
group.muted = muted
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.muted = muted
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
/** 切换用户时清空 */
|
||||
/** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
|
||||
clear() {
|
||||
this.groups = []
|
||||
this.loaded = false
|
||||
// TODO @AI:in-flight 这种调整下,不好理解。
|
||||
// 旧账号的 in-flight 即便 resolve 也会被 IIFE 内部的 userId 校验丢弃,索性清掉避免悬挂
|
||||
pendingMemberFetches.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
// 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底)
|
||||
const group = groupStore.getGroup(websocketMessage.groupId)
|
||||
if (!group) {
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
}
|
||||
|
||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
||||
|
|
@ -512,13 +512,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
/** GROUP_CREATE:本端入群(建群 / 被拉入);拉取群详情入库 */
|
||||
handleGroupCreate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** GROUP_UPDATE:群信息变更,重新拉一次群详情 */
|
||||
handleGroupUpdate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** GROUP_DELETE:群解散 / 自己退群 / 被踢出;本端清除群 + 级联清理群聊会话 */
|
||||
|
|
@ -527,10 +527,15 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
groupStore.removeGroup(websocketMessage.groupId)
|
||||
},
|
||||
|
||||
/** GROUP_MEMBER_UPDATE:多端同步自己在某群的成员属性变更(当前主要是免打扰);重新拉群详情 */
|
||||
/**
|
||||
* GROUP_MEMBER_UPDATE:多端同步成员属性变更(昵称 / 免打扰 / 退群等)
|
||||
*
|
||||
* 必须强刷成员而非群元数据——这些字段都在 ImGroupMemberRespVO 上,apiGetMyGroupList 不带;
|
||||
* 持久化后若不强刷,IDB 成员桶会长期陈旧
|
||||
*/
|
||||
handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
groupStore.fetchGroupMembers(websocketMessage.groupId, true).catch(() => undefined)
|
||||
},
|
||||
|
||||
// ==================== 心跳 / 重连 ====================
|
||||
|
|
|
|||
|
|
@ -49,14 +49,13 @@ export interface Conversation {
|
|||
lastSendTime: number // 最后一条消息时间,用于排序
|
||||
unreadCount: number // 未读数
|
||||
messages: Message[] // 消息列表
|
||||
/**
|
||||
* 最后一条消息的事实索引("谁、什么类型、是不是我发的")
|
||||
* 给会话列表前缀 / 撤回摘要等位置实时算展示文案——发送人名走 utils/user.getSenderDisplayName,
|
||||
* 永远不存名字快照,改备注 / 改群昵称后所有界面会自动响应式刷新
|
||||
*/
|
||||
// TODO @AI:lastMessage 对象,会不会更干净一点。然后把需要的字段放进去?
|
||||
/** 最后一条消息的事实索引;展示名实时算(getSenderDisplayName),不存名字快照 */
|
||||
lastSenderId?: number
|
||||
lastMessageType?: number
|
||||
lastSelfSend?: boolean
|
||||
/** 发送人显示名快照——仅作 getSenderDisplayName 算不出名字时的 fallback */
|
||||
lastSenderDisplayName?: string
|
||||
|
||||
// ========== UI 状态 ==========
|
||||
deleted?: boolean // 是否已删除(软删标记,持久化时过滤)
|
||||
|
|
@ -130,10 +129,11 @@ export interface GroupMember {
|
|||
userId: number // 用户编号
|
||||
avatar?: string // 头像
|
||||
nickname: string // 用户昵称
|
||||
// TODO @AI:还不是把 muted 字段是不是放到 Group 里?displayUserName、displayGroupName、muted;
|
||||
displayUserName?: string // 组内显示名(不与 nickname 合并,由消费方按需取舍)
|
||||
displayGroupName?: string // 群显示备注(当前用户对该群的自定义名)
|
||||
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
|
||||
muted?: boolean // 当前成员对该群的免打扰开关(loadGroupMembers 用它回填 Group.muted)
|
||||
muted?: boolean // 当前成员对该群的免打扰开关(fetchGroupMembers 用它回填 Group.muted)
|
||||
|
||||
// ========== 前端扩展字段 ==========
|
||||
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
// ====================================================================
|
||||
// IM 会话 / 撤回展示 utility
|
||||
// ====================================================================
|
||||
// TODO @AI:这里的注释,不用写历史,只写当下。
|
||||
// 职责:基于会话上下文 + sender 信息实时算"展示文案"。
|
||||
// 之前这些值是写入消息时固化到 Message.senderShowName / Conversation.senderShowName,
|
||||
// 改备注 / 改群昵称后历史消息不会刷新;改成实时算后字段语义彻底干净。
|
||||
// 历史上 Message / Conversation 上有 senderShowName 快照字段,改备注 / 改群昵称后历史消息
|
||||
// 不会刷新;现在 Message 不再带任何名字快照,发送人名一律由 utils/user.getSenderDisplayName
|
||||
// 实时算。Conversation.lastSenderDisplayName 仅作 fallback 快照(解决"没打开过的群"
|
||||
// members 没加载时的兜底显示),通过 fallback 参数透传到本文件的 buildRecallTip /
|
||||
// resolveConversationLastContent 而非内部硬编码读取
|
||||
//
|
||||
// 与 utils/user.ts 的关系:
|
||||
// user.ts 回答"谁叫什么名字",conversation.ts 在它基础上拼"撤回 tip / 摘要"等文案
|
||||
|
|
@ -14,35 +18,33 @@ import { parseMessage, resolveTipText, type TextMessage } from './message'
|
|||
import { getSenderDisplayName } from './user'
|
||||
import type { Message } from '../home/types'
|
||||
|
||||
/**
|
||||
* 撤回提示文案:自己撤回固定 "你撤回了一条消息",对方撤回带按 WeChat 优先级算的发送人名
|
||||
*
|
||||
* 发送人名一律实时算(改备注 / 改群昵称后立即刷新),store 没 ready 时由
|
||||
* getSenderDisplayName 内部退到 String(senderId),最差兜底显示"对方"
|
||||
*/
|
||||
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallback 兜底) */
|
||||
// TODO @AI:fallbackName
|
||||
export function buildRecallTip(
|
||||
senderId: number,
|
||||
selfSend: boolean,
|
||||
conversationType: number,
|
||||
conversationTargetId: number
|
||||
conversationTargetId: number,
|
||||
fallback?: string
|
||||
): string {
|
||||
if (selfSend) {
|
||||
return '你撤回了一条消息'
|
||||
}
|
||||
const senderDisplayName = getSenderDisplayName(senderId, conversationType, conversationTargetId)
|
||||
const senderDisplayName = getSenderDisplayName(
|
||||
senderId,
|
||||
conversationType,
|
||||
conversationTargetId,
|
||||
fallback
|
||||
)
|
||||
return `${senderDisplayName || '对方'} 撤回了一条消息`
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息类型计算会话列表最后一条摘要
|
||||
*
|
||||
* RECALL 分支走实时 buildRecallTip(不再依赖 message 上的 senderShowName 快照);
|
||||
* 其它分支照旧由 message.content 派生
|
||||
*/
|
||||
/** 会话列表最后一条摘要:RECALL 走 buildRecallTip + fallback;其它按消息类型派生 */
|
||||
export function resolveConversationLastContent(
|
||||
message: Message,
|
||||
conversationType: number,
|
||||
conversationTargetId: number
|
||||
conversationTargetId: number,
|
||||
fallback?: string
|
||||
): string {
|
||||
switch (message.type) {
|
||||
case ImMessageType.IMAGE:
|
||||
|
|
@ -58,7 +60,8 @@ export function resolveConversationLastContent(
|
|||
message.senderId,
|
||||
message.selfSend,
|
||||
conversationType,
|
||||
conversationTargetId
|
||||
conversationTargetId,
|
||||
fallback
|
||||
)
|
||||
case ImMessageType.TEXT:
|
||||
return parseMessage<TextMessage>(message.content)?.content ?? ''
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import localforage from 'localforage'
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
||||
|
||||
/**
|
||||
* IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage)
|
||||
|
|
@ -19,10 +22,11 @@ export const imStorage = localforage.createInstance({
|
|||
/**
|
||||
* 存储 key 统一在此生成
|
||||
*
|
||||
* - 会话相关(meta / message)走 imStorage(IndexedDB),key 形如 `conversation:xxx:{userId}:...`
|
||||
* - 会话 / 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
||||
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
||||
*
|
||||
* 所有会话相关 key 都注入 userId:多账号切换时按用户隔离,避免数据互串。
|
||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离,避免数据互串;账号切换时只清 in-memory,
|
||||
* IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||
*/
|
||||
export const StorageKeys = {
|
||||
/**
|
||||
|
|
@ -41,9 +45,37 @@ export const StorageKeys = {
|
|||
* 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大。
|
||||
* 软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留。
|
||||
*/
|
||||
conversationMessage: (userId: number | string, type: number, targetId: number) =>
|
||||
`conversation:message:${userId}:${type}:${targetId}`,
|
||||
conversationMessages: (userId: number | string, type: number, targetId: number) =>
|
||||
`conversation:messages:${userId}:${type}:${targetId}`,
|
||||
|
||||
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
|
||||
friends: (userId: number | string) => `friends:${userId}`,
|
||||
/** 群列表整桶(不含 members,剥离到独立 key),保证整桶写不带成员爆量 */
|
||||
groups: (userId: number | string) => `groups:${userId}`,
|
||||
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
|
||||
groupMembers: (userId: number | string, groupId: number) =>
|
||||
`groupMembers:${userId}:${groupId}`,
|
||||
|
||||
/** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */
|
||||
asideWidth: 'im:aside'
|
||||
} as const
|
||||
|
||||
/** 取当前登录用户编号;返回 0 表示未登录,调用方一律早 return 不写无主 key */
|
||||
export function getCurrentUserId(): number {
|
||||
const { wsCache } = useCache()
|
||||
const user = wsCache.get(CACHE_KEY.USER)?.user
|
||||
return Number(user?.id) || 0
|
||||
}
|
||||
|
||||
/** IDB 写入:fire-and-forget */
|
||||
// TODO @AI:setQuietly?会不会更好?
|
||||
export function safeImSet(key: string, value: unknown, errorLabel: string): void {
|
||||
// toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
|
||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
||||
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
|
||||
}
|
||||
|
||||
// TODO @AI:removeQuietly?会不会更好?
|
||||
export function safeImRemove(key: string, errorLabel: string): void {
|
||||
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,26 +46,26 @@ export function getMemberDisplayName(
|
|||
return resolveRemark(friend) || member.displayUserName || member.nickname
|
||||
}
|
||||
|
||||
// TODO @AI:fallbackName?这样更清晰?
|
||||
/**
|
||||
* 消息发送者「显示名」:渲染时实时算,按 conversation 上下文走 WeChat 优先级
|
||||
* 消息发送者显示名:渲染时实时算,按 WeChat 优先级
|
||||
*
|
||||
* - 自己(senderId === currentUserId):当前用户真实昵称
|
||||
* - 自己:userStore.nickname
|
||||
* - 私聊对方:好友备注 > 真实昵称
|
||||
* - 群聊对方:好友备注 > 群备注(displayUserName) > 真实昵称
|
||||
* - 查不到(store 还没 ready / 陌生人):兜底返回 String(senderId)
|
||||
*
|
||||
* 用在所有"展示给用户看的发送人名"位置(气泡上方、群聊列表前缀、撤回 tip 等)。
|
||||
* 不写入 message 字段——改备注 / 改群昵称后历史消息能跟着 Vue 响应式自动刷新
|
||||
* - 查不到:fallback || String(senderId)
|
||||
*/
|
||||
export function getSenderDisplayName(
|
||||
senderId: number,
|
||||
conversationType: number,
|
||||
conversationTargetId: number
|
||||
conversationTargetId: number,
|
||||
fallback?: string
|
||||
): string {
|
||||
// TODO @AI:getCurrentUserId;貌似可以复用;
|
||||
const userStore = useUserStore()
|
||||
const selfId = Number(userStore.getUser?.id) || 0
|
||||
const selfId = Number(userStore.getUser?.id) || 0 // TODO @AI:selfUserId 更好一点;
|
||||
|
||||
// 群聊场景所有人(含自己)都走 member + friend 三级——自己设了"我在本群昵称"也要生效
|
||||
// 自己也走 member 分支:要尊重"我在本群昵称"(GroupMember.displayUserName)
|
||||
if (conversationType === ImConversationType.GROUP) {
|
||||
const group = useGroupStore().getGroup(conversationTargetId)
|
||||
const member = group?.members?.find((m) => m.userId === senderId)
|
||||
|
|
@ -73,32 +73,33 @@ export function getSenderDisplayName(
|
|||
const friend = useFriendStore().getFriend(senderId)
|
||||
return getMemberDisplayName(member, friend)
|
||||
}
|
||||
// member 没加载到——self 兜底走 userStore,对方兜底走 senderId 字符串
|
||||
// member 没加载——self 走 userStore,对方走 fallback
|
||||
if (senderId === selfId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
return userStore.getUser?.nickname || fallback || String(senderId)
|
||||
}
|
||||
return String(senderId)
|
||||
return fallback || String(senderId)
|
||||
}
|
||||
|
||||
// 私聊场景:自己直接走 userStore;对方走好友备注 > 真实昵称
|
||||
if (conversationType === ImConversationType.PRIVATE) {
|
||||
if (senderId === selfId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
return userStore.getUser?.nickname || fallback || String(senderId)
|
||||
}
|
||||
const friend = useFriendStore().getFriend(senderId)
|
||||
if (friend) {
|
||||
return getFriendDisplayName(friend)
|
||||
}
|
||||
return String(senderId)
|
||||
return fallback || String(senderId)
|
||||
}
|
||||
|
||||
// 未知会话类型兜底
|
||||
if (senderId === selfId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
return userStore.getUser?.nickname || fallback || String(senderId)
|
||||
}
|
||||
return String(senderId)
|
||||
return fallback || String(senderId)
|
||||
}
|
||||
|
||||
// TODO @AI:是不是参考 getSenderDisplayName 注释风格。- xxx - xxx;
|
||||
/**
|
||||
* 消息发送者「真实昵称」:永远是 nickname,不掺备注
|
||||
*
|
||||
|
|
@ -110,6 +111,7 @@ export function getSenderRealNickname(
|
|||
conversationType: number,
|
||||
conversationTargetId: number
|
||||
): string {
|
||||
// TODO @AI:getCurrentUserId;貌似可以复用;
|
||||
const userStore = useUserStore()
|
||||
const selfId = Number(userStore.getUser?.id) || 0
|
||||
|
||||
|
|
@ -126,6 +128,7 @@ export function getSenderRealNickname(
|
|||
return String(senderId)
|
||||
}
|
||||
|
||||
// TODO @AI:这里要注释下么?
|
||||
if (conversationType === ImConversationType.PRIVATE) {
|
||||
if (senderId === selfId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
|
|
@ -134,6 +137,7 @@ export function getSenderRealNickname(
|
|||
return friend?.nickname || String(senderId)
|
||||
}
|
||||
|
||||
// TODO @AI:这里要注释下么?
|
||||
if (senderId === selfId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue