feat(im): 修 L-13/L-16:PagedScroller 加 itemKey 防索引乱位、私聊 Message.targetId 改对端 userId、抽 getPrivateMessagePeerId 收敛 4 处 peer 计算

im
YunaiV 2026-05-21 14:50:42 +08:00
parent 1015423431
commit 7a236b4378
7 changed files with 49 additions and 14 deletions

View File

@ -5,7 +5,12 @@
- 通过 slot 暴露每一项让调用方自己决定渲染
-->
<el-scrollbar ref="scrollbarRef" class="w-full h-full">
<slot v-for="(item, idx) in displayItems" :item="item" :index="idx" :key="idx"></slot>
<slot
v-for="(item, idx) in displayItems"
:item="item"
:index="idx"
:key="resolveItemKey(item, idx)"
></slot>
<div
v-if="showFooter"
class="py-3 text-xs text-center text-[var(--el-text-color-secondary)]"
@ -26,6 +31,7 @@ const props = withDefaults(
items: T[] //
pageSize?: number //
threshold?: number // px
itemKey?: string // 业务 id 字段名(如 'userId' / 'id'不传 / 字段值非 string|number 时回退 idx
}>(),
{
pageSize: 30,
@ -33,6 +39,15 @@ const props = withDefaults(
}
)
/** 解析每条 item 的 :keycaller 传 itemKey 则按字段取,无效 / 缺失回退索引,避免传错字段时全表 undefined key */
function resolveItemKey(item: T, idx: number): string | number {
if (!props.itemKey || item == null || typeof item !== 'object') {
return idx
}
const value = (item as Record<string, unknown>)[props.itemKey]
return typeof value === 'string' || typeof value === 'number' ? value : idx
}
const scrollbarRef = useTemplateRef<InstanceType<typeof ElScrollbar>>('scrollbarRef')
const page = ref(1)

View File

@ -21,7 +21,7 @@
</div>
<div class="flex-1 min-h-0">
<PagedScroller :items="shownMembers" :page-size="30">
<PagedScroller :items="shownMembers" :page-size="30" item-key="userId">
<template #default="{ item }">
<GroupMemberItem
:member="(item as GroupMemberLite)"

View File

@ -31,6 +31,7 @@ import {
} from '../../utils/config'
import { useUserStore } from '@/store/modules/user'
import { buildChannelConversationStub } from '../../utils/channel'
import { getPrivateMessagePeerId } from '../../utils/message'
import type { Message } from '../types'
/**
@ -52,11 +53,11 @@ export const useMessagePuller = () => {
const groupStore = useGroupStore()
const currentUserId = Number(userStore.getUser?.id) || 0
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话" */
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话"curry currentUserId 进闭包减少 3 处调用方的样板 */
const getPrivatePeerId = (message: ImPrivateMessageRespVO) =>
message.senderId === currentUserId ? message.receiverId : message.senderId
getPrivateMessagePeerId(message, currentUserId)
/** 服务端私聊消息 -> 本地 Message */
/** 服务端私聊消息 -> 本地 MessagetargetId 是会话主键(对端 userId */
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
return {
id: message.id,
@ -66,7 +67,7 @@ export const useMessagePuller = () => {
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
targetId: message.receiverId,
targetId: getPrivatePeerId(message),
selfSend: message.senderId === currentUserId
}
}

View File

@ -23,7 +23,7 @@
<el-tabs v-model="activeTab" stretch>
<el-tab-pane :label="`已读(${readMembers.length})`" name="read">
<PagedScroller :items="readMembers" :page-size="20" class="h-75">
<PagedScroller :items="readMembers" :page-size="20" item-key="userId" class="h-75">
<template #default="{ item }">
<GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
</template>
@ -36,7 +36,7 @@
</div>
</el-tab-pane>
<el-tab-pane :label="`未读(${unreadMembers.length})`" name="unread">
<PagedScroller :items="unreadMembers" :page-size="20" class="h-75">
<PagedScroller :items="unreadMembers" :page-size="20" item-key="userId" class="h-75">
<template #default="{ item }">
<GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
</template>

View File

@ -14,7 +14,11 @@ import {
isGroupRequestNotification,
isNormalMessage
} from '../../utils/constants'
import { playAudioTip, resolveCallEndReasonText } from '../../utils/message'
import {
getPrivateMessagePeerId,
playAudioTip,
resolveCallEndReasonText
} from '../../utils/message'
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
import { useConversationStore } from './conversationStore'
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
@ -54,7 +58,7 @@ const isFriendDeleteWithClear = (frame: ImPrivateMessageDTO): boolean => {
}
/**
* WebSocket DTO -> Message
* WebSocket DTO -> MessagetargetId userId
* utils/user /
*/
const convertPrivateMessage = (
@ -68,7 +72,7 @@ const convertPrivateMessage = (
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
targetId: websocketMessage.receiverId,
targetId: getPrivateMessagePeerId(websocketMessage, currentUserId),
selfSend: websocketMessage.senderId === currentUserId
})
@ -442,7 +446,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const friendStore = useFriendStore()
const currentUserId = Number(userStore.getUser?.id) || 0
const selfSend = websocketMessage.senderId === currentUserId
const peerId = selfSend ? websocketMessage.receiverId : websocketMessage.senderId
const peerId = getPrivateMessagePeerId(websocketMessage, currentUserId)
// 未知对端(陌生人加好友前先收到消息等场景):异步补拉一次,下次再渲染就有 name/avatar
const friend = friendStore.getFriend(peerId)
if (!friend) {
@ -640,7 +644,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
computeFriendPeerId(frame: ImPrivateMessageDTO): number {
const userStore = useUserStore()
const currentUserId = Number(userStore.getUser?.id) || 0
return frame.senderId === currentUserId ? frame.receiverId : frame.senderId
return getPrivateMessagePeerId(frame, currentUserId)
},
/**

View File

@ -83,7 +83,7 @@ export interface Message {
// ========== 前端扩展字段 ==========
// 发送人显示名一律渲染时实时算utils/user.getSenderDisplayName / getSenderRealNickname
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId与 Conversation.targetId 一致
targetId: number // 会话目标编号(私聊=对端 userId / 群聊=groupId与 Conversation.targetId 一致
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
uploadProgress?: number // 媒体消息上传进度0-100status=SENDING 期间持续更新ack 后置 undefined
// 媒体消息内存中保留的原始 File下划线前缀表示不进 JSON / 不持久化IDB 恢复后必为 undefined

View File

@ -28,6 +28,21 @@ export const generateClientMessageId = (): string => {
return generateUUID()
}
// ==================== 私聊对端 userId ====================
/**
* / DTO userId receiver sender
*
* 4 inlinewebsocketStore.convertPrivateMessage / handlePrivateMessage / computeFriendPeerId
* useMessagePuller.getPrivatePeerId senderId / receiverId REST WS DTO
*/
export function getPrivateMessagePeerId(
message: { senderId: number; receiverId: number },
currentUserId: number
): number {
return message.senderId === currentUserId ? message.receiverId : message.senderId
}
// ==================== 文本片段tip 文案 + TEXT 气泡共用) ====================
// 既用于灰条 tip"XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。
// mention 段携带 userId 用于挂点击弹 UserInfoCardlink 段携带 href 用于 <a> 跳转;