feat(im): 完善 friend、group 相关的本地存储(疯狂优化)

im
YunaiV 2026-04-29 22:03:54 +08:00
parent e90f9e5237
commit 4b64153044
15 changed files with 385 additions and 331 deletions

View File

@ -53,8 +53,11 @@ export const removeGroupMember = (data: ImGroupMemberRemoveReqVO) => {
} }
// 获得群成员详情 // 获得群成员详情
export const getGroupMember = (id: number | string) => { export const getGroupMember = (groupId: number, userId: number) => {
return request.get<ImGroupMemberRespVO>({ url: '/im/group-member/get', params: { id } }) return request.get<ImGroupMemberRespVO>({
url: '/im/group-member/get',
params: { groupId, userId }
})
} }
// 获得指定群的成员列表(聚合 AdminUser 昵称 / 头像) // 获得指定群的成员列表(聚合 AdminUser 昵称 / 头像)

View File

@ -0,0 +1,49 @@
import request from '@/config/axios'
export interface ImManagerGroupVO {
id: number
name: string
avatar?: string
notice?: string
ownerUserId: number
ownerNickname?: string
memberCount?: number
status: number
banned: boolean
bannedReason?: string
bannedTime?: Date
dissolvedTime?: Date
createTime: Date
}
export interface ImManagerGroupMemberVO {
userId: number
nickname?: string
avatar?: string
joinTime?: Date
}
// 获得群分页
export const getManagerGroupPage = (params: PageParam) => {
return request.get({ url: '/im/manager/group/page', params })
}
// 获得群详情
export const getManagerGroup = (id: number) => {
return request.get({ url: '/im/manager/group/get?id=' + id })
}
// 封禁群
export const banManagerGroup = (data: { id: number; reason: string }) => {
return request.put({ url: '/im/manager/group/ban', data })
}
// 解封群
export const unbanManagerGroup = (id: number) => {
return request.put({ url: '/im/manager/group/unban?id=' + id })
}
// 获得群成员列表
export const getManagerGroupMemberList = (groupId: number) => {
return request.get({ url: '/im/manager/group/member/list?groupId=' + groupId })
}

View File

