feat(im): 优化代码,移除 message 里的 name 存储,避免更新困难。(为 friend、group 独立存储做准备)

im
YunaiV 2026-04-28 23:32:40 +08:00
parent f0fc144e8a
commit de39bc7fc1
20 changed files with 540 additions and 212 deletions

View File

@ -10,8 +10,8 @@
>
<UserAvatar
:size="avatarSize"
:name="member.showNickName"
:url="member.showImage"
:name="member.nickname"
:url="member.avatar"
:clickable="clickable"
:id="member.userId"
/>
@ -19,7 +19,7 @@
class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--el-text-color-regular)]"
:style="{ lineHeight: height + 'px' }"
>
{{ member.showNickName }}
{{ member.showName }}
</div>
</div>
</template>
@ -34,10 +34,9 @@ defineOptions({ name: 'ImGroupMember' })
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */
export interface GroupMemberLite {
userId: number // IM_AT_ALL_USER_ID@
showNickName: string //
showImage?: string
// GroupMember.status退
// CommonStatusEnum.DISABLE producer quit
nickname: string // nickname UserAvatar
showName: string // > displayUserName > nickname""@
avatar?: string
status?: number
}

View File

@ -59,6 +59,7 @@ import { useUserStore } from '@/store/modules/user'
import { useImUiStore } from '../store/uiStore'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { ImConversationType } from '../../utils/constants'
import type { UserInfo } from '../types'
import UserAvatar from './UserAvatar.vue'
@ -136,14 +137,17 @@ function handleSendMessage() {
if (!user.value) {
return
}
// friendStore muted"" FriendPage.onChat
const friendEntry = friendStore.getFriend(user.value.id)
// friendStore /
const friend = friendStore.getFriend(user.value.id)
const conversationName = friend
? getFriendDisplayName(friend)
: user.value.nickname || ''
conversationStore.openConversation(
user.value.id,
ImConversationType.PRIVATE,
user.value.nickname || '',
conversationName,
user.value.avatar || '',
{ muted: !!friendEntry?.muted }
{ muted: !!friend?.muted }
)
// tab
if (router.currentRoute.value.name !== 'ImHomeConversation') {

View File

@ -2,6 +2,7 @@ import { watch } from 'vue'
import { useConversationStore } from '../store/conversationStore'
import { useImWebSocketStore } from '../store/websocketStore'
import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from '../store/groupStore'
import {
pullPrivateMessages as apiPullPrivateMessages,
@ -44,16 +45,8 @@ export const useMessagePuller = () => {
const getPrivatePeerId = (message: ImPrivateMessageRespVO) =>
message.senderId === currentUserId ? message.receiverId : message.senderId
/** 群消息发送者在群内的展示名(群备注 > 用户昵称) */
const getGroupSenderNickName = (message: ImGroupMessageRespVO): string => {
const group = groupStore.getGroup(message.groupId)
const member = group?.members?.find((m) => m.userId === message.senderId)
return member?.displayUserName || member?.nickname || ''
}
/** 服务端私聊消息 -> 本地 Message */
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
const friend = friendStore.getFriend(getPrivatePeerId(message))
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
@ -62,7 +55,6 @@ export const useMessagePuller = () => {
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
senderNickName: friend?.nickname || '',
targetId: message.receiverId,
selfSend: message.senderId === currentUserId
}
@ -78,7 +70,6 @@ export const useMessagePuller = () => {
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
senderNickName: getGroupSenderNickName(message),
targetId: message.groupId,
selfSend: message.senderId === currentUserId,
atUserIds: message.atUserIds || [],
@ -95,7 +86,7 @@ export const useMessagePuller = () => {
return {
type: ImConversationType.PRIVATE,
targetId,
name: friend?.nickname || String(targetId),
name: friend ? getFriendDisplayName(friend) : String(targetId), // 会话列表 / 顶部标题展示:好友备注 > 真实昵称
avatar: friend?.avatar || ''
}
}
@ -131,13 +122,10 @@ export const useMessagePuller = () => {
if (isPrivate) {
const message = raw as ImPrivateMessageRespVO
if (message.type === ImMessageType.RECALL) {
const peerId = getPrivatePeerId(message)
conversationStore.recallMessage(
ImConversationType.PRIVATE,
peerId,
message.content,
friendStore.getFriend(peerId)?.nickname || '',
message.senderId === currentUserId
getPrivatePeerId(message),
message.content
)
continue
}
@ -151,9 +139,7 @@ export const useMessagePuller = () => {
conversationStore.recallMessage(
ImConversationType.GROUP,
message.groupId,
message.content,
getGroupSenderNickName(message),
message.senderId === currentUserId
message.content
)
continue
}
@ -177,7 +163,7 @@ export const useMessagePuller = () => {
/**
* pull true isConnected watch pull
* socket onopen friendStore/groupStore watcher senderNickName
* socket onopen friendStore/groupStore watcher
*/
let bootstrapped = false

View File

@ -35,26 +35,27 @@ export const useMessageSender = () => {
const conversationStore = useConversationStore()
const userStore = useUserStore()
/** 构造本地乐观消息对象id=0 表示尚未拿到服务端消息 id */
/**构造本地乐观消息对象id=0 表示尚未拿到服务端消息 id */
const buildLocalMessage = (opts: {
clientMessageId: string
content: string
targetId: number
type: number
atUserIds?: number[]
}): Message => ({
id: 0,
clientMessageId: opts.clientMessageId,
type: opts.type,
content: opts.content,
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: Number(userStore.getUser?.id) || 0,
senderNickName: userStore.getUser?.nickname || '',
targetId: opts.targetId,
selfSend: true,
atUserIds: opts.atUserIds
})
}): Message => {
return {
id: 0,
clientMessageId: opts.clientMessageId,
type: opts.type,
content: opts.content,
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: Number(userStore.getUser?.id) || 0,
targetId: opts.targetId,
selfSend: true,
atUserIds: opts.atUserIds
}
}
/**
*

View File

@ -59,7 +59,8 @@ onMounted(async () => {
// ========== 2. + ==========
// 2.1 WebSocket Tab
wsStore.connect()
// 2.2 / awaitpullOnce friendStore / groupStore senderNickName name/avatar
// 2.2 / awaitpullOnce friendStore / groupStore name/avatar
// utils/user store ConversationItem senderId
await Promise.all([
friendStore.loadFriends().catch((e) => console.warn('[IM] 预拉好友失败', e)),
groupStore.loadGroups().catch((e) => console.warn('[IM] 预拉群列表失败', e))

View File

@ -411,7 +411,7 @@ const visibleMembers = computed(() =>
props.members.filter(
(member) =>
member.status !== CommonStatusEnum.DISABLE &&
(member.showNickName || '').includes(searchText.value)
(member.showName || '').includes(searchText.value)
)
)

View File

@ -44,17 +44,17 @@
<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 + 当前会话上下文算名字 -->
<span
v-if="showSendName"
class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap"
>
{{ conversation.senderNickName }}:&nbsp;
{{ lastSenderDisplayName }}:&nbsp;
</span>
<span
class="flex-1 overflow-hidden text-12px truncate text-[var(--el-text-color-secondary)]"
>
{{ conversation.lastContent }}
{{ lastContentDisplay }}
</span>
<!-- 免打扰图标 -->
<Icon
@ -79,7 +79,9 @@ import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
import { ImConversationType, isNormalMessage } from '../../../../../utils/constants'
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
import { getSenderDisplayName } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
import type { Conversation } from '../../../../types'
import UserAvatar from '../../../../components/UserAvatar.vue'
@ -106,19 +108,48 @@ const isActive = computed(
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
/** 群聊 + 有发送者昵称 + 最后一条是普通消息 时,显示发送者前缀 */
/** 最后一条消息发送者的展示名:按 conversation 上下文走 WeChat 优先级实时算 */
const lastSenderDisplayName = computed(() => {
const senderId = props.conversation.lastSenderId
if (!senderId) {
return ''
}
return getSenderDisplayName(senderId, props.conversation.type, props.conversation.targetId)
})
/** 群聊 + 有最后发送者 + 最后一条是普通消息 时,显示发送者前缀 */
const showSendName = computed(() => {
if (!isGroup.value) {
return false
}
if (!props.conversation.senderNickName) {
if (!props.conversation.lastSenderId) {
return false
}
const last = props.conversation.messages?.[props.conversation.messages.length - 1]
if (!last) {
// lastMessageType messages TIP_TIME / TIP_TEXT / RECALL
const lastType = props.conversation.lastMessageType
if (lastType == null) {
return false
}
return isNormalMessage(last.type)
return isNormalMessage(lastType)
})
/**
* 列表展示文案撤回类型实时按 lastSenderId 避免改备注后老 lastContent 文案过期
* 其余类型直接用 conversation.lastContent按消息进来时固化的摘要
*/
const lastContentDisplay = computed(() => {
if (
props.conversation.lastMessageType === ImMessageType.RECALL &&
props.conversation.lastSenderId != null
) {
return buildRecallTip(
props.conversation.lastSenderId,
!!props.conversation.lastSelfSend,
props.conversation.type,
props.conversation.targetId
)
}
return props.conversation.lastContent
})
/** 会话列表 "@ 我" / "@ 全体成员" 红字提示 */

View File

@ -116,7 +116,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { getFriendShowName } from '@/views/im/utils/user'
import { getFriendDisplayName } from '@/views/im/utils/user'
import { ImConversationType } from '@/views/im/utils/constants'
import type { Conversation, Friend } from '../../../../types'
@ -148,7 +148,7 @@ const friendStore = useFriendStore()
const message = useMessage()
/** tile 标签 / 后续聊天界面用的展示名:备注优先 */
const displayName = computed(() => (props.friend ? getFriendShowName(props.friend) : ''))
const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
const displayNamePopoverVisible = ref(false)
const editDisplayName = ref('')

View File

@ -28,7 +28,7 @@
<Icon icon="ep:user-filled" :size="18" />
</div>
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
{{ allItem.showNickName }}
{{ allItem.showName }}
</span>
</div>
@ -117,8 +117,7 @@ const allItem = computed<GroupMemberLite | null>(() => {
// @ nickname IM_AT_ALL_NICKNAME :name
return {
userId: IM_AT_ALL_USER_ID,
// TODO @AI displayName
showNickName: IM_AT_ALL_NICKNAME,
showName: IM_AT_ALL_NICKNAME,
nickname: IM_AT_ALL_NICKNAME
}
})
@ -129,8 +128,8 @@ const memberItems = computed<GroupMemberLite[]>(() =>
(member) =>
member.userId !== selfUserId.value &&
member.status !== CommonStatusEnum.DISABLE &&
!!member.showNickName &&
member.showNickName.startsWith(props.searchText)
!!member.showName &&
member.showName.startsWith(props.searchText)
)
)

View File

@ -124,7 +124,7 @@ import { updateFile } from '@/api/infra/file'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { getMemberShowName } from '@/views/im/utils/user'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
@ -430,7 +430,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showNickName: getMemberShowName(member, friend),
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
@ -548,7 +548,7 @@ function onMentionSelect(member: GroupMemberLite) {
span.className = 'mention-token'
span.dataset.id = String(member.userId)
span.contentEditable = 'false'
span.textContent = `@${member.showNickName}`
span.textContent = `@${member.showName}`
mentionRange.insertNode(span)
// token editor contenteditable=false token
// DOM walk

View File

@ -159,11 +159,7 @@
>
<UserAvatar
:url="getAvatar(message)"
:name="
message.selfSend
? userStore.getUser?.nickname
: message.senderNickName || '对方'
"
:name="senderRealNicknameOf(message)"
:size="36"
:clickable="false"
/>
@ -172,11 +168,7 @@
class="flex justify-between items-start text-12px text-[var(--el-text-color-secondary)]"
>
<span class="font-medium text-[var(--el-text-color-regular)]">
{{
message.selfSend
? userStore.getUser?.nickname || ''
: message.senderNickName || ''
}}
{{ senderDisplayNameOf(message) }}
</span>
<span class="im-message-history__meta relative flex-shrink-0">
<span class="block text-right">{{ formatTime(message.sendTime) }}</span>
@ -257,7 +249,7 @@
v-else-if="message.type === ImMessageType.RECALL"
class="text-sm italic text-[var(--el-text-color-secondary)]"
>
{{ buildRecallTip(message.senderNickName || '', !!message.selfSend) }}
{{ recallTipOf(message) }}
</div>
<!-- 兜底 -->
@ -308,18 +300,24 @@ import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/mess
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessagePuller } from '../../../../composables/useMessagePuller'
import { ImConversationType, ImMessageType } from '../../../../../utils/constants'
import { useFriendStore } from '../../../../store/friendStore'
import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname
} from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
parseMessage,
buildRecallTip,
resolveTipText,
type TextMessage,
type ImageMessage,
type FileMessage,
type AudioMessage
} from '../../../../../utils/message'
import type { Message } from '../../../../types'
} from '@/views/im/utils/message'
import type { Message } from '@/views/im/home/types'
import UserAvatar from '../../../../components/UserAvatar.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
@ -337,6 +335,7 @@ const emit = defineEmits<{
const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const message = useMessage()
@ -349,6 +348,34 @@ const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const allMessages = computed<Message[]>(() => conversation.value?.messages || [])
/** 单条消息的发送人显示名:渲染时按 conversation 上下文走 WeChat 优先级实时算 */
function senderDisplayNameOf(message: Message): string {
return getSenderDisplayName(
message.senderId,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
}
/** 单条消息的发送人真实昵称:给 UserAvatar 色卡 / alt 用,永远是 nickname 不掺备注 */
function senderRealNicknameOf(message: Message): string {
return getSenderRealNickname(
message.senderId,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
}
/** 单条撤回消息的 tip 文案buildRecallTip 内部按 conversation 上下文实时算 sender 名 */
function recallTipOf(message: Message): string {
return buildRecallTip(
message.senderId,
!!message.selfSend,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
}
// ==================== ====================
/**
@ -442,17 +469,21 @@ const filteredMembersForPicker = computed<GroupMemberLite[]>(() => {
return []
}
const group = groupStore.getGroup(conversation.value.targetId)
const all = (group?.members || []).map((member) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
const all = (group?.members || []).map((member) => {
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
const trimmedKeyword = memberSearchKeyword.value.trim()
if (!trimmedKeyword) {
return all
}
return all.filter((member) => member.showNickName.includes(trimmedKeyword))
return all.filter((member) => member.showName.includes(trimmedKeyword))
})
/** 群成员 picker 选择:落 activeFilter + 关 popover + 清搜索词 */
@ -460,7 +491,7 @@ function onMemberSelect(member: GroupMemberLite) {
activeFilter.value = {
kind: 'member',
userId: member.userId,
nickname: member.showNickName
nickname: member.showName
}
memberPopoverVisible.value = false
memberSearchKeyword.value = ''
@ -536,7 +567,7 @@ async function loadEarlier() {
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
// 3. list / useMessagePuller
// convert Message沿 senderNickName
// convert Message puller
let earlier: Message[] = []
if (isGroup.value) {
const list = await apiGetGroupMessageList({
@ -645,7 +676,7 @@ function textSnippetOf(message: Message): string {
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.RECALL:
return buildRecallTip(message.senderNickName || '', !!message.selfSend)
return recallTipOf(message)
default:
return ''
}

View File

@ -40,11 +40,7 @@
点头像弹 UserInfoCard UserAvatar 内部承接 -->
<UserAvatar
:id="message.selfSend ? userStore.getUser?.id : message.senderId"
:name="
message.selfSend
? userStore.getUser?.nickname
: message.senderNickName || String(message.senderId)
"
:name="senderRealNickname"
:url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
:size="36"
/>
@ -55,7 +51,7 @@
v-if="showSenderName"
class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight"
>
{{ message.senderNickName || message.senderId }}
{{ senderDisplayName }}
</div>
<div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
<!-- 消息内容 type v-if 分支 -->
@ -222,7 +218,6 @@ import {
} from '../../../../../utils/constants'
import {
parseMessage,
buildRecallTip,
resolveTipText,
type TextMessage,
type ImageMessage,
@ -230,11 +225,18 @@ import {
type AudioMessage,
type VideoMessage
} from '../../../../../utils/message'
import { buildRecallTip } from '../../../../../utils/conversation'
import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useFriendStore } from '../../../../store/friendStore'
import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname
} from '../../../../../utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import type { Message } from '../../../../types'
@ -251,6 +253,7 @@ const props = defineProps<{
const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
// confirm message props.message vue/no-dupe-keys
@ -424,11 +427,36 @@ onBeforeUnmount(() => {
voicePlaying.value = false
})
// buildRecallTip 线 content
// recallMessage ""
const recallTip = computed(() =>
buildRecallTip(props.message.senderNickName, props.message.selfSend)
)
// buildRecallTip sender conversation WeChat
const recallTip = computed(() => {
const conversation = conversationStore.activeConversation
return buildRecallTip(
props.message.senderId,
props.message.selfSend,
conversation?.type ?? 0,
conversation?.targetId ?? 0
)
})
/** 头像色卡 fallback 文本:永远是真实昵称,不掺备注 */
const senderRealNickname = computed(() => {
const conversation = conversationStore.activeConversation
return getSenderRealNickname(
props.message.senderId,
conversation?.type ?? 0,
conversation?.targetId ?? 0
)
})
/** 气泡上方发送人显示名(仅群聊对方消息显示):好友备注 > 群备注 > 真实昵称 */
const senderDisplayName = computed(() => {
const conversation = conversationStore.activeConversation
return getSenderDisplayName(
props.message.senderId,
conversation?.type ?? 0,
conversation?.targetId ?? 0
)
})
/** 私聊「已读 / 未读」态(仅对自己发送的私聊消息展示) */
const privateReadLabel = computed(() => {
@ -474,12 +502,16 @@ const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
return []
}
const group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
return (group?.members || []).map((member) => {
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
})
/** 是否 @我(群消息展示小徽标) */

View File

@ -82,15 +82,18 @@
v-if="isGroup"
v-model="sideVisible"
:group="groupInfo"
:conversation="conversationStore.activeConversation"
:members="groupMembers"
:friends="groupFriends"
@reload="reloadGroupData"
@open-history="historyVisible = true"
/>
<ConversationPrivateSide
v-else
v-model="sideVisible"
:conversation="conversationStore.activeConversation"
:friend="privateFriend"
@open-history="historyVisible = true"
/>
<!-- 历史消息抽屉 -->
@ -111,6 +114,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { getMemberDisplayName } from '../../../../../utils/user'
import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '../../../../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
@ -185,12 +189,17 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
return []
}
const group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => ({
userId: member.userId,
showNickName: member.displayUserName || member.nickname,
showImage: member.avatar,
status: member.status
}))
return (group?.members || []).map((member) => {
// > > nickname
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
})
/** 好友列表(用于"邀请入群"对话框):把 friendStore 的全量好友 map 成 FriendLite 窄接口 */

View File

@ -11,14 +11,8 @@ import {
TIME_TIP_GAP_MS
} from '../../utils/constants'
import { imStorage, StorageKeys } from '../../utils/storage'
import {
buildRecallTip,
generateClientMessageId,
parseMessage,
parseRecallMessageId,
resolveTipText,
type TextMessage
} from '../../utils/message'
import { generateClientMessageId, parseRecallMessageId } from '../../utils/message'
import { resolveConversationLastContent } from '../../utils/conversation'
import type { Conversation, ConversationStoreMeta, Message } from '../types'
// TODO @芋艿:单个 conversation 的消息过多后,可能存储起来会很慢,后续看看怎么优化。
@ -246,7 +240,12 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用) */
createEmptyConversation(type: number, targetId: number, name: string, avatar: string): Conversation {
createEmptyConversation(
type: number,
targetId: number,
name: string,
avatar: string
): Conversation {
return {
targetId,
type,
@ -261,7 +260,6 @@ export const useConversationStore = defineStore('imConversationStore', {
muted: false,
atMe: false,
atAll: false,
senderNickName: '',
lastTimeTip: 0
}
},
@ -345,21 +343,35 @@ export const useConversationStore = defineStore('imConversationStore', {
if (messageInfo.id && message.id && message.id === messageInfo.id) {
return true
}
return !!(messageInfo.clientMessageId && message.clientMessageId && message.clientMessageId === messageInfo.clientMessageId)
return !!(
messageInfo.clientMessageId &&
message.clientMessageId &&
message.clientMessageId === messageInfo.clientMessageId
)
})
if (existingIndex >= 0) {
// 覆盖更新,保留本地已有但服务端未带的字段(如 senderNickName
conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo }
// 覆盖更新:服务端字段优先,本地已有的扩展字段(如 selfSend保留
conversation.messages[existingIndex] = {
...conversation.messages[existingIndex],
...messageInfo
}
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime
this.updateMaxId(conversationInfo.type, messageInfo.id)
this.saveConversations(conversation)
return
}
// 2.1 更新会话摘要lastContent / lastSendTime / senderNickName
conversation.lastContent = this.resolveLastContent(messageInfo)
// 2.1 更新会话摘要lastContent / lastSendTime + 事实索引 lastSenderId / lastMessageType / lastSelfSend
// 发送人名不存快照,由 ConversationItem 渲染时通过 utils/user.getSenderDisplayName 实时算
conversation.lastContent = resolveConversationLastContent(
messageInfo,
conversation.type,
conversation.targetId
)
conversation.lastSendTime = messageInfo.sendTime || Date.now()
conversation.senderNickName = messageInfo.senderNickName || ''
conversation.lastSenderId = messageInfo.senderId
conversation.lastMessageType = messageInfo.type
conversation.lastSelfSend = messageInfo.selfSend
// 2.2 群聊 @ 标记(仅对方消息 + 未读态有效)
if (
@ -405,7 +417,6 @@ export const useConversationStore = defineStore('imConversationStore', {
status: ImMessageStatus.UNREAD,
sendTime,
senderId: 0,
senderNickName: '',
targetId: conversationInfo.targetId,
selfSend: false
})
@ -436,29 +447,6 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations(conversation)
},
/** 根据消息类型计算会话列表最后一条摘要 */
resolveLastContent(messageInfo: Message): string {
switch (messageInfo.type) {
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.FILE:
return '[文件]'
case ImMessageType.VOICE:
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.RECALL:
return buildRecallTip(messageInfo.senderNickName, messageInfo.selfSend)
case ImMessageType.TEXT:
return parseMessage<TextMessage>(messageInfo.content)?.content ?? ''
case ImMessageType.TIP_TEXT:
// TIP_TEXT 后端常发裸字符串(群解散 / 退群 / 踢人),不能按 TextMessage JSON 解析,否则摘要变空
return resolveTipText(messageInfo.content)
default:
return parseMessage<TextMessage>(messageInfo.content)?.content ?? ''
}
},
/**
* clientMessageId
*
@ -486,14 +474,11 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations(conversation)
},
/** 撤回消息:解析撤回信号 content`{"messageId": xxx}`),找到原消息更新为 RECALL 态 + 刷新会话摘要 */
recallMessage(
conversationType: number,
targetId: number,
recallSignalContent: string,
senderNickName: string,
selfSend: boolean
) {
/**
* content`{"messageId": xxx}` RECALL +
* ConversationItem / MessageItem buildRecallTip
*/
recallMessage(conversationType: number, targetId: number, recallSignalContent: string) {
const messageId = parseRecallMessageId(recallSignalContent)
if (messageId <= 0) {
return
@ -508,12 +493,17 @@ export const useConversationStore = defineStore('imConversationStore', {
}
message.type = ImMessageType.RECALL
message.status = ImMessageStatus.RECALL
message.content = JSON.stringify({
content: buildRecallTip(senderNickName, selfSend)
})
// 最后一条消息是刚撤回的,才更新会话摘要
// content 不再写撤回文案:渲染层走 buildRecallTip(senderId, selfSend, ...) 实时算
// 这里清空,避免老 content 被误认为有效消息文本
message.content = ''
// 最后一条消息是刚撤回的,才更新会话摘要 + 事实索引
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
conversation.lastContent = buildRecallTip(senderNickName, selfSend)
conversation.lastContent = resolveConversationLastContent(
message,
conversation.type,
conversation.targetId
)
conversation.lastMessageType = ImMessageType.RECALL
}
this.saveConversations(conversation)
},
@ -613,18 +603,26 @@ export const useConversationStore = defineStore('imConversationStore', {
if (key.id && message.id && message.id === key.id) {
return true
}
return !!(key.clientMessageId && message.clientMessageId && message.clientMessageId === key.clientMessageId)
return !!(
key.clientMessageId &&
message.clientMessageId &&
message.clientMessageId === key.clientMessageId
)
})
if (index < 0) {
return
}
conversation.messages.splice(index, 1)
// 如果删的是最后一条,刷新摘要
// 如果删的是最后一条,按倒数第二条重算摘要 + 事实索引
if (index === conversation.messages.length) {
const last = conversation.messages[conversation.messages.length - 1]
conversation.lastContent = last ? this.resolveLastContent(last) : ''
conversation.lastContent = last
? resolveConversationLastContent(last, conversation.type, conversation.targetId)
: ''
conversation.lastSendTime = last?.sendTime || conversation.lastSendTime
conversation.senderNickName = last?.senderNickName || ''
conversation.lastSenderId = last?.senderId
conversation.lastMessageType = last?.type
conversation.lastSelfSend = last?.selfSend
}
this.saveConversations(conversation)
},

View File

@ -12,6 +12,7 @@ import {
} from '@/api/im/friend'
import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants'
import { getFriendDisplayName } from '../../utils/user'
import type { Friend } from '../types'
/**
@ -61,7 +62,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const conversationStore = useConversationStore()
for (const f of this.friends) {
conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, {
name: f.nickname,
name: getFriendDisplayName(f),
avatar: f.avatar,
muted: f.muted
})
@ -120,8 +121,9 @@ export const useFriendStore = defineStore('imFriendStore', {
}
// 同步对应私聊会话的展示
const conversationStore = useConversationStore()
const merged = this.getFriend(friend.friendUserId)
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
name: friend.nickname,
name: merged ? getFriendDisplayName(merged) : friend.nickname,
avatar: friend.avatar,
muted: friend.muted
})
@ -149,6 +151,26 @@ export const useFriendStore = defineStore('imFriendStore', {
}
},
/**
*
*
* /im/friend/update friend + name
* UI /
*/
async setDisplayName(friendUserId: number, displayName: string) {
const value = displayName.trim()
// 后端的 displayName 语义null/undefined = 不改,"" = 清空,所以这里直接传 value可能是空串
await apiUpdateFriend({ friendUserId, displayName: value })
const friend = this.getFriend(friendUserId)
if (friend) {
friend.displayName = value
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
name: getFriendDisplayName(friend)
})
}
},
/** 切换用户时清空 */
clear() {
this.friends = []
@ -164,6 +186,7 @@ function convertFriend(vo: ImFriendRespVO): Friend {
nickname: vo.nickname || String(vo.friendUserId),
avatar: vo.avatar,
muted: !!vo.muted,
displayName: vo.displayName || '',
status: vo.status,
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined

View File

@ -7,6 +7,7 @@ import { ImWebSocketMessageType, ImMessageType, ImConversationType } from '../..
import { playAudioTip } from '../../utils/message'
import { useConversationStore } from './conversationStore'
import { useFriendStore } from './friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
@ -17,11 +18,13 @@ import type {
Message
} from '../types'
/** WebSocket 私聊 DTO -> 前端 MessagesendTime 转毫秒senderNickName 由调用方按好友信息补 */
/**
* WebSocket DTO -> Message
* utils/user /
*/
const convertPrivateMessage = (
websocketMessage: ImPrivateMessageDTO,
currentUserId: number,
senderNickName: string
currentUserId: number
): Message => ({
id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId,
@ -30,16 +33,18 @@ const convertPrivateMessage = (
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.receiverId,
selfSend: websocketMessage.senderId === currentUserId
})
/** WebSocket 群聊 DTO -> 前端 Message群消息额外带 atUserIds / receiverUserIds给 @ 标记和回执用 */
/**
* WebSocket DTO -> Message
* atUserIds / receiverUserIds @
* receiptStatus / readCount UI
*/
const convertGroupMessage = (
websocketMessage: ImGroupMessageDTO,
currentUserId: number,
senderNickName: string
currentUserId: number
): Message => ({
id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId,
@ -48,11 +53,12 @@ const convertGroupMessage = (
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.groupId,
selfSend: websocketMessage.senderId === currentUserId,
atUserIds: websocketMessage.atUserIds || [],
receiverUserIds: websocketMessage.receiverUserIds || []
receiverUserIds: websocketMessage.receiverUserIds || [],
receiptStatus: websocketMessage.receiptStatus,
readCount: websocketMessage.readCount
})
/**
@ -284,6 +290,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!friend) {
friendStore.loadFriendInfo(peerId).catch(() => undefined)
}
// 会话标题永远跟「对端」走(不管谁发的消息);这里只算一次给 insertMessage 用
const peerDisplayName = friend ? getFriendDisplayName(friend) : ''
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态),不让它作为新消息进列表
@ -291,20 +299,18 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.recallMessage(
ImConversationType.PRIVATE,
peerId,
websocketMessage.content,
friend?.nickname || '',
selfSend
websocketMessage.content
)
return
}
// 4. 后端 DTO → 前端 Message
const message = convertPrivateMessage(websocketMessage, currentUserId, friend?.nickname || '')
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
const message = convertPrivateMessage(websocketMessage, currentUserId)
conversationStore.insertMessage(
{
type: ImConversationType.PRIVATE,
targetId: peerId,
name: friend?.nickname || String(peerId),
name: peerDisplayName || String(peerId),
avatar: friend?.avatar || ''
},
message
@ -361,13 +367,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
},
/**
* + handlePrivateMessage senderNickName
* + handlePrivateMessage
*
*
* 1. 线
* 2. + senderNickName
* 2.
* 3. TIP
* 4. Message + at
* 4. Message + at
* 5. lastMessageId
*/
handleGroupMessage(websocketMessage: ImGroupMessageDTO) {
@ -390,9 +396,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!group) {
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
}
// senderNickName 取值优先级:群内自定义显示名 > 用户昵称 > 空(群里通常用前者,符合微信式体验)
const senderMember = group?.members?.find((m) => m.userId === websocketMessage.senderId)
const senderNickName = senderMember?.displayUserName || senderMember?.nickname || ''
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态)
@ -400,15 +403,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.recallMessage(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.content,
senderNickName,
selfSend
websocketMessage.content
)
return
}
// 4. 后端 DTO → 前端 Message
const message = convertGroupMessage(websocketMessage, currentUserId, senderNickName)
// 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
const message = convertGroupMessage(websocketMessage, currentUserId)
conversationStore.insertMessage(
{
type: ImConversationType.GROUP,

View File

@ -49,7 +49,14 @@ export interface Conversation {
lastSendTime: number // 最后一条消息时间,用于排序
unreadCount: number // 未读数
messages: Message[] // 消息列表
senderNickName?: string // 最后一条消息的发送者昵称(群聊列表前缀展示用)
/**
* "谁、什么类型、是不是我发的"
* / utils/user.getSenderDisplayName
* /
*/
lastSenderId?: number
lastMessageType?: number
lastSelfSend?: boolean
// ========== UI 状态 ==========
deleted?: boolean // 是否已删除(软删标记,持久化时过滤)
@ -76,7 +83,8 @@ export interface Message {
readCount?: number // 群回执已读人数(仅群消息)
// ========== 前端扩展字段 ==========
senderNickName: string // 发送人昵称(前端从 friendStore / groupStore 补全)
// 发送人显示名一律渲染时实时算utils/user.getSenderDisplayName / getSenderRealNickname
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId与 Conversation.targetId 一致
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
}
@ -138,9 +146,10 @@ export interface Friend {
// ========== 后端字段(对齐 ImFriendRespVO ==========
id?: number // 好友关系记录编号(本地乐观新增时可能暂缺)
friendUserId: number // 好友用户编号(与 Conversation.targetId 对齐)
nickname: string // 好友昵称
nickname: string // 好友昵称对方真实昵称永远不被备注覆盖UI 显示走 displayName || nickname
avatar?: string // 好友头像
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
displayName?: string // 好友展示备注:仅自己可见的别名(命名对齐 GroupMember.displayGroupName 风格,单字段不歧义就不带 Friend 前缀)
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)

View File

@ -0,0 +1,71 @@
// ====================================================================
// IM 会话 / 撤回展示 utility
// ====================================================================
// 职责:基于会话上下文 + sender 信息实时算"展示文案"。
// 之前这些值是写入消息时固化到 Message.senderShowName / Conversation.senderShowName
// 改备注 / 改群昵称后历史消息不会刷新;改成实时算后字段语义彻底干净。
//
// 与 utils/user.ts 的关系:
// user.ts 回答"谁叫什么名字"conversation.ts 在它基础上拼"撤回 tip / 摘要"等文案
// ====================================================================
import { ImMessageType } from './constants'
import { parseMessage, resolveTipText, type TextMessage } from './message'
import { getSenderDisplayName } from './user'
import type { Message } from '../home/types'
/**
* "你撤回了一条消息" WeChat
*
* / store ready
* getSenderDisplayName 退 String(senderId)"对方"
*/
export function buildRecallTip(
senderId: number,
selfSend: boolean,
conversationType: number,
conversationTargetId: number
): string {
if (selfSend) {
return '你撤回了一条消息'
}
const senderDisplayName = getSenderDisplayName(senderId, conversationType, conversationTargetId)
return `${senderDisplayName || '对方'} 撤回了一条消息`
}
/**
*
*
* RECALL buildRecallTip message senderShowName
* message.content
*/
export function resolveConversationLastContent(
message: Message,
conversationType: number,
conversationTargetId: number
): string {
switch (message.type) {
case ImMessageType.IMAGE:
return '[图片]'
case ImMessageType.FILE:
return '[文件]'
case ImMessageType.VOICE:
return '[语音]'
case ImMessageType.VIDEO:
return '[视频]'
case ImMessageType.RECALL:
return buildRecallTip(
message.senderId,
message.selfSend,
conversationType,
conversationTargetId
)
case ImMessageType.TEXT:
return parseMessage<TextMessage>(message.content)?.content ?? ''
case ImMessageType.TIP_TEXT:
// TIP_TEXT 后端常发裸字符串(群解散 / 退群 / 踢人),不能按 TextMessage JSON 解析,否则摘要变空
return resolveTipText(message.content)
default:
return parseMessage<TextMessage>(message.content)?.content ?? ''
}
}

View File

@ -107,14 +107,6 @@ export const resolveTipText = (content: string): string => {
// ==================== 撤回 ====================
/**
*
* ImMessageType.TIP_TEXT(21)
*/
export const buildRecallTip = (senderName: string, selfSend: boolean): string => {
return selfSend ? '你撤回了一条消息' : `${senderName || '对方'} 撤回了一条消息`
}
/**
* TIP_TEXT content id
* content `{"messageId": 123}` messageId 0 tip

141
src/views/im/utils/user.ts Normal file
View File

@ -0,0 +1,141 @@
// ====================================================================
// IM 用户展示名 utility
// ====================================================================
// 职责:统一回答"某个用户在 UI 上应该叫什么名字"。
// 拆两层:
// 1. 纯派生getFriendDisplayName / getMemberDisplayName—— 输入 friend / member 对象,
// 不查 store给 store 内部 / 各种组装点复用,避免逻辑散落
// 2. 上下文感知getSenderDisplayName / getSenderRealNickname—— 渲染时按
// conversation 上下文实时查 friendStore / groupStore / userStore让备注 / 群昵称 /
// 真实昵称变更后所有历史消息立即刷新(不再写"快照"到 message 字段里)
//
// 命名约定:函数名一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// ====================================================================
import { useUserStore } from '@/store/modules/user'
import { ImConversationType } from './constants'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'
import type { Friend } from '../home/types'
/**
* DISABLEdisplayName
*
*/
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
}
/**
* > displayUserName >
*
* 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
}
/**
* conversation WeChat
*
* - senderId === currentUserId
* - >
* - > displayUserName >
* - store ready / String(senderId)
*
* "展示给用户看的发送人名" tip
* message / Vue
*/
export function getSenderDisplayName(
senderId: number,
conversationType: number,
conversationTargetId: number
): string {
const userStore = useUserStore()
const selfId = Number(userStore.getUser?.id) || 0
// 群聊场景所有人(含自己)都走 member + friend 三级——自己设了"我在本群昵称"也要生效
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对方兜底走 senderId 字符串
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId)
}
return String(senderId)
}
// 私聊场景:自己直接走 userStore对方走好友备注 > 真实昵称
if (conversationType === ImConversationType.PRIVATE) {
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId)
}
const friend = useFriendStore().getFriend(senderId)
if (friend) {
return getFriendDisplayName(friend)
}
return String(senderId)
}
// 未知会话类型兜底
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId)
}
return String(senderId)
}
/**
* nickname
*
* UserAvatar :name / alt
* friend.nickname member.nickname userStore
*/
export function getSenderRealNickname(
senderId: number,
conversationType: number,
conversationTargetId: number
): string {
const userStore = useUserStore()
const selfId = Number(userStore.getUser?.id) || 0
// 群聊先走 member.nicknameself 也是 member异常时再走 self / senderId 兜底
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
if (member?.nickname) {
return member.nickname
}
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId)
}
return String(senderId)
}
if (conversationType === ImConversationType.PRIVATE) {
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId)
}
const friend = useFriendStore().getFriend(senderId)
return friend?.nickname || String(senderId)
}
if (senderId === selfId) {
return userStore.getUser?.nickname || String(senderId)
}
return String(senderId)
}