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