feat(im): 增加 friend、group 相关的本地存储

im
YunaiV 2026-04-29 15:50:49 +08:00
parent de39bc7fc1
commit e90f9e5237
13 changed files with 518 additions and 182 deletions

View File

@ -41,6 +41,7 @@ import ContextMenu from './components/ContextMenu.vue'
defineOptions({ name: 'ImIndex' })
const conversationStore = useConversationStore()
// TODO @AIwebSocketStore
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 @AIWS 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 / awaitpullOnce 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 @AI1 2 1.1 1.2
// 1. IDB loadConversations voidload{Friends,Groups}
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
conversationStore.loadConversations(),
friendStore.loadFriends(),
groupStore.loadGroups()
])
// 2.3 线 + 使 minId pullOnce finally loading
// TODO @AISWR
// 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 @AI3.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 loadingpullOnce finally saveConversations return
// TODO WS disconnect onUnmounted
// loadingpullOnce finally
// saveConversations return WS disconnect
// onUnmounted
conversationStore.loading = false
console.error('[IM] 初始化失败', e)
}
})
/** 离开 IM 主壳:主动断 WSdisconnect 内部已清掉 onclose 防自动重连) */
@ -81,6 +111,7 @@ onUnmounted(() => {
wsStore.disconnect()
})
// TODO @AI
/**
* 会话切换时自动标记为已读 + 私聊下拉对方已读位置
* - 立刻清零本地未读

View File

@ -108,13 +108,18 @@ const isActive = computed(
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
/** 最后一条消息发送者的展示名:按 conversation 上下文走 WeChat 优先级实时算 */
/** 最后一条消息发送者的展示名:实时算 + 快照 fallbackgetSenderDisplayName 算不出时兜底) */
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先落本地再异步推后端失败回滚 + 提示
*

View File

@ -493,7 +493,8 @@ const showGroupReadStatus = computed(() => {
/**
* 当前群成员 MessageReadStatus 计算未读列表用
*
* 群成员是按需懒加载到 groupStoreloadGroupMembers未加载完 group?.members undefined
* // TODO @AI
* 群成员是按需懒加载到 groupStoreloadGroupMembers / fetchGroupMembers未加载完 group?.members undefined
* 兜底空数组MessageReadStatus 拿空数组就不渲染未读名单不会出错
*/
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {

View File

@ -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 @AISWR
/** 切换到群会话时按 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)

View File

@ -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

View File

@ -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)
},

View File

@ -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 成功后置位loadFriendsIDB不置位否则后台 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-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
this.friends = []
this.loaded = false

View File

@ -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 成功后置位loadGroupsIDB不置位否则后台 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
* mutedapiGetMyGroupList 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 @AIin-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 @AIfinally 最注释下,好理解;
})().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-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
this.groups = []
this.loaded = false
// TODO @AIin-flight 这种调整下,不好理解。
// 旧账号的 in-flight 即便 resolve 也会被 IIFE 内部的 userId 校验丢弃,索性清掉避免悬挂
pendingMemberFetches.clear()
}
}
})

View File

@ -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)
},
// ==================== 心跳 / 重连 ====================

View File

@ -49,14 +49,13 @@ export interface Conversation {
lastSendTime: number // 最后一条消息时间,用于排序
unreadCount: number // 未读数
messages: Message[] // 消息列表
/**
* "谁、什么类型、是不是我发的"
* / utils/user.getSenderDisplayName
* /
*/
// TODO @AIlastMessage 对象,会不会更干净一点。然后把需要的字段放进去?
/** 最后一条消息的事实索引展示名实时算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 计算)

View File

@ -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 @AIfallbackName
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 ?? ''

View File

@ -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 imStorageIndexedDBkey `conversation:xxx:{userId}:...`
* - / / imStorageIndexedDBkey 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 @AIsetQuietly会不会更好
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 @AIremoveQuietly会不会更好
export function safeImRemove(key: string, errorLabel: string): void {
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
}

View File

@ -46,26 +46,26 @@ export function getMemberDisplayName(
return resolveRemark(friend) || member.displayUserName || member.nickname
}
// TODO @AIfallbackName这样更清晰
/**
* 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 @AIgetCurrentUserId貌似可以复用
const userStore = useUserStore()
const selfId = Number(userStore.getUser?.id) || 0
const selfId = Number(userStore.getUser?.id) || 0 // TODO @AIselfUserId 更好一点;
// 群聊场景所有人(含自己)都走 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 @AIgetCurrentUserId貌似可以复用
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)
}