@ -41,33 +41,26 @@ import ContextMenu from './components/ContextMenu.vue'
defineOptions({ name: 'ImIndex' }) defineOptions({ name: 'ImIndex' })
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
// TODO @AIwebSocketStore const webSocketStore = useImWebSocketStore()
const wsStore = useImWebSocketStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const { pullOnce } = useMessagePuller() const { pullOnce } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender() const { readActive, syncPrivateReadStatus } = useMessageSender()
/** 初始化:本地缓存恢复 → 远端通信/同步 → 默认视图 */ /** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
// TODO @AI /
onMounted(async () => { onMounted(async () => {
// TODO @AIWS WebSocket // 1.1 loading=true saveConversations + WebSocket connect pullOnce maxId pull 线
// loading=true saveConversations + WS
// connect pullOnce maxId pull 线
conversationStore.loading = true conversationStore.loading = true
try { try {
// TODO @AI1 2 1.1 1.2 // 1.2 store IDBloadConversations voidload{Friends,Groups}
// 1. IDB loadConversations voidload{Friends,Groups}
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([ const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
conversationStore.loadConversations(), conversationStore.loadConversations(),
friendStore.loadFriends(), friendStore.loadFriends(),
groupStore.loadGroups() groupStore.loadGroups()
]) ])
// TODO @AISWR // 2.1 IDB pullOnce
// TODO @AI // 2.2 / await + onMounted pullOnce senderId IDB fetch Promise.all RTT
// 2. SWR await + pullOnce senderId
// IDB fetch Promise.all RTT
const requiredFetches: Promise<unknown>[] = [] const requiredFetches: Promise<unknown>[] = []
if (hasCachedFriends) { if (hasCachedFriends) {
void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e)) void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e))
@ -82,41 +75,34 @@ onMounted(async () => {
if (requiredFetches.length > 0) { if (requiredFetches.length > 0) {
await Promise.all(requiredFetches) await Promise.all(requiredFetches)
} }
// TODO @AI3.1 3.2 websocket 线
// 3. connect fetch catch return WS // 3. WebSocket + 线pullOnce finally loading fetch catch return WebSocket friend/group store handle*Message senderId
// friend/group store handle*Message senderId webSocketStore.connect()
wsStore.connect()
// 4. 线pullOnce finally loading
await pullOnce() await pullOnce()
// 5. // 4.
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) { } catch (e) {
// TODO @AI // 1. loadingpullOnce finally saveConversations return
// TODO loadingpullOnce finally saveConversations return // 2. WebSocket disconnect onUnmounted
// TODO WS disconnect onUnmounted
// loadingpullOnce finally
// saveConversations return WS disconnect
// onUnmounted
conversationStore.loading = false conversationStore.loading = false
console.error('[IM] 初始化失败', e) console.error('[IM] 初始化失败', e)
} }
}) })
/** 离开 IM 主壳:主动断 WSdisconnect 内部已清掉 onclose 防自动重连) */ /** 离开 IM 主壳:主动断 WebSocketdisconnect 内部已清掉 onclose 防自动重连) */
onUnmounted(() => { onUnmounted(() => {
wsStore.disconnect() webSocketStore.disconnect()
}) })
// TODO @AI
/** /**
* 会话切换时自动标记为已读 + 私聊下拉对方已读位置 * 当前会话切换本地清零未读 + 上报后端已读 + 私聊补"对方已读到哪条"
* - 立刻清零本地未读 *
* - 同步后端已读状态服务端会广播 READ/RECEIPT 事件通知其它端与对方 * 只针对当前 active 会话做处理其它会话已读状态由 WebSocket READ/RECEIPT 事件被动同步
* - 私聊额外补一次对方已读到哪条弥补离线 / 多端漏掉的 RECEIPT 推送 * 私聊补一次拉对方已读位置弥补离线 / 多端漏掉的 RECEIPT 推送
*/ */
watch( watch(
() => conversationStore.activeConversation?.targetId, () => conversationStore.activeConversation?.targetId,

View File

@ -44,7 +44,7 @@
<div class="flex items-center mt-1 leading-5"> <div class="flex items-center mt-1 leading-5">
<!-- @红字提示atMe 优先于 atAll --> <!-- @红字提示atMe 优先于 atAll -->
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span> <span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span>
<!-- 群聊最后一条发送者前缀实时lastSenderId + 当前会话上下文算名字 --> <!-- 群聊最后一条发送者前缀lastSenderId + 当前会话上下文实时算名字 -->
<span <span
v-if="showSendName" v-if="showSendName"
class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap" class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap"
@ -122,7 +122,7 @@ const lastSenderDisplayName = computed(() => {
) )
}) })
/** 群聊 + 有最后发送者 + 最后一条是普通消息 时,显示发送者前缀 */ /** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀TIP_TIME / TIP_TEXT / RECALL 不带前缀) */
const showSendName = computed(() => { const showSendName = computed(() => {
if (!isGroup.value) { if (!isGroup.value) {
return false return false
@ -130,18 +130,11 @@ const showSendName = computed(() => {
if (!props.conversation.lastSenderId) { if (!props.conversation.lastSenderId) {
return false return false
} }
// lastMessageType messages TIP_TIME / TIP_TEXT / RECALL
const lastType = props.conversation.lastMessageType const lastType = props.conversation.lastMessageType
if (lastType == null) { return lastType != null && isNormalMessage(lastType)
return false
}
return isNormalMessage(lastType)
}) })
/** /** 列表展示文案:撤回类型实时算(避免改备注后老 lastContent 过期),其余直接用 lastContent */
* 列表展示文案撤回类型实时按 lastSenderId 避免改备注后老 lastContent 文案过期
* 其余类型直接用 conversation.lastContent按消息进来时固化的摘要
*/
const lastContentDisplay = computed(() => { const lastContentDisplay = computed(() => {
if ( if (
props.conversation.lastMessageType === ImMessageType.RECALL && props.conversation.lastMessageType === ImMessageType.RECALL &&
@ -183,15 +176,7 @@ function handleTop() {
) )
} }
// TODO @AI /** 切换免打扰:乐观 UI先本地切换菜单立即关后端失败回滚 conversation 状态) */
/**
* 切换免打扰乐观 UI先落本地再异步推后端失败回滚 + 提示
*
* awaitUI 已经通过 conversationStore.setMuted 完成视觉切换菜单立即关闭
* 后端 /im/friend/update / /im/group-member/update conversationStore
* 避免本地已经 saveConversations IndexedDB跟服务端长期不一致
* friend / group 自身的 setMuted await 失败时不会落本地只有 conversation 需要回滚
*/
function handleMuted() { function handleMuted() {
const next = !props.conversation.muted const next = !props.conversation.muted
const { type, targetId } = props.conversation const { type, targetId } = props.conversation

View File

@ -1,9 +1,6 @@
<template> <template>
<!-- TODO @AI@全部人的消息高亮在消息内容里 -->
<!-- <!--
布局约定DOM 顺序永远是头像在前 / 气泡在后对方消息走默认 row头像顶左 布局约定DOM 顺序永远是头像在前 / 气泡在后对方消息走默认 row头像顶左自己消息靠外层 flex-row-reverse 翻视觉头像顶右气泡在头像左侧跟微信对齐
自己消息靠外层 flex-row-reverse 翻视觉头像顶右气泡在头像左侧跟微信对齐
早先双 v-if 头像 + row-reverse 会让自己消息时气泡顶右头像反而在气泡左边
--> -->
<!-- 时间分隔线TIP_TIME=20居中灰色时间 --> <!-- 时间分隔线TIP_TIME=20居中灰色时间 -->
<div <div
@ -490,13 +487,7 @@ const showGroupReadStatus = computed(() => {
return status !== ImGroupReceiptStatus.NO_RECEIPT return status !== ImGroupReceiptStatus.NO_RECEIPT
}) })
/** /** 当前群成员(供 MessageReadStatus 计算未读名单;未加载完时兜底空数组不渲染) */
* 当前群成员 MessageReadStatus 计算未读列表用
*
* // TODO @AI
* 群成员是按需懒加载到 groupStoreloadGroupMembers / fetchGroupMembers未加载完 group?.members undefined
* 兜底空数组MessageReadStatus 拿空数组就不渲染未读名单不会出错
*/
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => { const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) { if (!conversation || conversation.type !== ImConversationType.GROUP) {

View File

@ -212,38 +212,25 @@ const groupFriends = computed<FriendLite[]>(() =>
})) }))
) )
// TODO @AISWR /** 切换到群会话时同步群信息 + 成员;各自 fire-and-forget + catch任何一项失败不牵连其它 */
/** 切换到群会话时按 SWR 同步群 / 成员 / 好友;各自 fire-and-forget + catch任何一项失败不牵连其它 */
async function ensureGroupData(groupId: number) { async function ensureGroupData(groupId: number) {
// TODO @AI // / /
groupStore.fetchGroupInfo(groupId).catch((error) => { groupStore.fetchGroupInfo(groupId).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error) console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error)
}) })
// TODO @AI IDB / // IDB /
// IDB /
await groupStore.loadGroupMembers(groupId).catch((error) => { await groupStore.loadGroupMembers(groupId).catch((error) => {
console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error) console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error)
return null return null
}) })
// TODO @AI // in-memory
// force=true in-memory
groupStore.fetchGroupMembers(groupId, true).catch((error) => { groupStore.fetchGroupMembers(groupId, true).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupMembers 失败', { groupId }, 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 跳过缓存
*
* 仅在当前会话仍是同一个群时执行避免 await 期间用户已经切走把别的群数据也跟着重拉
*/
function reloadGroupData() { function reloadGroupData() {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) { if (!conversation || conversation.type !== ImConversationType.GROUP) {
@ -254,13 +241,7 @@ function reloadGroupData() {
} }
const historyVisible = ref(false) const historyVisible = ref(false)
/** const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
* 信息抽屉的开关 MessagePanel 本地 UI 状态
*
* 群聊 / 私聊共用一个 ref模板里 v-if-else 决定挂哪个抽屉同一时刻只有一个组件
* DOM 所以一个布尔够用早先拆成 sideVisible + privateSideVisible 是冗余
*/
const sideVisible = ref(false)
/** 信息抽屉的 toggle跟 header 上 3 点图标按钮共用 */ /** 信息抽屉的 toggle跟 header 上 3 点图标按钮共用 */
function toggleSide() { function toggleSide() {
@ -384,7 +365,7 @@ watch(
newMessageCount.value = 0 newMessageCount.value = 0
showJumpToBottom.value = false showJumpToBottom.value = false
scrollToBottom() scrollToBottom()
// / / friendStore globally pull // / fetchFriends
if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) { if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) {
ensureGroupData(targetId) ensureGroupData(targetId)
} }

View File

@ -51,7 +51,7 @@
<MessagePanel /> <MessagePanel />
<!-- 添加朋友 / 发起群聊弹窗 --> <!-- 添加朋友 / 发起群聊弹窗 -->
<AddFriendDialog v-model="addFriendVisible" @added="handleFriendAdded" /> <AddFriendDialog v-model="addFriendVisible" />
<CreateGroupDialog <CreateGroupDialog
v-model="createGroupVisible" v-model="createGroupVisible"
:friends="friends" :friends="friends"
@ -110,20 +110,14 @@ const friends = computed<FriendLite[]>(() =>
})) }))
) )
/** 加好友成功后强制刷新好友列表,让群聊弹窗的勾选项也能看到新好友 */ /** 处理建群成功 */
async function handleFriendAdded() { function handleGroupCreated(groupId: number) {
// TODO @AI // CreateGroupDialog upsertGroup store get +
await friendStore.fetchFriends(true)
}
/** 建群成功后刷新群列表,并直接打开新群会话(自动选中并渲染到右侧 MessagePanel */
async function handleGroupCreated(groupId: number) {
// 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
} }
//
conversationStore.openConversation( conversationStore.openConversation(
groupId, groupId,
ImConversationType.GROUP, ImConversationType.GROUP,

View File

@ -9,45 +9,53 @@ 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 { getCurrentUserId, imStorage, safeImRemove, StorageKeys } from '../../utils/storage' import { getCurrentUserId, imStorage, removeQuietly, 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 { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore' 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 * lastSenderDisplayName caller conversation.lastSenderDisplayName
* ****"同一发送人" / *
* 1.
* 2. + 沿
* 3. + undefined
*
* store
*/ */
function refreshLastSenderDisplayName(conversation: Conversation, senderId: number): void { function deriveLastSenderDisplayName(
const liveSenderName = getSenderDisplayName(senderId, conversation.type, conversation.targetId) conversation: Conversation,
const isRealName = liveSenderName !== String(senderId) senderId: number
const isSameSender = conversation.lastSenderId === senderId ): string | undefined {
if (isRealName) { // 1. 严格版算名字:能拿到 displayUserName / 备注 / 真实昵称就直接用,对应规则 1
conversation.lastSenderDisplayName = liveSenderName const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
return if (liveSenderName) {
return liveSenderName
} }
// 群聊算不出真名:单靠快照覆盖不了"换发送人 + members 没加载"主动补成员store 内部已单飞) // 2. 群聊兜底拉成员:分两种情况
// TODO @AI是不是可以增加一个补齐单个人这样改造这个方法。支持传递 groupId + memberUserId // a. members 完全没加载(!membersLoaded→ 拉整群pullOnce 期间多个 senderId 都缺时,单飞表会 dedup 成一次请求)
// b. members 已加载但缺这一个(新加入的成员,本端未收到 GROUP_MEMBER_UPDATE→ 补齐这一个
if (conversation.type === ImConversationType.GROUP) { if (conversation.type === ImConversationType.GROUP) {
useGroupStore() const groupStore = useGroupStore()
.fetchGroupMembers(conversation.targetId, true) const group = groupStore.getGroup(conversation.targetId)
.catch((e) => const fetchPromise = group?.membersLoaded
console.warn( ? groupStore.fetchGroupMember(conversation.targetId, senderId)
'[IM conversationStore] 兜底拉群成员失败', : groupStore.fetchGroupMembers(conversation.targetId)
{ groupId: conversation.targetId }, fetchPromise.catch((e) =>
e console.warn(
) '[IM conversationStore] 兜底拉群成员失败',
{ groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
e
) )
)
} }
// 同发送人沿用旧快照(冷拉期间常见),换人则清掉避免显示成上一个人 // 3. 算不出真名:同发送人沿用旧快照(规则 2换人则清掉避免显示成上一个人规则 3
if (!isSameSender) { const isSameSender = conversation.lastSenderId === senderId
conversation.lastSenderDisplayName = undefined return isSameSender ? conversation.lastSenderDisplayName : undefined
}
} }
// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。 // TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
@ -163,8 +171,7 @@ export const useConversationStore = defineStore('imConversationStore', {
* - conversation meta + * - conversation meta +
* - meta + loading flush * - meta + loading flush
* *
* key * key catch UI void
* catch UI void
*/ */
saveConversations(target?: Conversation | Conversation[] | null): void { saveConversations(target?: Conversation | Conversation[] | null): void {
// loading 期间跳过,避免离线消息批量到达时的密集写入 // loading 期间跳过,避免离线消息批量到达时的密集写入
@ -191,8 +198,7 @@ export const useConversationStore = defineStore('imConversationStore', {
Array.isArray(target) ? target : target ? [target] : [] Array.isArray(target) ? target : target ? [target] : []
).filter((c) => !c.deleted) ).filter((c) => !c.deleted)
for (const conversation of conversationsToFlush) { for (const conversation of conversationsToFlush) {
// toRaw 拆掉 Vue reactive ProxyIDB 的 structuredClone 不接受 Proxy // toRaw 拆掉 Vue reactive ProxyIDB 的 structuredClone 不接受 Proxy不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去messages 永远丢)
// 不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去messages 永远丢)
tasks.push( tasks.push(
imStorage.setItem( imStorage.setItem(
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId), StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
@ -210,7 +216,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!userId) { if (!userId) {
return return
} }
safeImRemove( removeQuietly(
StorageKeys.conversationMessages(userId, type, targetId), StorageKeys.conversationMessages(userId, type, targetId),
'[IM] 本地消息缓存删除失败' '[IM] 本地消息缓存删除失败'
) )
@ -390,18 +396,20 @@ export const useConversationStore = defineStore('imConversationStore', {
return return
} }
// 2.1 更新会话摘要 + 事实索引 + 发送人名快照 // 2.1 更新会话摘要 + 最后一条消息事实索引(含发送人名快照)。
refreshLastSenderDisplayName(conversation, messageInfo.senderId) // deriveLastSenderDisplayName 必须在更新 lastSenderId 之前调用,靠旧值判断"同发送人"
const senderDisplayName = deriveLastSenderDisplayName(conversation, messageInfo.senderId)
conversation.lastContent = resolveConversationLastContent( conversation.lastContent = resolveConversationLastContent(
messageInfo, messageInfo,
conversation.type, conversation.type,
conversation.targetId, conversation.targetId,
conversation.lastSenderDisplayName senderDisplayName
) )
conversation.lastSendTime = messageInfo.sendTime || Date.now() conversation.lastSendTime = messageInfo.sendTime || Date.now()
conversation.lastSenderId = messageInfo.senderId conversation.lastSenderId = messageInfo.senderId
conversation.lastMessageType = messageInfo.type conversation.lastMessageType = messageInfo.type
conversation.lastSelfSend = messageInfo.selfSend conversation.lastSelfSend = messageInfo.selfSend
conversation.lastSenderDisplayName = senderDisplayName
// 2.2 群聊 @ 标记(仅对方消息 + 未读态有效) // 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
if ( if (
@ -523,10 +531,9 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
message.type = ImMessageType.RECALL message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL message.status = ImMessageStatus.RECALL
// content 不再写撤回文案:渲染层走 buildRecallTip(senderId, selfSend, ...) 实时算 // 清空 content撤回文案由渲染层 buildRecallTip 实时算,老 content 留着会被误认为有效消息文本
// 这里清空,避免老 content 被误认为有效消息文本
message.content = '' message.content = ''
// 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引lastSenderId 不变,沿用快照) // 最后一条消息是刚撤回的,才更新会话摘要 + lastMessageTypesenderId 不变,沿用旧快照)
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,
@ -644,27 +651,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]
if (last) { if (last) {
refreshLastSenderDisplayName(conversation, last.senderId) const senderDisplayName = deriveLastSenderDisplayName(conversation, last.senderId)
conversation.lastContent = resolveConversationLastContent( conversation.lastContent = resolveConversationLastContent(
last, last,
conversation.type, conversation.type,
conversation.targetId, conversation.targetId,
conversation.lastSenderDisplayName senderDisplayName
) )
conversation.lastSendTime = last.sendTime || conversation.lastSendTime conversation.lastSendTime = last.sendTime || conversation.lastSendTime
conversation.lastSenderId = last.senderId conversation.lastSenderId = last.senderId
conversation.lastMessageType = last.type conversation.lastMessageType = last.type
conversation.lastSelfSend = last.selfSend conversation.lastSelfSend = last.selfSend
conversation.lastSenderDisplayName = senderDisplayName
} else { } else {
conversation.lastContent = '' conversation.lastContent = ''
conversation.lastSenderDisplayName = undefined
conversation.lastSenderId = undefined conversation.lastSenderId = undefined
conversation.lastMessageType = undefined conversation.lastMessageType = undefined
conversation.lastSelfSend = undefined conversation.lastSelfSend = undefined
conversation.lastSenderDisplayName = undefined
} }
} }
this.saveConversations(conversation) this.saveConversations(conversation)

View File

@ -12,7 +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 { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getFriendDisplayName } from '../../utils/user' import { getFriendDisplayName } from '../../utils/user'
import type { Friend } from '../types' import type { Friend } from '../types'
@ -54,11 +54,10 @@ export const useFriendStore = defineStore('imFriendStore', {
actions: { actions: {
// ==================== 本地缓存 ==================== // ==================== 本地缓存 ====================
// TODO @AI是不是不用 “不更新 conversationStore——会话缓存和好友缓存是同一会话写入的名字头像天然一致” 注释。只要说明 boolean 是啥就行了把。
/** /**
* IDB boolean SWR * IDB
* *
* conversationStore * @return
*/ */
async loadFriends(): Promise<boolean> { async loadFriends(): Promise<boolean> {
const userId = getCurrentUserId() const userId = getCurrentUserId()
@ -84,7 +83,7 @@ export const useFriendStore = defineStore('imFriendStore', {
if (!userId) { if (!userId) {
return return
} }
safeImSet(StorageKeys.friends(userId), this.friends, '[IM friendStore] 本地好友缓存写入失败') setQuietly(StorageKeys.friends(userId), this.friends, '[IM friendStore] 本地好友缓存写入失败')
}, },
// ==================== 远端拉取 ==================== // ==================== 远端拉取 ====================
@ -197,8 +196,7 @@ export const useFriendStore = defineStore('imFriendStore', {
/** /**
* *
* *
* /im/friend/update friend + name * /im/friend/update friend + name UI /
* UI /
*/ */
async setDisplayName(friendUserId: number, displayName: string) { async setDisplayName(friendUserId: number, displayName: string) {
const value = displayName.trim() const value = displayName.trim()

View File

@ -7,6 +7,7 @@ import {
type ImGroupRespVO type ImGroupRespVO
} from '@/api/im/group' } from '@/api/im/group'
import { import {
getGroupMember as apiGetGroupMember,
getGroupMemberList as apiGetGroupMemberList, getGroupMemberList as apiGetGroupMemberList,
updateGroupMember as apiUpdateGroupMember, updateGroupMember as apiUpdateGroupMember,
type ImGroupMemberRespVO type ImGroupMemberRespVO
@ -16,21 +17,30 @@ import { ImConversationType } from '../../utils/constants'
import { import {
getCurrentUserId, getCurrentUserId,
imStorage, imStorage,
safeImRemove, removeQuietly,
safeImSet, setQuietly,
StorageKeys StorageKeys
} from '../../utils/storage' } from '../../utils/storage'
import { getGroupDisplayName } from '../../utils/user'
import type { Group, GroupMember } from '../types' import type { Group, GroupMember } from '../types'
/** /**
* fetchGroupMembers groupId * fetchGroupMembers groupId Promise
* *
* key userId A in-flight B IIFE * key userId A B IIFE saveGroupMembers A B IDB
* saveGroupMembers A B IDB
*/ */
const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>() const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}` const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}`
/**
* fetchGroupMember (groupId, memberUserId) Promise
*
* fetch fetch me muted
*/
const pendingSingleMemberFetches = new Map<string, Promise<GroupMember | null>>()
const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: number) =>
`${userId}:${groupId}:${memberUserId}`
/** /**
* IM Store * IM Store
* *
@ -57,12 +67,7 @@ export const useGroupStore = defineStore('imGroupStore', {
actions: { actions: {
// ==================== 本地缓存 ==================== // ==================== 本地缓存 ====================
// TODO @AI简化注释参考 friendStore /** 从 IDB 恢复群列表(不带 members返回是否命中缓存 */
/**
* IDB members boolean SWR
*
* conversationStore
*/
async loadGroups(): Promise<boolean> { async loadGroups(): Promise<boolean> {
const userId = getCurrentUserId() const userId = getCurrentUserId()
if (!userId) { if (!userId) {
@ -88,24 +93,24 @@ export const useGroupStore = defineStore('imGroupStore', {
return return
} }
const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest) const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest)
safeImSet( setQuietly(
StorageKeys.groups(userId), StorageKeys.groups(userId),
groupsWithoutMembers, groupsWithoutMembers,
'[IM groupStore] 本地群缓存写入失败' '[IM groupStore] 本地群缓存写入失败'
) )
}, },
// TODO @AI命中返回数组caller 紧接渲染省一次二次访问),未命中返回 null 是不是没必要注释?只是说返回结果而已。。。 /** 从 IDB 恢复指定群成员;命中返回成员数组,未命中返回 null */
/** 从 IDB 恢复指定群成员命中返回数组caller 紧接渲染省一次二次访问),未命中返回 null */
async loadGroupMembers(groupId: number): Promise<GroupMember[] | null> { async loadGroupMembers(groupId: number): Promise<GroupMember[] | null> {
const userId = getCurrentUserId() const userId = getCurrentUserId()
if (!userId) { if (!userId) {
return null return null
} }
// in-memory 已就位(同会话二次进群 / fetchGroupMembers 已跑过):直接复用 // in-memory 已"完整"加载fetchGroupMembers 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
const cachedInMemory = this.getGroup(groupId)?.members // 单成员补齐fetchGroupMember写进的 partial members 不在此短路——其 membersLoaded=false
if (cachedInMemory && cachedInMemory.length > 0) { const cachedGroup = this.getGroup(groupId)
return cachedInMemory if (cachedGroup?.members && cachedGroup.membersLoaded) {
return cachedGroup.members
} }
try { try {
const cached = await imStorage.getItem<GroupMember[]>( const cached = await imStorage.getItem<GroupMember[]>(
@ -117,17 +122,19 @@ export const useGroupStore = defineStore('imGroupStore', {
// 把 IDB 拿到的成员落到对应 group // 把 IDB 拿到的成员落到对应 group
const group = this.getGroup(groupId) const group = this.getGroup(groupId)
if (!group) { if (!group) {
// group 还没就位:仅 in-memory 占位name='' 表示未知),不调 upsertGroup // group 还没就位:仅 in-memory 占位name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
// 原因:避免把假名灌进 conversation.name + groups IDB 桶;等 fetchGroups 浅合并时被真名覆盖 // 后续,等 fetchGroups 浅合并时,被真名覆盖
this.groups.push({ this.groups.push({
id: groupId, id: groupId,
name: '', name: '',
members: cached, members: cached,
memberCount: cached.length memberCount: cached.length,
membersLoaded: true
}) })
} else { } else {
group.members = cached group.members = cached
group.memberCount = cached.length group.memberCount = cached.length
group.membersLoaded = true
} }
return cached return cached
} catch (e) { } catch (e) {
@ -146,7 +153,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!members) { if (!members) {
return return
} }
safeImSet( setQuietly(
StorageKeys.groupMembers(userId, groupId), StorageKeys.groupMembers(userId, groupId),
members, members,
`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})` `[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`
@ -163,8 +170,7 @@ export const useGroupStore = defineStore('imGroupStore', {
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers
const list = await apiGetMyGroupList() const list = await apiGetMyGroupList()
const fresh = (list || []).map(convertGroup) const fresh = (list || []).map(convertGroup)
// 合并而非全量替换:保留 loadGroupMembers / fetchGroupMembers 已经写入的 members / memberCount / muted // 合并而非全量替换:保留 user-per-group 字段muted / displayGroupName+ 成员缓存——这些字段不在 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) => {
const existing = groupMap.get(group.id) const existing = groupMap.get(group.id)
@ -175,14 +181,16 @@ export const useGroupStore = defineStore('imGroupStore', {
...group, ...group,
members: existing.members, members: existing.members,
memberCount: existing.memberCount ?? group.memberCount, memberCount: existing.memberCount ?? group.memberCount,
muted: existing.muted ?? group.muted muted: existing.muted ?? group.muted,
displayGroupName: existing.displayGroupName,
membersLoaded: existing.membersLoaded
} }
}) })
this.loaded = true this.loaded = true
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
for (const group of this.groups) { for (const group of this.groups) {
conversationStore.updateConversation(ImConversationType.GROUP, group.id, { conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
name: group.name, name: getGroupDisplayName(group),
avatar: group.avatar, avatar: group.avatar,
muted: group.muted muted: group.muted
}) })
@ -203,79 +211,143 @@ export const useGroupStore = defineStore('imGroupStore', {
} }
}, },
// TODO @AIin-flight 单飞;这个注释有点奇怪 /** 按群拉取成员in-memory 缓存 + 并发去重force=true 强刷)+ 落 IDB */
/** 按群拉取成员in-memory 缓存 + in-flight 单飞force=true 强刷)+ 落 IDB */
fetchGroupMembers(groupId: number, force = false): Promise<GroupMember[]> { fetchGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
// in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此短路membersLoaded=false
const cached = this.getGroup(groupId) const cached = this.getGroup(groupId)
if (cached && cached.members && !force) { if (cached && cached.members && cached.membersLoaded && !force) {
return Promise.resolve(cached.members) return Promise.resolve(cached.members)
} }
// 未登录:不发起请求也不登记 in-flight避免污染单飞表
const requestUserId = getCurrentUserId() const requestUserId = getCurrentUserId()
if (!requestUserId) { if (!requestUserId) {
return Promise.resolve([]) return Promise.resolve([])
} }
// TODO @AI最好这里注释下。 // 同 (userId, groupId) 已经有正在飞的请求:直接复用,避免重复打接口
const key = pendingMemberKey(requestUserId, groupId) const key = pendingMemberKey(requestUserId, groupId)
const inflight = pendingMemberFetches.get(key) const inflight = pendingMemberFetches.get(key)
if (inflight) { if (inflight) {
return inflight return inflight
} }
const promise = (async () => { const promise = (async () => {
// TODO @AI这里是不是要注释下 // 拉接口 + 单 pass 转换:同时捕获 me 的原始 VO给下面回填 user-per-group 字段muted / displayGroupName
// convertGroupMember 不带 muted / displayGroupName已搬到 Group 上),所以从 raw VO 里捞
const list = await apiGetGroupMemberList(groupId) const list = await apiGetGroupMemberList(groupId)
const members = (list || []).map((member) => convertGroupMember(member, groupId)) let meRaw: ImGroupMemberRespVO | undefined
// 网络往返期间用户可能已切——A 的数据写到 B 的 store / IDB 是数据互串,丢弃 const members = (list || []).map((member) => {
// TODO @AI这个应该不存在把有点过度设计了。 if (member.userId === requestUserId) {
if (getCurrentUserId() !== requestUserId) { meRaw = member
return [] }
} return convertGroupMember(member, groupId)
})
// muted 是成员维度字段apiGetMyGroupList 不带),借这次拉成员回填到 group / conversation const muted = !!meRaw?.muted
const me = members.find((m) => m.userId === requestUserId) const displayGroupName = meRaw?.displayGroupName || ''
const muted = !!me?.muted
// 必须 await 之后重新 getGroup避免 fetchGroups 已并发写入真实 group 的 race // 必须 await 之后重新 getGroup避免 fetchGroups 已并发写入真实 group 的 race
const group = this.getGroup(groupId) const group = this.getGroup(groupId)
const isPlaceholder = !group const isPlaceholder = !group
let mutedChanged = false let groupFieldsChanged = false
if (!group) { if (!group) {
// group 还没就位:仅 in-memory push 占位name='' 表示未知),不调 upsertGroup // group 还没就位:仅 in-memory push 占位name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
// 避免把假名灌进 conversation.name + groups IDB 桶。等 fetchGroups 浅合并时被真名覆盖 // 后续,等 fetchGroups 浅合并时,被真名覆盖
this.groups.push({ this.groups.push({
id: groupId, id: groupId,
name: '', name: '',
members, members,
memberCount: members.length, memberCount: members.length,
muted muted,
displayGroupName,
membersLoaded: true
}) })
} else { } else {
group.members = members group.members = members
group.memberCount = members.length group.memberCount = members.length
// TODO @AI这里最好注释下。 group.membersLoaded = true
if (group.muted !== muted) { // muted / displayGroupName 任一变化就同步到 conversation + 触发 saveGroups
// 后续displayGroupName 变化要刷会话名("我对该群的备注"是会话列表的展示名)
if (group.muted !== muted || group.displayGroupName !== displayGroupName) {
group.muted = muted group.muted = muted
mutedChanged = true group.displayGroupName = displayGroupName
groupFieldsChanged = true
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, groupId, { muted }) conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
name: getGroupDisplayName(group),
muted
})
} }
} }
// TODO @AI“避免批量进群 fan-out 时重复重写整桶”调整下。fan-out 不太好理解。 // groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写
// groups 桶仅在 muted 实际变化时写——避免批量进群 fan-out 时重复重写整桶
this.saveGroupMembers(groupId) this.saveGroupMembers(groupId)
if (!isPlaceholder && mutedChanged) { if (!isPlaceholder && groupFieldsChanged) {
this.saveGroups() this.saveGroups()
} }
return members return members
// TODO @AIfinally 最注释下,好理解; // 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise
})().finally(() => pendingMemberFetches.delete(key)) })().finally(() => pendingMemberFetches.delete(key))
// TODO @AI这里是不是要注释下 // 把 Promise 登记进单飞表,让此后短时间内的同 (userId, groupId) 请求复用
pendingMemberFetches.set(key, promise) pendingMemberFetches.set(key, promise)
return promise return promise
}, },
/** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把 name / avatar / muted 推到对应会话 */ /**
* (groupId, memberUserId) deriveLastSenderDisplayName
*
* fetchGroupMembers me muted / displayGroupName me
* upsert group.members IDB displayUserName
*/
fetchGroupMember(groupId: number, memberUserId: number): Promise<GroupMember | null> {
// in-memory 命中直接返回,不打接口
const cached = this.getGroup(groupId)?.members?.find((m) => m.userId === memberUserId)
if (cached) {
return Promise.resolve(cached)
}
// 未登录:不发起请求也不登记 in-flight避免污染单飞表
const requestUserId = getCurrentUserId()
if (!requestUserId) {
return Promise.resolve(null)
}
// 同 (userId, groupId, memberUserId) 已经有正在飞的请求:直接复用
const key = pendingSingleMemberKey(requestUserId, groupId, memberUserId)
const inflight = pendingSingleMemberFetches.get(key)
if (inflight) {
return inflight
}
const promise = (async () => {
const data = await apiGetGroupMember(groupId, memberUserId)
if (!data) {
return null
}
const member = convertGroupMember(data, groupId)
// 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底group 还没就位则用 placeholder
// 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMembers
const group = this.getGroup(groupId)
if (!group) {
// memberCount 不设:后续 fetchGroups 合并 `existing.memberCount ?? fresh.memberCount` 时,
// 占位值会顶替真实值fresh 不带 memberCount等 fetchGroupMembers 跑过才能拿到真实数
this.groups.push({
id: groupId,
name: '',
members: [member]
})
} else {
const memberList = group.members ?? []
const index = memberList.findIndex((m) => m.userId === memberUserId)
if (index >= 0) {
memberList[index] = member
} else {
memberList.push(member)
}
group.members = memberList
}
return member
})().finally(() => pendingSingleMemberFetches.delete(key))
pendingSingleMemberFetches.set(key, promise)
return promise
},
/** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把展示名 / 头像 / 免打扰推到对应会话 */
upsertGroup(group: Group) { upsertGroup(group: Group) {
const index = this.groups.findIndex((g) => g.id === group.id) const index = this.groups.findIndex((g) => g.id === group.id)
if (index >= 0) { if (index >= 0) {
@ -283,14 +355,15 @@ export const useGroupStore = defineStore('imGroupStore', {
} else { } else {
this.groups.push(group) this.groups.push(group)
} }
// TODO @AI这里注释下 // 同步推到 conversation群名 / 头像 / 免打扰是会话列表展示用的,必须紧随 group 变更
const merged = this.getGroup(group.id) ?? group
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, group.id, { conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
name: group.name, name: getGroupDisplayName(merged),
avatar: group.avatar, avatar: merged.avatar,
muted: group.muted muted: merged.muted
}) })
// TODO @AI这里注释下 // 持久化到 IDBfire-and-forget
this.saveGroups() this.saveGroups()
}, },
@ -301,11 +374,10 @@ export const useGroupStore = defineStore('imGroupStore', {
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
conversationStore.removeGroupConversation(id) conversationStore.removeGroupConversation(id)
this.saveGroups() this.saveGroups()
// TODO @AI避免 IDB 留 orphan注释调整下orphan 有点不好理解。 // 群解散后顺手删 IDB 里该群的成员桶——这桶仅靠 groupId 索引,不删会一直留在 IDB 占空间
// 把对应的群成员桶物理删掉,避免 IDB 留 orphan
const userId = getCurrentUserId() const userId = getCurrentUserId()
if (userId) { if (userId) {
safeImRemove( removeQuietly(
StorageKeys.groupMembers(userId, id), StorageKeys.groupMembers(userId, id),
`[IM groupStore] 群成员缓存删除失败 (groupId=${id})` `[IM groupStore] 群成员缓存删除失败 (groupId=${id})`
) )
@ -327,9 +399,9 @@ export const useGroupStore = defineStore('imGroupStore', {
clear() { clear() {
this.groups = [] this.groups = []
this.loaded = false this.loaded = false
// TODO @AIin-flight 这种调整下,不好理解。 // 单飞表跟 in-memory state 一起重置;旧账号 in-flight 的请求 finally 也会自己 delete key提前清空只是更干脆
// 旧账号的 in-flight 即便 resolve 也会被 IIFE 内部的 userId 校验丢弃,索性清掉避免悬挂
pendingMemberFetches.clear() pendingMemberFetches.clear()
pendingSingleMemberFetches.clear()
} }
} }
}) })
@ -352,9 +424,7 @@ function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): Group
nickname: member.nickname || String(member.userId), nickname: member.nickname || String(member.userId),
avatar: member.avatar, avatar: member.avatar,
displayUserName: member.displayUserName, displayUserName: member.displayUserName,
displayGroupName: member.displayGroupName, status: member.status
status: member.status,
muted: member.muted
} }
} }

View File

@ -530,8 +530,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/** /**
* GROUP_MEMBER_UPDATE / / 退 * GROUP_MEMBER_UPDATE / / 退
* *
* ImGroupMemberRespVO apiGetMyGroupList * ImGroupMemberRespVO apiGetMyGroupList IDB
* IDB
*/ */
handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) { handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) {
const groupStore = useGroupStore() const groupStore = useGroupStore()

View File

@ -45,17 +45,16 @@ export interface Conversation {
// ========== 展示字段 ========== // ========== 展示字段 ==========
name: string // 展示名称(私聊=好友昵称;群聊=群名) name: string // 展示名称(私聊=好友昵称;群聊=群名)
avatar: string // 头像 avatar: string // 头像
lastContent: string // 会话列表展示的最后一条消息摘要
lastSendTime: number // 最后一条消息时间,用于排序
unreadCount: number // 未读数 unreadCount: number // 未读数
messages: Message[] // 消息列表 messages: Message[] // 消息列表
// TODO @AIlastMessage 对象,会不会更干净一点。然后把需要的字段放进去?
/** 最后一条消息的事实索引展示名实时算getSenderDisplayName不存名字快照 */ // ========== 最后一条消息事实索引 ==========
lastSenderId?: number lastContent: string // 会话列表展示的最后一条消息摘要
lastMessageType?: number lastSendTime: number // 最后一条消息时间,用于排序
lastSelfSend?: boolean lastSenderId?: number // 发送人编号
/** 发送人显示名快照——仅作 getSenderDisplayName 算不出名字时的 fallback */ lastMessageType?: number // 消息类型,对齐 ImMessageType
lastSenderDisplayName?: string lastSelfSend?: boolean // 是否自己发的
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
// ========== UI 状态 ========== // ========== UI 状态 ==========
deleted?: boolean // 是否已删除(软删标记,持久化时过滤) deleted?: boolean // 是否已删除(软删标记,持久化时过滤)
@ -115,9 +114,11 @@ export interface Group {
notice?: string // 群公告 notice?: string // 群公告
ownerUserId?: number // 群主用户编号 ownerUserId?: number // 群主用户编号
// ========== 前端扩展字段 ========== // ========== 前端扩展字段user-per-group 维度) ==========
muted?: boolean // 是否免打扰(来自当前用户的 ImGroupMemberRespVO.muted muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
displayGroupName?: string // 群显示备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载) members?: GroupMember[] // 群成员缓存(按需懒加载)
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 短路时误判整群已加载
memberCount?: number // 成员总数 memberCount?: number // 成员总数
} }
@ -129,11 +130,8 @@ export interface GroupMember {
userId: number // 用户编号 userId: number // 用户编号
avatar?: string // 头像 avatar?: string // 头像
nickname: string // 用户昵称 nickname: string // 用户昵称
// TODO @AI还不是把 muted 字段是不是放到 Group 里displayUserName、displayGroupName、muted displayUserName?: string // 该成员在群内自定义昵称(每个 member 一份;不与 nickname 合并,由消费方按需取舍)
displayUserName?: string // 组内显示名(不与 nickname 合并,由消费方按需取舍)
displayGroupName?: string // 群显示备注(当前用户对该群的自定义名)
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
muted?: boolean // 当前成员对该群的免打扰开关fetchGroupMembers 用它回填 Group.muted
// ========== 前端扩展字段 ========== // ========== 前端扩展字段 ==========
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算) isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
@ -149,7 +147,7 @@ export interface Friend {
nickname: string // 好友昵称对方真实昵称永远不被备注覆盖UI 显示走 displayName || nickname nickname: string // 好友昵称对方真实昵称永远不被备注覆盖UI 显示走 displayName || nickname
avatar?: string // 好友头像 avatar?: string // 好友头像
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
displayName?: string // 好友展示备注:仅自己可见的别名(命名对齐 GroupMember.displayGroupName 风格,单字段不歧义就不带 Friend 前缀) displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录) status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)

View File

@ -1,16 +1,9 @@
// ==================================================================== // ====================================================================
// IM 会话 / 撤回展示 utility // IM 会话 / 撤回展示 utility
// ==================================================================== // ====================================================================
// TODO @AI这里的注释不用写历史只写当下。 // 基于 conversation 上下文 + sender 信息实时算"展示文案"。
// 职责:基于会话上下文 + sender 信息实时算"展示文案"。 // 1. 发送人名一律由 utils/user.getSenderDisplayName 实时算,本文件只负责拼"撤回 tip / 摘要"等文案。
// 历史上 Message / Conversation 上有 senderShowName 快照字段,改备注 / 改群昵称后历史消息 // 2. fallbackName 由调用方传入典型来源Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底
// 不会刷新;现在 Message 不再带任何名字快照,发送人名一律由 utils/user.getSenderDisplayName
// 实时算。Conversation.lastSenderDisplayName 仅作 fallback 快照(解决"没打开过的群"
// members 没加载时的兜底显示),通过 fallback 参数透传到本文件的 buildRecallTip /
// resolveConversationLastContent 而非内部硬编码读取
//
// 与 utils/user.ts 的关系:
// user.ts 回答"谁叫什么名字"conversation.ts 在它基础上拼"撤回 tip / 摘要"等文案
// ==================================================================== // ====================================================================
import { ImMessageType } from './constants' import { ImMessageType } from './constants'
@ -18,14 +11,13 @@ 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 兜底) */ /** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
// TODO @AIfallbackName
export function buildRecallTip( export function buildRecallTip(
senderId: number, senderId: number,
selfSend: boolean, selfSend: boolean,
conversationType: number, conversationType: number,
conversationTargetId: number, conversationTargetId: number,
fallback?: string fallbackName?: string
): string { ): string {
if (selfSend) { if (selfSend) {
return '你撤回了一条消息' return '你撤回了一条消息'
@ -34,17 +26,17 @@ export function buildRecallTip(
senderId, senderId,
conversationType, conversationType,
conversationTargetId, conversationTargetId,
fallback fallbackName
) )
return `${senderDisplayName || '对方'} 撤回了一条消息` return `${senderDisplayName || '对方'} 撤回了一条消息`
} }
/** 会话列表最后一条摘要RECALL 走 buildRecallTip + fallback;其它按消息类型派生 */ /** 会话列表最后一条摘要RECALL 走 buildRecallTip + fallbackName;其它按消息类型派生 */
export function resolveConversationLastContent( export function resolveConversationLastContent(
message: Message, message: Message,
conversationType: number, conversationType: number,
conversationTargetId: number, conversationTargetId: number,
fallback?: string fallbackName?: string
): string { ): string {
switch (message.type) { switch (message.type) {
case ImMessageType.IMAGE: case ImMessageType.IMAGE:
@ -61,7 +53,7 @@ export function resolveConversationLastContent(
message.selfSend, message.selfSend,
conversationType, conversationType,
conversationTargetId, conversationTargetId,
fallback fallbackName
) )
case ImMessageType.TEXT: case ImMessageType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? '' return parseMessage<TextMessage>(message.content)?.content ?? ''

View File

@ -10,8 +10,8 @@ import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
* 1. localStorage 5~10MB * 1. localStorage 5~10MB
* 2. localStorage key MB 线 * 2. localStorage key MB 线
* *
* key StorageKeys key * key StorageKeys key
* IndexedDB ~50% localStorage * IndexedDB ~50% localStorage
*/ */
export const imStorage = localforage.createInstance({ export const imStorage = localforage.createInstance({
name: 'im', name: 'im',
@ -25,15 +25,13 @@ export const imStorage = localforage.createInstance({
* - / / imStorageIndexedDBkey userId * - / / imStorageIndexedDBkey userId
* - UI localStorage Tab IndexedDB * - UI localStorage Tab IndexedDB
* *
* key userId in-memory * key userId in-memoryIDB / /
* IDB / /
*/ */
export const StorageKeys = { export const StorageKeys = {
/** /**
* + messages ConversationStoreMeta * + messages ConversationStoreMeta
* *
* top / muted / unread / / key * top / muted / unread / / key messages key KB
* messages key KB
*/ */
conversationMeta: (userId: number | string) => `conversation:meta:${userId}`, conversationMeta: (userId: number | string) => `conversation:meta:${userId}`,
/** /**
@ -42,8 +40,7 @@ export const StorageKeys = {
* - type / ImConversationType * - type / ImConversationType
* - targetId userId / groupId * - targetId userId / groupId
* *
* key"全量写所有会话所有消息" * key"全量写所有会话所有消息" conversationStore.removeConversationMessages key orphan
* conversationStore.removeConversationMessages key orphan
*/ */
conversationMessages: (userId: number | string, type: number, targetId: number) => conversationMessages: (userId: number | string, type: number, targetId: number) =>
`conversation:messages:${userId}:${type}:${targetId}`, `conversation:messages:${userId}:${type}:${targetId}`,
@ -68,14 +65,12 @@ export function getCurrentUserId(): number {
} }
/** IDB 写入fire-and-forget */ /** IDB 写入fire-and-forget */
// TODO @AIsetQuietly会不会更好 export function setQuietly(key: string, value: unknown, errorLabel: string): void {
export function safeImSet(key: string, value: unknown, errorLabel: string): void {
// toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘 // toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
const raw = value && typeof value === 'object' ? toRaw(value) : value const raw = value && typeof value === 'object' ? toRaw(value) : value
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e)) void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
} }
// TODO @AIremoveQuietly会不会更好 export function removeQuietly(key: string, errorLabel: string): void {
export function safeImRemove(key: string, errorLabel: string): void {
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e)) void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
} }

View File

@ -1,119 +1,126 @@
// ==================================================================== // ====================================================================
// IM 用户展示名 utility // IM 用户展示名 utility
// ==================================================================== // ====================================================================
// 职责:统一回答"某个用户在 UI 上应该叫什么名字"。 // 职责:统一回答"某个用户在 UI 上应该叫什么名字"。拆两层:
// 拆两层: // 1. 纯派生getFriendDisplayName / getMemberDisplayName / getGroupDisplayName输入 friend / member / group 对象,不查 store给 store 内部 / 各种组装点复用,避免逻辑散落
// 1. 纯派生getFriendDisplayName / getMemberDisplayName—— 输入 friend / member 对象, // 2. 上下文感知getSenderDisplayName / getSenderRealNickname / tryGetSenderDisplayName渲染时按 conversation 上下文实时查 friendStore / groupStore / userStore让备注 / 群昵称 / 真实昵称变更后所有历史消息立即响应式刷新
// 不查 store给 store 内部 / 各种组装点复用,避免逻辑散落
// 2. 上下文感知getSenderDisplayName / getSenderRealNickname—— 渲染时按
// conversation 上下文实时查 friendStore / groupStore / userStore让备注 / 群昵称 /
// 真实昵称变更后所有历史消息立即刷新(不再写"快照"到 message 字段里)
// //
// 命名约定:函数名一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐 // 命名约定:函数名一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// ==================================================================== // ====================================================================
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { ImConversationType } from './constants' import { ImConversationType } from './constants'
import { getCurrentUserId } from './storage'
import { useFriendStore } from '../home/store/friendStore' import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore' import { useGroupStore } from '../home/store/groupStore'
import type { Friend } from '../home/types' import type { Friend, Group } from '../home/types'
/** /**
* DISABLEdisplayName * >
* *
* displayName DISABLE
*/ */
function resolveRemark(friend?: Pick<Friend, 'displayName'> | null): string {
return friend?.displayName || ''
}
/** 私聊好友显示名:备注 > 真实昵称 */
export function getFriendDisplayName( export function getFriendDisplayName(
friend: Pick<Friend, 'nickname' | 'displayName'> friend: Pick<Friend, 'nickname' | 'displayName'>
): string { ): string {
return resolveRemark(friend) || friend.nickname return friend.displayName || friend.nickname
} }
/** /**
* > displayUserName > * > displayUserName >
* *
* WeChat "我" ta * WeChat "我" ta friend member
* friend member
*/ */
export function getMemberDisplayName( export function getMemberDisplayName(
member: { displayUserName?: string; nickname: string }, member: { displayUserName?: string; nickname: string },
friend?: Pick<Friend, 'displayName'> | null friend?: Pick<Friend, 'displayName'> | null
): string { ): string {
return resolveRemark(friend) || member.displayUserName || member.nickname return friend?.displayName || member.displayUserName || member.nickname
}
/** 群显示名当前用户对该群的备注displayGroupName > 群名name */
export function getGroupDisplayName(group: Pick<Group, 'name' | 'displayGroupName'>): string {
return group.displayGroupName || group.name
}
/**
* undefined
*
* 1. "是否真名" conversationStore lastSenderDisplayName fetchGroupMembers
* 2. GROUP member sender self undefinedself "真名" displayUserNamemembers userStore.nickname deriveLastSenderDisplayName
*/
export function tryGetSenderDisplayName(
senderId: number,
conversationType: number,
conversationTargetId: number
): string | undefined {
// GROUP 路径完全不查 userStore——member 在不在群直接决定结果
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
if (!member) {
return undefined
}
const friend = useFriendStore().getFriend(senderId)
return getMemberDisplayName(member, friend)
}
// PRIVATE / 未知会话类型self 走 userStore对方走 friend
if (senderId === getCurrentUserId()) {
return useUserStore().getUser?.nickname || undefined
}
if (conversationType === ImConversationType.PRIVATE) {
const friend = useFriendStore().getFriend(senderId)
return friend ? getFriendDisplayName(friend) : undefined
}
return undefined
} }
// TODO @AIfallbackName这样更清晰
/** /**
* WeChat * WeChat
* *
* - userStore.nickname * - userStore.nickname
* - > * - >
* - > displayUserName > * - > displayUserName >
* - fallback || String(senderId) * - fallbackName || (self userStore.nickname) || String(senderId)
*/ */
export function getSenderDisplayName( export function getSenderDisplayName(
senderId: number, senderId: number,
conversationType: number, conversationType: number,
conversationTargetId: number, conversationTargetId: number,
fallback?: string fallbackName?: string
): string { ): string {
// TODO @AIgetCurrentUserId貌似可以复用 const real = tryGetSenderDisplayName(senderId, conversationType, conversationTargetId)
if (real) {
return real
}
if (fallbackName) {
return fallbackName
}
// self 在 GROUP members 没加载时,至少用真实昵称兜底渲染(比 String(senderId) 友好);兜底拉成员由 conversationStore 触发,回来后 try 版本能命中真名自然刷新
const userStore = useUserStore() const userStore = useUserStore()
const selfId = Number(userStore.getUser?.id) || 0 // TODO @AIselfUserId 更好一点; if (senderId === getCurrentUserId()) {
return userStore.getUser?.nickname || String(senderId)
// 自己也走 member 分支:要尊重"我在本群昵称"GroupMember.displayUserName
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
if (member) {
const friend = useFriendStore().getFriend(senderId)
return getMemberDisplayName(member, friend)
}
// member 没加载——self 走 userStore对方走 fallback
if (senderId === selfId) {
return userStore.getUser?.nickname || fallback || String(senderId)
}
return fallback || String(senderId)
} }
return String(senderId)
// 私聊场景:自己直接走 userStore对方走好友备注 > 真实昵称
if (conversationType === ImConversationType.PRIVATE) {
if (senderId === selfId) {
return userStore.getUser?.nickname || fallback || String(senderId)
}
const friend = useFriendStore().getFriend(senderId)
if (friend) {
return getFriendDisplayName(friend)
}
return fallback || String(senderId)
}
// 未知会话类型兜底
if (senderId === selfId) {
return userStore.getUser?.nickname || fallback || String(senderId)
}
return fallback || String(senderId)
} }
// TODO @AI是不是参考 getSenderDisplayName 注释风格。- xxx - xxx
/** /**
* nickname * nickname
* *
* UserAvatar :name / alt * - userStore.nickname
* friend.nickname member.nickname userStore * - friend.nickname
* - member.nickname
*
* UserAvatar :name / alt
*/ */
export function getSenderRealNickname( export function getSenderRealNickname(
senderId: number, senderId: number,
conversationType: number, conversationType: number,
conversationTargetId: number conversationTargetId: number
): string { ): string {
// TODO @AIgetCurrentUserId貌似可以复用
const userStore = useUserStore() const userStore = useUserStore()
const selfId = Number(userStore.getUser?.id) || 0 const selfUserId = getCurrentUserId()
// 群聊先走 member.nicknameself 也是 member异常时再走 self / senderId 兜底 // 群聊先走 member.nicknameself 也是 member异常时再走 self / senderId 兜底
if (conversationType === ImConversationType.GROUP) { if (conversationType === ImConversationType.GROUP) {
@ -122,23 +129,21 @@ export function getSenderRealNickname(
if (member?.nickname) { if (member?.nickname) {
return member.nickname return member.nickname
} }
if (senderId === selfId) { if (senderId === selfUserId) {
return userStore.getUser?.nickname || String(senderId) return userStore.getUser?.nickname || String(senderId)
} }
return String(senderId) return String(senderId)
} }
// TODO @AI这里要注释下么
if (conversationType === ImConversationType.PRIVATE) { if (conversationType === ImConversationType.PRIVATE) {
if (senderId === selfId) { if (senderId === selfUserId) {
return userStore.getUser?.nickname || String(senderId) return userStore.getUser?.nickname || String(senderId)
} }
const friend = useFriendStore().getFriend(senderId) const friend = useFriendStore().getFriend(senderId)
return friend?.nickname || String(senderId) return friend?.nickname || String(senderId)
} }
// TODO @AI这里要注释下么 if (senderId === selfUserId) {
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId) return userStore.getUser?.nickname || String(senderId)
} }
return String(senderId) return String(senderId)