✨ feat(im): 完善 friend、group 相关的本地存储(疯狂优化)
parent
e90f9e5237
commit
4b64153044
|
|
@ -53,8 +53,11 @@ export const removeGroupMember = (data: ImGroupMemberRemoveReqVO) => {
|
|||
}
|
||||
|
||||
// 获得群成员详情
|
||||
export const getGroupMember = (id: number | string) => {
|
||||
return request.get<ImGroupMemberRespVO>({ url: '/im/group-member/get', params: { id } })
|
||||
export const getGroupMember = (groupId: number, userId: number) => {
|
||||
return request.get<ImGroupMemberRespVO>({
|
||||
url: '/im/group-member/get',
|
||||
params: { groupId, userId }
|
||||
})
|
||||
}
|
||||
|
||||
// 获得指定群的成员列表(聚合 AdminUser 昵称 / 头像)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -41,33 +41,26 @@ import ContextMenu from './components/ContextMenu.vue'
|
|||
defineOptions({ name: 'ImIndex' })
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
// TODO @AI:webSocketStore 全称更合适。
|
||||
const wsStore = useImWebSocketStore()
|
||||
const webSocketStore = useImWebSocketStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const { pullOnce } = useMessagePuller()
|
||||
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
||||
|
||||
/** 初始化:本地缓存恢复 → 远端通信/同步 → 默认视图 */
|
||||
// TODO @AI:上面的“初始化:本地缓存恢复 → 远端通信/同步 → 默认视图”,有点不好理解。
|
||||
/** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
|
||||
onMounted(async () => {
|
||||
// TODO @AI:WS 全称 WebSocket,不要缩写。其他地方也是
|
||||
// loading=true 整段阻断 saveConversations 抖动写盘 + WS 普通消息进缓冲,
|
||||
// 避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
conversationStore.loading = true
|
||||
try {
|
||||
// TODO @AI:1 和 2,是不是改成 1.1 1.2;先拉取本地缓存。拉不到在拉远端数据。感觉更清晰一些。
|
||||
// 1. IDB 并发恢复(loadConversations 返回 void;load{Friends,Groups} 返回是否有缓存)
|
||||
// 1.2 三个 store 并发吃 IDB(loadConversations 返回 void;load{Friends,Groups} 返回是否命中缓存)
|
||||
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
|
||||
conversationStore.loadConversations(),
|
||||
friendStore.loadFriends(),
|
||||
groupStore.loadGroups()
|
||||
])
|
||||
|
||||
// TODO @AI:SWR 这个注释,看看怎么更好的理解。
|
||||
// TODO @AI:下面这个注释,感觉没啥层次感。
|
||||
// 2. SWR 刷新:有缓存背景刷;无缓存必须 await + 抛错中断——否则 pullOnce 会用 senderId
|
||||
// 数字给会话起名落到 IDB 后续很难自愈。无缓存分支两个 fetch 并发 Promise.all 省一个 RTT
|
||||
// 2.1 有缓存:异步背景刷新,失败仅记日志(IDB 数据已经够撑首屏,pullOnce 也能正常入库)
|
||||
// 2.2 无缓存(首登 / 切账号回切):必须 await + 失败抛出中断本轮 onMounted,否则 pullOnce 会用 senderId 数字给会话起名落到 IDB 后续基本无法自愈;无缓存分支两个 fetch 并发 Promise.all 省一个 RTT
|
||||
const requiredFetches: Promise<unknown>[] = []
|
||||
if (hasCachedFriends) {
|
||||
void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e))
|
||||
|
|
@ -82,41 +75,34 @@ onMounted(async () => {
|
|||
if (requiredFetches.length > 0) {
|
||||
await Promise.all(requiredFetches)
|
||||
}
|
||||
// TODO @AI:3.1 3.2 是不是一起。一个是 websocket 加载数据;一个是加载离线消息。本质是解决实时通信;
|
||||
// 3. 数据就绪后再 connect——无缓存 fetch 失败会走外层 catch 提前 return,避免 WS 已连
|
||||
// 但 friend/group store 空,handle*Message 用 senderId 数字落库
|
||||
wsStore.connect()
|
||||
// 4. 拉离线消息;pullOnce finally 里把 loading 归位
|
||||
|
||||
// 3. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位);上一步无缓存 fetch 失败会被外层 catch 提前 return 不到这里,避免 WebSocket 已连但 friend/group store 是空的、handle*Message 用 senderId 数字落库
|
||||
webSocketStore.connect()
|
||||
await pullOnce()
|
||||
|
||||
// 5. 默认选中第一个会话
|
||||
// 4. 默认选中第一个会话
|
||||
const sorted = conversationStore.getSortedConversations
|
||||
if (sorted.length > 0 && !conversationStore.activeConversation) {
|
||||
conversationStore.setActiveConversation(sorted[0])
|
||||
}
|
||||
} catch (e) {
|
||||
// TODO @AI:注释可以写的超过一行;尽量换行的时候,是一个事情写完,不然读起来很累。【其他地方也是!!!】例子如下:
|
||||
// TODO !首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断。
|
||||
// TODO WS 不在这里 disconnect —— 路由离开走 onUnmounted 自然清理,用户也可以刷新重试
|
||||
// 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),
|
||||
// 否则后续 saveConversations 全被早 return 阻断。WS 不在这里 disconnect——
|
||||
// 路由离开走 onUnmounted 自然清理,用户也可以刷新重试
|
||||
// 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断
|
||||
// 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试
|
||||
conversationStore.loading = false
|
||||
console.error('[IM] 初始化失败', e)
|
||||
}
|
||||
})
|
||||
|
||||
/** 离开 IM 主壳:主动断 WS(disconnect 内部已清掉 onclose 防自动重连) */
|
||||
/** 离开 IM 主壳:主动断 WebSocket(disconnect 内部已清掉 onclose 防自动重连) */
|
||||
onUnmounted(() => {
|
||||
wsStore.disconnect()
|
||||
webSocketStore.disconnect()
|
||||
})
|
||||
|
||||
// TODO @AI:要说下,当前对话的处理。因为不涉及其他对话呀。
|
||||
/**
|
||||
* 会话切换时自动标记为已读 + 私聊下拉对方已读位置:
|
||||
* - 立刻清零本地未读
|
||||
* - 同步后端已读状态;服务端会广播 READ/RECEIPT 事件通知其它端与对方
|
||||
* - 私聊额外补一次「对方已读到哪条」,弥补离线 / 多端漏掉的 RECEIPT 推送
|
||||
* 当前会话切换:本地清零未读 + 上报后端已读 + 私聊补"对方已读到哪条"
|
||||
*
|
||||
* 只针对当前 active 会话做处理,其它会话已读状态由 WebSocket READ/RECEIPT 事件被动同步。
|
||||
* 私聊补一次拉对方已读位置,弥补离线 / 多端漏掉的 RECEIPT 推送
|
||||
*/
|
||||
watch(
|
||||
() => conversationStore.activeConversation?.targetId,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
<div class="flex items-center mt-1 leading-5">
|
||||
<!-- @红字提示:atMe 优先于 atAll -->
|
||||
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span>
|
||||
<!-- 群聊最后一条发送者前缀:实时按 lastSenderId + 当前会话上下文算名字 -->
|
||||
<!-- 群聊最后一条发送者前缀:按 lastSenderId + 当前会话上下文实时算名字 -->
|
||||
<span
|
||||
v-if="showSendName"
|
||||
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(() => {
|
||||
if (!isGroup.value) {
|
||||
return false
|
||||
|
|
@ -130,18 +130,11 @@ const showSendName = computed(() => {
|
|||
if (!props.conversation.lastSenderId) {
|
||||
return false
|
||||
}
|
||||
// 走 lastMessageType 索引(避免再去翻 messages 数组),TIP_TIME / TIP_TEXT / RECALL 不带前缀
|
||||
const lastType = props.conversation.lastMessageType
|
||||
if (lastType == null) {
|
||||
return false
|
||||
}
|
||||
return isNormalMessage(lastType)
|
||||
return lastType != null && isNormalMessage(lastType)
|
||||
})
|
||||
|
||||
/**
|
||||
* 列表展示文案:撤回类型实时按 lastSenderId 算,避免改备注后老 lastContent 文案过期;
|
||||
* 其余类型直接用 conversation.lastContent(按消息进来时固化的摘要)
|
||||
*/
|
||||
/** 列表展示文案:撤回类型实时算(避免改备注后老 lastContent 过期),其余直接用 lastContent */
|
||||
const lastContentDisplay = computed(() => {
|
||||
if (
|
||||
props.conversation.lastMessageType === ImMessageType.RECALL &&
|
||||
|
|
@ -183,15 +176,7 @@ function handleTop() {
|
|||
)
|
||||
}
|
||||
|
||||
// TODO @AI:这块注释,会不会台复杂了。
|
||||
/**
|
||||
* 切换免打扰:乐观 UI(先落本地,再异步推后端),失败回滚 + 提示
|
||||
*
|
||||
* 不 await:UI 已经通过 conversationStore.setMuted 完成视觉切换,菜单立即关闭;
|
||||
* 后端 /im/friend/update / /im/group-member/update 失败时回滚 conversationStore,
|
||||
* 避免本地(已经 saveConversations 落 IndexedDB)跟服务端长期不一致
|
||||
* (friend / group 自身的 setMuted 在 await 失败时不会落本地,只有 conversation 需要回滚)
|
||||
*/
|
||||
/** 切换免打扰:乐观 UI(先本地切换,菜单立即关;后端失败回滚 conversation 状态) */
|
||||
function handleMuted() {
|
||||
const next = !props.conversation.muted
|
||||
const { type, targetId } = props.conversation
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
<template>
|
||||
<!-- TODO @AI:@全部、人的消息高亮;在消息内容里 -->
|
||||
<!--
|
||||
布局约定:DOM 顺序永远是「头像在前 / 气泡在后」,对方消息走默认 row(头像顶左),
|
||||
自己消息靠外层 flex-row-reverse 翻视觉(头像顶右、气泡在头像左侧),跟微信对齐。
|
||||
早先双 v-if 头像 + row-reverse 会让自己消息时气泡顶右、头像反而在气泡左边。
|
||||
布局约定:DOM 顺序永远是「头像在前 / 气泡在后」,对方消息走默认 row(头像顶左),自己消息靠外层 flex-row-reverse 翻视觉(头像顶右、气泡在头像左侧),跟微信对齐
|
||||
-->
|
||||
<!-- 时间分隔线(TIP_TIME=20):居中灰色时间 -->
|
||||
<div
|
||||
|
|
@ -490,13 +487,7 @@ const showGroupReadStatus = computed(() => {
|
|||
return status !== ImGroupReceiptStatus.NO_RECEIPT
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前群成员(供 MessageReadStatus 计算未读列表用)
|
||||
*
|
||||
* // TODO @AI:下面的注释,会不会台复杂了。是不是拿到方法体里?
|
||||
* 群成员是按需懒加载到 groupStore(loadGroupMembers / fetchGroupMembers),未加载完 group?.members 为 undefined →
|
||||
* 兜底空数组,MessageReadStatus 拿空数组就不渲染未读名单,不会出错
|
||||
*/
|
||||
/** 当前群成员(供 MessageReadStatus 计算未读名单;未加载完时兜底空数组不渲染) */
|
||||
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
|
|
|
|||
|
|
@ -212,38 +212,25 @@ const groupFriends = computed<FriendLite[]>(() =>
|
|||
}))
|
||||
)
|
||||
|
||||
// TODO @AI:SWR 这个缩写,大家不一定看的懂。
|
||||
/** 切换到群会话时按 SWR 同步群 / 成员 / 好友;各自 fire-and-forget + catch,任何一项失败不牵连其它 */
|
||||
/** 切换到群会话时同步群信息 + 成员;各自 fire-and-forget + catch,任何一项失败不牵连其它 */
|
||||
async function ensureGroupData(groupId: number) {
|
||||
// TODO @AI:从远程异步拉取群信息,保证数据是最新的
|
||||
// 远程异步拉群信息(群名 / 公告 / 群主等元数据)
|
||||
groupStore.fetchGroupInfo(groupId).catch((error) => {
|
||||
console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error)
|
||||
})
|
||||
|
||||
// TODO @AI:先从 IDB 同步加载群成员,保证首帧就有成员名 / 头像;这样注释更好?
|
||||
// 先吃 IDB 让首帧立即出成员名 / 头像
|
||||
// 先从 IDB 同步加载群成员,让首帧立即出成员名 / 头像
|
||||
await groupStore.loadGroupMembers(groupId).catch((error) => {
|
||||
console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error)
|
||||
return null
|
||||
})
|
||||
// TODO @AI:再从远程异步拉取群成员信息,保证数据是最新的
|
||||
// force=true 跳过上一行刚塞进 in-memory 的短路,保证每次进群拿到最新成员状态
|
||||
// 再从远程异步拉成员,强刷以跳过 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 跳过缓存)
|
||||
*
|
||||
* 仅在当前会话仍是同一个群时执行,避免 await 期间用户已经切走、把别的群数据也跟着重拉
|
||||
*/
|
||||
/** 群信息抽屉里点"刷新":强拉一次最新群元数据 + 群成员 */
|
||||
function reloadGroupData() {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
|
|
@ -254,13 +241,7 @@ function reloadGroupData() {
|
|||
}
|
||||
|
||||
const historyVisible = ref(false)
|
||||
/**
|
||||
* 信息抽屉的开关(纯 MessagePanel 本地 UI 状态)
|
||||
*
|
||||
* 群聊 / 私聊共用一个 ref:模板里 v-if-else 决定挂哪个抽屉,同一时刻只有一个组件
|
||||
* 在 DOM 里,所以一个布尔够用。早先拆成 sideVisible + privateSideVisible 是冗余
|
||||
*/
|
||||
const sideVisible = ref(false)
|
||||
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
|
||||
|
||||
/** 信息抽屉的 toggle:跟 header 上 3 点图标按钮共用 */
|
||||
function toggleSide() {
|
||||
|
|
@ -384,7 +365,7 @@ watch(
|
|||
newMessageCount.value = 0
|
||||
showJumpToBottom.value = false
|
||||
scrollToBottom()
|
||||
// 仅群聊预拉详情 / 成员 / 好友(私聊只需 friendStore 里的对端,已经 globally pull 了)
|
||||
// 仅群聊预拉详情 / 成员(私聊对端在首屏 fetchFriends 时就拉了)
|
||||
if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) {
|
||||
ensureGroupData(targetId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<MessagePanel />
|
||||
|
||||
<!-- 添加朋友 / 发起群聊弹窗 -->
|
||||
<AddFriendDialog v-model="addFriendVisible" @added="handleFriendAdded" />
|
||||
<AddFriendDialog v-model="addFriendVisible" />
|
||||
<CreateGroupDialog
|
||||
v-model="createGroupVisible"
|
||||
:friends="friends"
|
||||
|
|
@ -110,20 +110,14 @@ const friends = computed<FriendLite[]>(() =>
|
|||
}))
|
||||
)
|
||||
|
||||
/** 加好友成功后强制刷新好友列表,让群聊弹窗的勾选项也能看到新好友 */
|
||||
async function handleFriendAdded() {
|
||||
// TODO @AI:添加完后,不要重新啥新,成本太高了。。。
|
||||
await friendStore.fetchFriends(true)
|
||||
}
|
||||
|
||||
/** 建群成功后刷新群列表,并直接打开新群会话(自动选中并渲染到右侧 MessagePanel) */
|
||||
async function handleGroupCreated(groupId: number) {
|
||||
// TODO @AI:建群成功后,是不是可以不加载 group;按道理说,新建完,直接写入 groups 里面就好了。这里只负责 get 下;
|
||||
await groupStore.fetchGroups(true)
|
||||
/** 处理建群成功 */
|
||||
function handleGroupCreated(groupId: number) {
|
||||
// CreateGroupDialog 已经 upsertGroup 把新群写进 store,这里只 get + 打开会话
|
||||
const group = groupStore.getGroup(groupId)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
// 打开会话
|
||||
conversationStore.openConversation(
|
||||
groupId,
|
||||
ImConversationType.GROUP,
|
||||
|
|
|
|||
|
|
@ -9,45 +9,53 @@ import {
|
|||
IM_AT_ALL_USER_ID,
|
||||
TIME_TIP_GAP_MS
|
||||
} 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 { resolveConversationLastContent } from '../../utils/conversation'
|
||||
import { getSenderDisplayName } from '../../utils/user'
|
||||
import { tryGetSenderDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from './groupStore'
|
||||
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 {
|
||||
const liveSenderName = getSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
||||
const isRealName = liveSenderName !== String(senderId)
|
||||
const isSameSender = conversation.lastSenderId === senderId
|
||||
if (isRealName) {
|
||||
conversation.lastSenderDisplayName = liveSenderName
|
||||
return
|
||||
function deriveLastSenderDisplayName(
|
||||
conversation: Conversation,
|
||||
senderId: number
|
||||
): string | undefined {
|
||||
// 1. 严格版算名字:能拿到 displayUserName / 备注 / 真实昵称就直接用,对应规则 1
|
||||
const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
|
||||
if (liveSenderName) {
|
||||
return liveSenderName
|
||||
}
|
||||
|
||||
// 群聊算不出真名:单靠快照覆盖不了"换发送人 + members 没加载",主动补成员(store 内部已单飞)
|
||||
// TODO @AI:是不是可以增加一个,补齐单个人?这样,改造这个方法。支持传递 groupId + memberUserId;
|
||||
// 2. 群聊兜底拉成员:分两种情况
|
||||
// a. members 完全没加载(!membersLoaded)→ 拉整群(pullOnce 期间多个 senderId 都缺时,单飞表会 dedup 成一次请求)
|
||||
// b. members 已加载但缺这一个(新加入的成员,本端未收到 GROUP_MEMBER_UPDATE)→ 补齐这一个
|
||||
if (conversation.type === ImConversationType.GROUP) {
|
||||
useGroupStore()
|
||||
.fetchGroupMembers(conversation.targetId, true)
|
||||
.catch((e) =>
|
||||
const groupStore = useGroupStore()
|
||||
const group = groupStore.getGroup(conversation.targetId)
|
||||
const fetchPromise = group?.membersLoaded
|
||||
? groupStore.fetchGroupMember(conversation.targetId, senderId)
|
||||
: groupStore.fetchGroupMembers(conversation.targetId)
|
||||
fetchPromise.catch((e) =>
|
||||
console.warn(
|
||||
'[IM conversationStore] 兜底拉群成员失败',
|
||||
{ groupId: conversation.targetId },
|
||||
{ groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
|
||||
e
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 同发送人沿用旧快照(冷拉期间常见),换人则清掉避免显示成上一个人
|
||||
if (!isSameSender) {
|
||||
conversation.lastSenderDisplayName = undefined
|
||||
}
|
||||
// 3. 算不出真名:同发送人沿用旧快照(规则 2),换人则清掉避免显示成上一个人(规则 3)
|
||||
const isSameSender = conversation.lastSenderId === senderId
|
||||
return isSameSender ? conversation.lastSenderDisplayName : undefined
|
||||
}
|
||||
|
||||
// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
|
||||
|
|
@ -163,8 +171,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
* - 传单个 conversation:写 meta + 该会话的消息(单条消息变更走这里)
|
||||
* - 传数组:写 meta + 数组里所有未删除会话的消息(loading 完成后兜底 flush 用)
|
||||
*
|
||||
* 按会话分桶后,单条消息变更只重写当前会话的消息 key,避免老方案的全量序列化。
|
||||
* 写入失败已在内部 catch 兜底(仅打印日志),不影响 UI 流程,所以接口签名设为 void。
|
||||
* 按会话分桶后,单条消息变更只重写当前会话的消息 key,避免老方案的全量序列化;写入失败已在内部 catch 兜底(仅打印日志)不影响 UI 流程,所以接口签名设为 void
|
||||
*/
|
||||
saveConversations(target?: Conversation | Conversation[] | null): void {
|
||||
// loading 期间跳过,避免离线消息批量到达时的密集写入
|
||||
|
|
@ -191,8 +198,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
Array.isArray(target) ? target : target ? [target] : []
|
||||
).filter((c) => !c.deleted)
|
||||
for (const conversation of conversationsToFlush) {
|
||||
// toRaw 拆掉 Vue reactive Proxy:IDB 的 structuredClone 不接受 Proxy,
|
||||
// 不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢)
|
||||
// toRaw 拆掉 Vue reactive Proxy:IDB 的 structuredClone 不接受 Proxy,不拆会抛 DataCloneError 静默落盘失败(只 meta 写得进去,messages 永远丢)
|
||||
tasks.push(
|
||||
imStorage.setItem(
|
||||
StorageKeys.conversationMessages(userId, conversation.type, conversation.targetId),
|
||||
|
|
@ -210,7 +216,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
if (!userId) {
|
||||
return
|
||||
}
|
||||
safeImRemove(
|
||||
removeQuietly(
|
||||
StorageKeys.conversationMessages(userId, type, targetId),
|
||||
'[IM] 本地消息缓存删除失败'
|
||||
)
|
||||
|
|
@ -390,18 +396,20 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return
|
||||
}
|
||||
|
||||
// 2.1 更新会话摘要 + 事实索引 + 发送人名快照
|
||||
refreshLastSenderDisplayName(conversation, messageInfo.senderId)
|
||||
// 2.1 更新会话摘要 + 最后一条消息事实索引(含发送人名快照)。
|
||||
// deriveLastSenderDisplayName 必须在更新 lastSenderId 之前调用,靠旧值判断"同发送人"
|
||||
const senderDisplayName = deriveLastSenderDisplayName(conversation, messageInfo.senderId)
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
messageInfo,
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
conversation.lastSenderDisplayName
|
||||
senderDisplayName
|
||||
)
|
||||
conversation.lastSendTime = messageInfo.sendTime || Date.now()
|
||||
conversation.lastSenderId = messageInfo.senderId
|
||||
conversation.lastMessageType = messageInfo.type
|
||||
conversation.lastSelfSend = messageInfo.selfSend
|
||||
conversation.lastSenderDisplayName = senderDisplayName
|
||||
|
||||
// 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
|
||||
if (
|
||||
|
|
@ -523,10 +531,9 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
}
|
||||
message.type = ImMessageType.RECALL
|
||||
message.status = ImMessageStatus.RECALL
|
||||
// content 不再写撤回文案:渲染层走 buildRecallTip(senderId, selfSend, ...) 实时算
|
||||
// 这里清空,避免老 content 被误认为有效消息文本
|
||||
// 清空 content:撤回文案由渲染层 buildRecallTip 实时算,老 content 留着会被误认为有效消息文本
|
||||
message.content = ''
|
||||
// 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引(lastSenderId 不变,沿用快照)
|
||||
// 最后一条消息是刚撤回的,才更新会话摘要 + lastMessageType(senderId 不变,沿用旧快照)
|
||||
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
message,
|
||||
|
|
@ -644,27 +651,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]
|
||||
if (last) {
|
||||
refreshLastSenderDisplayName(conversation, last.senderId)
|
||||
const senderDisplayName = deriveLastSenderDisplayName(conversation, last.senderId)
|
||||
conversation.lastContent = resolveConversationLastContent(
|
||||
last,
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
conversation.lastSenderDisplayName
|
||||
senderDisplayName
|
||||
)
|
||||
conversation.lastSendTime = last.sendTime || conversation.lastSendTime
|
||||
conversation.lastSenderId = last.senderId
|
||||
conversation.lastMessageType = last.type
|
||||
conversation.lastSelfSend = last.selfSend
|
||||
conversation.lastSenderDisplayName = senderDisplayName
|
||||
} else {
|
||||
conversation.lastContent = ''
|
||||
conversation.lastSenderDisplayName = undefined
|
||||
conversation.lastSenderId = undefined
|
||||
conversation.lastMessageType = undefined
|
||||
conversation.lastSelfSend = undefined
|
||||
conversation.lastSenderDisplayName = undefined
|
||||
}
|
||||
}
|
||||
this.saveConversations(conversation)
|
||||
|
|
|
|||
|
|
@ -12,7 +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 { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
import type { Friend } from '../types'
|
||||
|
||||
|
|
@ -54,11 +54,10 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
actions: {
|
||||
// ==================== 本地缓存 ====================
|
||||
|
||||
// TODO @AI:是不是不用 “不更新 conversationStore——会话缓存和好友缓存是同一会话写入的,名字头像天然一致” 注释。只要说明 boolean 是啥就行了把。
|
||||
/**
|
||||
* 从 IDB 恢复好友列表,返回 boolean 给首屏 SWR 决策用
|
||||
* 从 IDB 恢复好友列表
|
||||
*
|
||||
* 不更新 conversationStore——会话缓存和好友缓存是同一会话写入的,名字头像天然一致
|
||||
* @return 返回是否命中缓存
|
||||
*/
|
||||
async loadFriends(): Promise<boolean> {
|
||||
const userId = getCurrentUserId()
|
||||
|
|
@ -84,7 +83,7 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
if (!userId) {
|
||||
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,
|
||||
* 失败就直接抛给上层,让 UI 决定是否回滚 / 提示用户
|
||||
* 走后端 /im/friend/update 接口;保存成功后再同步本地 friend + 会话列表 name,失败直接抛给上层让 UI 决定回滚 / 提示
|
||||
*/
|
||||
async setDisplayName(friendUserId: number, displayName: string) {
|
||||
const value = displayName.trim()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type ImGroupRespVO
|
||||
} from '@/api/im/group'
|
||||
import {
|
||||
getGroupMember as apiGetGroupMember,
|
||||
getGroupMemberList as apiGetGroupMemberList,
|
||||
updateGroupMember as apiUpdateGroupMember,
|
||||
type ImGroupMemberRespVO
|
||||
|
|
@ -16,21 +17,30 @@ import { ImConversationType } from '../../utils/constants'
|
|||
import {
|
||||
getCurrentUserId,
|
||||
imStorage,
|
||||
safeImRemove,
|
||||
safeImSet,
|
||||
removeQuietly,
|
||||
setQuietly,
|
||||
StorageKeys
|
||||
} from '../../utils/storage'
|
||||
import { getGroupDisplayName } from '../../utils/user'
|
||||
import type { Group, GroupMember } from '../types'
|
||||
|
||||
/**
|
||||
* fetchGroupMembers 单飞表:同 groupId 并发只打一次接口
|
||||
* fetchGroupMembers 并发去重表:同 groupId 同时进的请求共用一个 Promise
|
||||
*
|
||||
* key 必须带 userId——账号切换时 A 的 in-flight 不能被 B 复用,否则 IIFE 内部
|
||||
* 的 saveGroupMembers 会把 A 的成员数据写进 B 的 IDB 桶
|
||||
* key 必须带 userId——账号切换时 A 的请求不能被 B 复用,否则 IIFE 内部的 saveGroupMembers 会把 A 的成员数据写进 B 的 IDB 桶
|
||||
*/
|
||||
const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
|
||||
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
|
||||
*
|
||||
|
|
@ -57,12 +67,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
actions: {
|
||||
// ==================== 本地缓存 ====================
|
||||
|
||||
// TODO @AI:简化注释,参考 friendStore;
|
||||
/**
|
||||
* 从 IDB 恢复群列表(不带 members),返回 boolean 给首屏 SWR 决策用
|
||||
*
|
||||
* 不更新 conversationStore——会话缓存和群缓存是同一会话写入的,名字头像天然一致
|
||||
*/
|
||||
/** 从 IDB 恢复群列表(不带 members),返回是否命中缓存 */
|
||||
async loadGroups(): Promise<boolean> {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
|
|
@ -88,24 +93,24 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
return
|
||||
}
|
||||
const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest)
|
||||
safeImSet(
|
||||
setQuietly(
|
||||
StorageKeys.groups(userId),
|
||||
groupsWithoutMembers,
|
||||
'[IM groupStore] 本地群缓存写入失败'
|
||||
)
|
||||
},
|
||||
|
||||
// TODO @AI:命中返回数组(caller 紧接渲染省一次二次访问),未命中返回 null 是不是没必要注释?只是说返回结果而已。。。
|
||||
/** 从 IDB 恢复指定群成员;命中返回数组(caller 紧接渲染省一次二次访问),未命中返回 null */
|
||||
/** 从 IDB 恢复指定群成员;命中返回成员数组,未命中返回 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
|
||||
// in-memory 已"完整"加载(fetchGroupMembers 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
|
||||
// 单成员补齐(fetchGroupMember)写进的 partial members 不在此短路——其 membersLoaded=false
|
||||
const cachedGroup = this.getGroup(groupId)
|
||||
if (cachedGroup?.members && cachedGroup.membersLoaded) {
|
||||
return cachedGroup.members
|
||||
}
|
||||
try {
|
||||
const cached = await imStorage.getItem<GroupMember[]>(
|
||||
|
|
@ -117,17 +122,19 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
// 把 IDB 拿到的成员落到对应 group
|
||||
const group = this.getGroup(groupId)
|
||||
if (!group) {
|
||||
// group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup 。
|
||||
// 原因:避免把假名灌进 conversation.name + groups IDB 桶;等 fetchGroups 浅合并时被真名覆盖
|
||||
// group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
|
||||
// 后续,等 fetchGroups 浅合并时,被真名覆盖
|
||||
this.groups.push({
|
||||
id: groupId,
|
||||
name: '',
|
||||
members: cached,
|
||||
memberCount: cached.length
|
||||
memberCount: cached.length,
|
||||
membersLoaded: true
|
||||
})
|
||||
} else {
|
||||
group.members = cached
|
||||
group.memberCount = cached.length
|
||||
group.membersLoaded = true
|
||||
}
|
||||
return cached
|
||||
} catch (e) {
|
||||
|
|
@ -146,7 +153,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
if (!members) {
|
||||
return
|
||||
}
|
||||
safeImSet(
|
||||
setQuietly(
|
||||
StorageKeys.groupMembers(userId, groupId),
|
||||
members,
|
||||
`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`
|
||||
|
|
@ -163,8 +170,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers)
|
||||
const list = await apiGetMyGroupList()
|
||||
const fresh = (list || []).map(convertGroup)
|
||||
// 合并而非全量替换:保留 loadGroupMembers / fetchGroupMembers 已经写入的 members / memberCount / muted
|
||||
// (这些字段不在 ImGroupRespVO 里,全量替换会把成员级数据全冲掉)
|
||||
// 合并而非全量替换:保留 user-per-group 字段(muted / displayGroupName)+ 成员缓存——这些字段不在 ImGroupRespVO 里,全量替换会把它们冲掉
|
||||
const groupMap = new Map(this.groups.map((group) => [group.id, group]))
|
||||
this.groups = fresh.map((group) => {
|
||||
const existing = groupMap.get(group.id)
|
||||
|
|
@ -175,14 +181,16 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
...group,
|
||||
members: existing.members,
|
||||
memberCount: existing.memberCount ?? group.memberCount,
|
||||
muted: existing.muted ?? group.muted
|
||||
muted: existing.muted ?? group.muted,
|
||||
displayGroupName: existing.displayGroupName,
|
||||
membersLoaded: existing.membersLoaded
|
||||
}
|
||||
})
|
||||
this.loaded = true
|
||||
const conversationStore = useConversationStore()
|
||||
for (const group of this.groups) {
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
|
||||
name: group.name,
|
||||
name: getGroupDisplayName(group),
|
||||
avatar: group.avatar,
|
||||
muted: group.muted
|
||||
})
|
||||
|
|
@ -203,79 +211,143 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
}
|
||||
},
|
||||
|
||||
// TODO @AI:in-flight 单飞;这个注释有点奇怪
|
||||
/** 按群拉取成员(in-memory 缓存 + in-flight 单飞,force=true 强刷)+ 落 IDB */
|
||||
/** 按群拉取成员(in-memory 缓存 + 并发去重,force=true 强刷)+ 落 IDB */
|
||||
fetchGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
|
||||
// in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此短路(membersLoaded=false)
|
||||
const cached = this.getGroup(groupId)
|
||||
if (cached && cached.members && !force) {
|
||||
if (cached && cached.members && cached.membersLoaded && !force) {
|
||||
return Promise.resolve(cached.members)
|
||||
}
|
||||
// 未登录:不发起请求也不登记 in-flight,避免污染单飞表
|
||||
const requestUserId = getCurrentUserId()
|
||||
if (!requestUserId) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
// TODO @AI:最好这里注释下。
|
||||
// 同 (userId, groupId) 已经有正在飞的请求:直接复用,避免重复打接口
|
||||
const key = pendingMemberKey(requestUserId, groupId)
|
||||
const inflight = pendingMemberFetches.get(key)
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
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 members = (list || []).map((member) => convertGroupMember(member, groupId))
|
||||
// 网络往返期间用户可能已切——A 的数据写到 B 的 store / IDB 是数据互串,丢弃
|
||||
// TODO @AI:这个应该不存在把?有点过度设计了。
|
||||
if (getCurrentUserId() !== requestUserId) {
|
||||
return []
|
||||
let meRaw: ImGroupMemberRespVO | undefined
|
||||
const members = (list || []).map((member) => {
|
||||
if (member.userId === requestUserId) {
|
||||
meRaw = member
|
||||
}
|
||||
|
||||
// muted 是成员维度字段(apiGetMyGroupList 不带),借这次拉成员回填到 group / conversation
|
||||
const me = members.find((m) => m.userId === requestUserId)
|
||||
const muted = !!me?.muted
|
||||
return convertGroupMember(member, groupId)
|
||||
})
|
||||
const muted = !!meRaw?.muted
|
||||
const displayGroupName = meRaw?.displayGroupName || ''
|
||||
|
||||
// 必须 await 之后重新 getGroup,避免 fetchGroups 已并发写入真实 group 的 race
|
||||
const group = this.getGroup(groupId)
|
||||
const isPlaceholder = !group
|
||||
let mutedChanged = false
|
||||
let groupFieldsChanged = false
|
||||
if (!group) {
|
||||
// group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup
|
||||
// 避免把假名灌进 conversation.name + groups IDB 桶。等 fetchGroups 浅合并时被真名覆盖
|
||||
// group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
|
||||
// 后续,等 fetchGroups 浅合并时,被真名覆盖
|
||||
this.groups.push({
|
||||
id: groupId,
|
||||
name: '',
|
||||
members,
|
||||
memberCount: members.length,
|
||||
muted
|
||||
muted,
|
||||
displayGroupName,
|
||||
membersLoaded: true
|
||||
})
|
||||
} else {
|
||||
group.members = members
|
||||
group.memberCount = members.length
|
||||
// TODO @AI:这里最好注释下。
|
||||
if (group.muted !== muted) {
|
||||
group.membersLoaded = true
|
||||
// muted / displayGroupName 任一变化就同步到 conversation + 触发 saveGroups;
|
||||
// 后续,displayGroupName 变化要刷会话名("我对该群的备注"是会话列表的展示名)
|
||||
if (group.muted !== muted || group.displayGroupName !== displayGroupName) {
|
||||
group.muted = muted
|
||||
mutedChanged = true
|
||||
group.displayGroupName = displayGroupName
|
||||
groupFieldsChanged = true
|
||||
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 桶仅在 muted 实际变化时写——避免批量进群 fan-out 时重复重写整桶
|
||||
// groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写
|
||||
this.saveGroupMembers(groupId)
|
||||
if (!isPlaceholder && mutedChanged) {
|
||||
if (!isPlaceholder && groupFieldsChanged) {
|
||||
this.saveGroups()
|
||||
}
|
||||
return members
|
||||
// TODO @AI:finally 最注释下,好理解;
|
||||
// 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise
|
||||
})().finally(() => pendingMemberFetches.delete(key))
|
||||
|
||||
// TODO @AI:这里是不是要注释下
|
||||
// 把 Promise 登记进单飞表,让此后短时间内的同 (userId, groupId) 请求复用
|
||||
pendingMemberFetches.set(key, 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) {
|
||||
const index = this.groups.findIndex((g) => g.id === group.id)
|
||||
if (index >= 0) {
|
||||
|
|
@ -283,14 +355,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
} else {
|
||||
this.groups.push(group)
|
||||
}
|
||||
// TODO @AI:这里注释下
|
||||
// 同步推到 conversation:群名 / 头像 / 免打扰是会话列表展示用的,必须紧随 group 变更
|
||||
const merged = this.getGroup(group.id) ?? group
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
|
||||
name: group.name,
|
||||
avatar: group.avatar,
|
||||
muted: group.muted
|
||||
name: getGroupDisplayName(merged),
|
||||
avatar: merged.avatar,
|
||||
muted: merged.muted
|
||||
})
|
||||
// TODO @AI:这里注释下
|
||||
// 持久化到 IDB(fire-and-forget)
|
||||
this.saveGroups()
|
||||
},
|
||||
|
||||
|
|
@ -301,11 +374,10 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
const conversationStore = useConversationStore()
|
||||
conversationStore.removeGroupConversation(id)
|
||||
this.saveGroups()
|
||||
// TODO @AI:避免 IDB 留 orphan;注释调整下,orphan 有点不好理解。
|
||||
// 把对应的群成员桶物理删掉,避免 IDB 留 orphan
|
||||
// 群解散后顺手删 IDB 里该群的成员桶——这桶仅靠 groupId 索引,不删会一直留在 IDB 占空间
|
||||
const userId = getCurrentUserId()
|
||||
if (userId) {
|
||||
safeImRemove(
|
||||
removeQuietly(
|
||||
StorageKeys.groupMembers(userId, id),
|
||||
`[IM groupStore] 群成员缓存删除失败 (groupId=${id})`
|
||||
)
|
||||
|
|
@ -327,9 +399,9 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
clear() {
|
||||
this.groups = []
|
||||
this.loaded = false
|
||||
// TODO @AI:in-flight 这种调整下,不好理解。
|
||||
// 旧账号的 in-flight 即便 resolve 也会被 IIFE 内部的 userId 校验丢弃,索性清掉避免悬挂
|
||||
// 单飞表跟 in-memory state 一起重置;旧账号 in-flight 的请求 finally 也会自己 delete key,提前清空只是更干脆
|
||||
pendingMemberFetches.clear()
|
||||
pendingSingleMemberFetches.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -352,9 +424,7 @@ function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): Group
|
|||
nickname: member.nickname || String(member.userId),
|
||||
avatar: member.avatar,
|
||||
displayUserName: member.displayUserName,
|
||||
displayGroupName: member.displayGroupName,
|
||||
status: member.status,
|
||||
muted: member.muted
|
||||
status: member.status
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -530,8 +530,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
/**
|
||||
* GROUP_MEMBER_UPDATE:多端同步成员属性变更(昵称 / 免打扰 / 退群等)
|
||||
*
|
||||
* 必须强刷成员而非群元数据——这些字段都在 ImGroupMemberRespVO 上,apiGetMyGroupList 不带;
|
||||
* 持久化后若不强刷,IDB 成员桶会长期陈旧
|
||||
* 必须强刷成员而非群元数据——这些字段都在 ImGroupMemberRespVO 上,apiGetMyGroupList 不带;持久化后若不强刷,IDB 成员桶会长期陈旧
|
||||
*/
|
||||
handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
|
|
|
|||
|
|
@ -45,17 +45,16 @@ export interface Conversation {
|
|||
// ========== 展示字段 ==========
|
||||
name: string // 展示名称(私聊=好友昵称;群聊=群名)
|
||||
avatar: string // 头像
|
||||
lastContent: string // 会话列表展示的最后一条消息摘要
|
||||
lastSendTime: number // 最后一条消息时间,用于排序
|
||||
unreadCount: number // 未读数
|
||||
messages: Message[] // 消息列表
|
||||
// TODO @AI:lastMessage 对象,会不会更干净一点。然后把需要的字段放进去?
|
||||
/** 最后一条消息的事实索引;展示名实时算(getSenderDisplayName),不存名字快照 */
|
||||
lastSenderId?: number
|
||||
lastMessageType?: number
|
||||
lastSelfSend?: boolean
|
||||
/** 发送人显示名快照——仅作 getSenderDisplayName 算不出名字时的 fallback */
|
||||
lastSenderDisplayName?: string
|
||||
|
||||
// ========== 最后一条消息事实索引 ==========
|
||||
lastContent: string // 会话列表展示的最后一条消息摘要
|
||||
lastSendTime: number // 最后一条消息时间,用于排序
|
||||
lastSenderId?: number // 发送人编号
|
||||
lastMessageType?: number // 消息类型,对齐 ImMessageType
|
||||
lastSelfSend?: boolean // 是否自己发的
|
||||
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
|
||||
|
||||
// ========== UI 状态 ==========
|
||||
deleted?: boolean // 是否已删除(软删标记,持久化时过滤)
|
||||
|
|
@ -115,9 +114,11 @@ export interface Group {
|
|||
notice?: string // 群公告
|
||||
ownerUserId?: number // 群主用户编号
|
||||
|
||||
// ========== 前端扩展字段 ==========
|
||||
muted?: boolean // 是否免打扰(来自当前用户的 ImGroupMemberRespVO.muted)
|
||||
// ========== 前端扩展字段(user-per-group 维度) ==========
|
||||
muted?: boolean // 是否免打扰。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
displayGroupName?: string // 群显示备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 短路时误判整群已加载
|
||||
memberCount?: number // 成员总数
|
||||
}
|
||||
|
||||
|
|
@ -129,11 +130,8 @@ export interface GroupMember {
|
|||
userId: number // 用户编号
|
||||
avatar?: string // 头像
|
||||
nickname: string // 用户昵称
|
||||
// TODO @AI:还不是把 muted 字段是不是放到 Group 里?displayUserName、displayGroupName、muted;
|
||||
displayUserName?: string // 组内显示名(不与 nickname 合并,由消费方按需取舍)
|
||||
displayGroupName?: string // 群显示备注(当前用户对该群的自定义名)
|
||||
displayUserName?: string // 该成员在群内自定义昵称(每个 member 一份;不与 nickname 合并,由消费方按需取舍)
|
||||
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
|
||||
muted?: boolean // 当前成员对该群的免打扰开关(fetchGroupMembers 用它回填 Group.muted)
|
||||
|
||||
// ========== 前端扩展字段 ==========
|
||||
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
|
||||
|
|
@ -149,7 +147,7 @@ export interface Friend {
|
|||
nickname: string // 好友昵称(对方真实昵称,永远不被备注覆盖;UI 显示走 displayName || nickname)
|
||||
avatar?: string // 好友头像
|
||||
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||
displayName?: string // 好友展示备注:仅自己可见的别名(命名对齐 GroupMember.displayGroupName 风格,单字段不歧义就不带 Friend 前缀)
|
||||
displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
|
||||
status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录)
|
||||
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
||||
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
// ====================================================================
|
||||
// IM 会话 / 撤回展示 utility
|
||||
// ====================================================================
|
||||
// TODO @AI:这里的注释,不用写历史,只写当下。
|
||||
// 职责:基于会话上下文 + sender 信息实时算"展示文案"。
|
||||
// 历史上 Message / Conversation 上有 senderShowName 快照字段,改备注 / 改群昵称后历史消息
|
||||
// 不会刷新;现在 Message 不再带任何名字快照,发送人名一律由 utils/user.getSenderDisplayName
|
||||
// 实时算。Conversation.lastSenderDisplayName 仅作 fallback 快照(解决"没打开过的群"
|
||||
// members 没加载时的兜底显示),通过 fallback 参数透传到本文件的 buildRecallTip /
|
||||
// resolveConversationLastContent 而非内部硬编码读取
|
||||
//
|
||||
// 与 utils/user.ts 的关系:
|
||||
// user.ts 回答"谁叫什么名字",conversation.ts 在它基础上拼"撤回 tip / 摘要"等文案
|
||||
// 基于 conversation 上下文 + sender 信息实时算"展示文案"。
|
||||
// 1. 发送人名一律由 utils/user.getSenderDisplayName 实时算,本文件只负责拼"撤回 tip / 摘要"等文案。
|
||||
// 2. fallbackName 由调用方传入(典型来源:Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底
|
||||
// ====================================================================
|
||||
|
||||
import { ImMessageType } from './constants'
|
||||
|
|
@ -18,14 +11,13 @@ import { parseMessage, resolveTipText, type TextMessage } from './message'
|
|||
import { getSenderDisplayName } from './user'
|
||||
import type { Message } from '../home/types'
|
||||
|
||||
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallback 兜底) */
|
||||
// TODO @AI:fallbackName
|
||||
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
|
||||
export function buildRecallTip(
|
||||
senderId: number,
|
||||
selfSend: boolean,
|
||||
conversationType: number,
|
||||
conversationTargetId: number,
|
||||
fallback?: string
|
||||
fallbackName?: string
|
||||
): string {
|
||||
if (selfSend) {
|
||||
return '你撤回了一条消息'
|
||||
|
|
@ -34,17 +26,17 @@ export function buildRecallTip(
|
|||
senderId,
|
||||
conversationType,
|
||||
conversationTargetId,
|
||||
fallback
|
||||
fallbackName
|
||||
)
|
||||
return `${senderDisplayName || '对方'} 撤回了一条消息`
|
||||
}
|
||||
|
||||
/** 会话列表最后一条摘要:RECALL 走 buildRecallTip + fallback;其它按消息类型派生 */
|
||||
/** 会话列表最后一条摘要:RECALL 走 buildRecallTip + fallbackName;其它按消息类型派生 */
|
||||
export function resolveConversationLastContent(
|
||||
message: Message,
|
||||
conversationType: number,
|
||||
conversationTargetId: number,
|
||||
fallback?: string
|
||||
fallbackName?: string
|
||||
): string {
|
||||
switch (message.type) {
|
||||
case ImMessageType.IMAGE:
|
||||
|
|
@ -61,7 +53,7 @@ export function resolveConversationLastContent(
|
|||
message.selfSend,
|
||||
conversationType,
|
||||
conversationTargetId,
|
||||
fallback
|
||||
fallbackName
|
||||
)
|
||||
case ImMessageType.TEXT:
|
||||
return parseMessage<TextMessage>(message.content)?.content ?? ''
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
|||
* 1. 配额:localStorage 整体上限 5~10MB,多会话长历史很容易撑爆
|
||||
* 2. 写放大:localStorage 必须按 key 整体写入,单次写就是 MB 级序列化阻塞主线程
|
||||
*
|
||||
* 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key。
|
||||
* IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果。
|
||||
* 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key;
|
||||
* IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果
|
||||
*/
|
||||
export const imStorage = localforage.createInstance({
|
||||
name: 'im',
|
||||
|
|
@ -25,15 +25,13 @@ export const imStorage = localforage.createInstance({
|
|||
* - 会话 / 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
||||
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
||||
*
|
||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离,避免数据互串;账号切换时只清 in-memory,
|
||||
* IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||
*/
|
||||
export const StorageKeys = {
|
||||
/**
|
||||
* 会话索引:游标 + 会话元数据(不含 messages),对应 ConversationStoreMeta
|
||||
*
|
||||
* 任何会话级元数据变更(top / muted / unread / 列表增删 / 排序)都会重写这一个 key;
|
||||
* 由于 messages 已剥离到独立 key,单次写体积稳定(仅元数据,量级 KB 级)。
|
||||
* 任何会话级元数据变更(top / muted / unread / 列表增删 / 排序)都会重写这一个 key;由于 messages 已剥离到独立 key,单次写体积稳定(仅元数据,量级 KB 级)
|
||||
*/
|
||||
conversationMeta: (userId: number | string) => `conversation:meta:${userId}`,
|
||||
/**
|
||||
|
|
@ -42,8 +40,7 @@ export const StorageKeys = {
|
|||
* - type:私聊 / 群聊(对齐 ImConversationType)
|
||||
* - targetId:私聊的对方 userId / 群聊的 groupId
|
||||
*
|
||||
* 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大。
|
||||
* 软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留。
|
||||
* 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大;软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留
|
||||
*/
|
||||
conversationMessages: (userId: number | string, type: number, targetId: number) =>
|
||||
`conversation:messages:${userId}:${type}:${targetId}`,
|
||||
|
|
@ -68,14 +65,12 @@ export function getCurrentUserId(): number {
|
|||
}
|
||||
|
||||
/** IDB 写入:fire-and-forget */
|
||||
// TODO @AI:setQuietly?会不会更好?
|
||||
export function safeImSet(key: string, value: unknown, errorLabel: string): void {
|
||||
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
|
||||
// toRaw 拆 Vue / Pinia reactive Proxy——structuredClone 不接 Proxy 会抛 DataCloneError 静默丢盘
|
||||
const raw = value && typeof value === 'object' ? toRaw(value) : value
|
||||
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
|
||||
}
|
||||
|
||||
// TODO @AI:removeQuietly?会不会更好?
|
||||
export function safeImRemove(key: string, errorLabel: string): void {
|
||||
export function removeQuietly(key: string, errorLabel: string): void {
|
||||
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,126 @@
|
|||
// ====================================================================
|
||||
// IM 用户展示名 utility
|
||||
// ====================================================================
|
||||
// 职责:统一回答"某个用户在 UI 上应该叫什么名字"。
|
||||
// 拆两层:
|
||||
// 1. 纯派生(getFriendDisplayName / getMemberDisplayName)—— 输入 friend / member 对象,
|
||||
// 不查 store,给 store 内部 / 各种组装点复用,避免逻辑散落
|
||||
// 2. 上下文感知(getSenderDisplayName / getSenderRealNickname)—— 渲染时按
|
||||
// conversation 上下文实时查 friendStore / groupStore / userStore,让备注 / 群昵称 /
|
||||
// 真实昵称变更后所有历史消息立即刷新(不再写"快照"到 message 字段里)
|
||||
// 职责:统一回答"某个用户在 UI 上应该叫什么名字"。拆两层:
|
||||
// 1. 纯派生(getFriendDisplayName / getMemberDisplayName / getGroupDisplayName):输入 friend / member / group 对象,不查 store,给 store 内部 / 各种组装点复用,避免逻辑散落
|
||||
// 2. 上下文感知(getSenderDisplayName / getSenderRealNickname / tryGetSenderDisplayName):渲染时按 conversation 上下文实时查 friendStore / groupStore / userStore,让备注 / 群昵称 / 真实昵称变更后所有历史消息立即响应式刷新
|
||||
//
|
||||
// 命名约定:函数名一律使用 displayName,与 friend.displayName / member.displayUserName 字段对齐
|
||||
// ====================================================================
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { ImConversationType } from './constants'
|
||||
import { getCurrentUserId } from './storage'
|
||||
import { useFriendStore } from '../home/store/friendStore'
|
||||
import { useGroupStore } from '../home/store/groupStore'
|
||||
import type { Friend } from '../home/types'
|
||||
import type { Friend, Group } from '../home/types'
|
||||
|
||||
/**
|
||||
* 取好友备注:删好友(DISABLE)也保留——displayName 是「我对这个人的私人称呼」,属于我的数据,
|
||||
* 不该跟好友关系一起清掉。删了再加回来时备注自然延续,历史消息里也仍以备注辨识
|
||||
* 私聊好友显示名:备注 > 真实昵称
|
||||
*
|
||||
* displayName 是「我对这个人的私人称呼」属于我的数据,删好友(DISABLE)也保留;删了再加回来时备注自然延续,历史消息里仍以备注辨识
|
||||
*/
|
||||
function resolveRemark(friend?: Pick<Friend, 'displayName'> | null): string {
|
||||
return friend?.displayName || ''
|
||||
}
|
||||
|
||||
/** 私聊好友显示名:备注 > 真实昵称 */
|
||||
export function getFriendDisplayName(
|
||||
friend: Pick<Friend, 'nickname' | 'displayName'>
|
||||
): string {
|
||||
return resolveRemark(friend) || friend.nickname
|
||||
return friend.displayName || friend.nickname
|
||||
}
|
||||
|
||||
/**
|
||||
* 群成员显示名:好友备注 > 用户群备注(displayUserName) > 真实昵称
|
||||
*
|
||||
* WeChat 优先级:好友备注是"我"对该成员的私人称呼,最高优先;其次是 ta 在群内自定义昵称;最后真实昵称兜底。
|
||||
* 调用方拿到 friend 才传入,没拿到(陌生人)就只用 member 字段降级
|
||||
* WeChat 优先级:好友备注是"我"对该成员的私人称呼,最高优先;其次是 ta 在群内自定义昵称;最后真实昵称兜底;调用方拿到 friend 才传入,没拿到(陌生人)就只用 member 字段降级
|
||||
*/
|
||||
export function getMemberDisplayName(
|
||||
member: { displayUserName?: string; nickname: string },
|
||||
friend?: Pick<Friend, 'displayName'> | null
|
||||
): 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)都返回 undefined:self 在群里的"真名"是 displayUserName,members 没加载时不能拿 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 @AI:fallbackName?这样更清晰?
|
||||
/**
|
||||
* 消息发送者显示名:渲染时实时算,按 WeChat 优先级
|
||||
*
|
||||
* - 自己:userStore.nickname
|
||||
* - 私聊对方:好友备注 > 真实昵称
|
||||
* - 群聊对方:好友备注 > 群备注(displayUserName) > 真实昵称
|
||||
* - 查不到:fallback || String(senderId)
|
||||
* - 查不到:fallbackName || (self 走 userStore.nickname) || String(senderId)
|
||||
*/
|
||||
export function getSenderDisplayName(
|
||||
senderId: number,
|
||||
conversationType: number,
|
||||
conversationTargetId: number,
|
||||
fallback?: string
|
||||
fallbackName?: string
|
||||
): string {
|
||||
// TODO @AI:getCurrentUserId;貌似可以复用;
|
||||
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 selfId = Number(userStore.getUser?.id) || 0 // TODO @AI:selfUserId 更好一点;
|
||||
|
||||
// 自己也走 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)
|
||||
if (senderId === getCurrentUserId()) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
}
|
||||
// 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,不掺备注
|
||||
*
|
||||
* 专给 UserAvatar 的 :name 用——色卡首字母 / alt 文本要保证同一个人在所有界面一致,
|
||||
* 不能跟着备注变。私聊走 friend.nickname,群聊走 member.nickname,自己走 userStore
|
||||
* - 自己:userStore.nickname
|
||||
* - 私聊对方:friend.nickname
|
||||
* - 群聊对方:member.nickname
|
||||
*
|
||||
* 专给 UserAvatar 的 :name 用——色卡首字母 / alt 文本要保证同一个人在所有界面一致,不跟备注变
|
||||
*/
|
||||
export function getSenderRealNickname(
|
||||
senderId: number,
|
||||
conversationType: number,
|
||||
conversationTargetId: number
|
||||
): string {
|
||||
// TODO @AI:getCurrentUserId;貌似可以复用;
|
||||
const userStore = useUserStore()
|
||||
const selfId = Number(userStore.getUser?.id) || 0
|
||||
const selfUserId = getCurrentUserId()
|
||||
|
||||
// 群聊先走 member.nickname(self 也是 member),异常时再走 self / senderId 兜底
|
||||
if (conversationType === ImConversationType.GROUP) {
|
||||
|
|
@ -122,23 +129,21 @@ export function getSenderRealNickname(
|
|||
if (member?.nickname) {
|
||||
return member.nickname
|
||||
}
|
||||
if (senderId === selfId) {
|
||||
if (senderId === selfUserId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
}
|
||||
return String(senderId)
|
||||
}
|
||||
|
||||
// TODO @AI:这里要注释下么?
|
||||
if (conversationType === ImConversationType.PRIVATE) {
|
||||
if (senderId === selfId) {
|
||||
if (senderId === selfUserId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
}
|
||||
const friend = useFriendStore().getFriend(senderId)
|
||||
return friend?.nickname || String(senderId)
|
||||
}
|
||||
|
||||
// TODO @AI:这里要注释下么?
|
||||
if (senderId === selfId) {
|
||||
if (senderId === selfUserId) {
|
||||
return userStore.getUser?.nickname || String(senderId)
|
||||
}
|
||||
return String(senderId)
|
||||
|
|
|
|||
Loading…
Reference in New Issue