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 <UserAvatar
:size="avatarSize" :size="avatarSize"
:name="member.showNickName" :name="member.nickname"
:url="member.showImage" :url="member.avatar"
:clickable="clickable" :clickable="clickable"
:id="member.userId" :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)]" 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' }" :style="{ lineHeight: height + 'px' }"
> >
{{ member.showNickName }} {{ member.showName }}
</div> </div>
</div> </div>
</template> </template>
@ -34,10 +34,9 @@ defineOptions({ name: 'ImGroupMember' })
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */ /** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */
export interface GroupMemberLite { export interface GroupMemberLite {
userId: number // IM_AT_ALL_USER_ID@ userId: number // IM_AT_ALL_USER_ID@
showNickName: string // nickname: string // nickname UserAvatar
showImage?: string showName: string // > displayUserName > nickname""@
// GroupMember.status退 avatar?: string
// CommonStatusEnum.DISABLE producer quit
status?: number status?: number
} }

View File

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

View File

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

View File

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

View File

@ -59,7 +59,8 @@ onMounted(async () => {
// ========== 2. + ========== // ========== 2. + ==========
// 2.1 WebSocket Tab // 2.1 WebSocket Tab
wsStore.connect() 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([ await Promise.all([
friendStore.loadFriends().catch((e) => console.warn('[IM] 预拉好友失败', e)), friendStore.loadFriends().catch((e) => console.warn('[IM] 预拉好友失败', e)),
groupStore.loadGroups().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( props.members.filter(
(member) => (member) =>
member.status !== CommonStatusEnum.DISABLE && 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"> <div class="flex items-center mt-1 leading-5">
<!-- @红字提示atMe 优先于 atAll --> <!-- @红字提示atMe 优先于 atAll -->
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span> <span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span>
<!-- 群聊最后一条发送者前缀 --> <!-- 群聊最后一条发送者前缀实时按 lastSenderId + 当前会话上下文算名字 -->
<span <span
v-if="showSendName" v-if="showSendName"
class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap" class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap"
> >
{{ conversation.senderNickName }}:&nbsp; {{ lastSenderDisplayName }}:&nbsp;
</span> </span>
<span <span
class="flex-1 overflow-hidden text-12px truncate text-[var(--el-text-color-secondary)]" class="flex-1 overflow-hidden text-12px truncate text-[var(--el-text-color-secondary)]"
> >
{{ conversation.lastContent }} {{ lastContentDisplay }}
</span> </span>
<!-- 免打扰图标 --> <!-- 免打扰图标 -->
<Icon <Icon
@ -79,7 +79,9 @@ import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore' 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 type { Conversation } from '../../../../types'
import UserAvatar from '../../../../components/UserAvatar.vue' import UserAvatar from '../../../../components/UserAvatar.vue'
@ -106,19 +108,48 @@ const isActive = computed(
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP) 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(() => { const showSendName = computed(() => {
if (!isGroup.value) { if (!isGroup.value) {
return false return false
} }
if (!props.conversation.senderNickName) { if (!props.conversation.lastSenderId) {
return false return false
} }
const last = props.conversation.messages?.[props.conversation.messages.length - 1] // lastMessageType messages TIP_TIME / TIP_TEXT / RECALL
if (!last) { const lastType = props.conversation.lastMessageType
if (lastType == null) {
return false 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 { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useFriendStore } from '@/views/im/home/store/friendStore' 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 { ImConversationType } from '@/views/im/utils/constants'
import type { Conversation, Friend } from '../../../../types' import type { Conversation, Friend } from '../../../../types'
@ -148,7 +148,7 @@ const friendStore = useFriendStore()
const message = useMessage() const message = useMessage()
/** tile 标签 / 后续聊天界面用的展示名:备注优先 */ /** tile 标签 / 后续聊天界面用的展示名:备注优先 */
const displayName = computed(() => (props.friend ? getFriendShowName(props.friend) : '')) const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
const displayNamePopoverVisible = ref(false) const displayNamePopoverVisible = ref(false)
const editDisplayName = ref('') const editDisplayName = ref('')

View File

@ -28,7 +28,7 @@
<Icon icon="ep:user-filled" :size="18" /> <Icon icon="ep:user-filled" :size="18" />
</div> </div>
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]"> <span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
{{ allItem.showNickName }} {{ allItem.showName }}
</span> </span>
</div> </div>
@ -117,8 +117,7 @@ const allItem = computed<GroupMemberLite | null>(() => {
// @ nickname IM_AT_ALL_NICKNAME :name // @ nickname IM_AT_ALL_NICKNAME :name
return { return {
userId: IM_AT_ALL_USER_ID, userId: IM_AT_ALL_USER_ID,
// TODO @AI displayName showName: IM_AT_ALL_NICKNAME,
showNickName: IM_AT_ALL_NICKNAME,
nickname: IM_AT_ALL_NICKNAME nickname: IM_AT_ALL_NICKNAME
} }
}) })
@ -129,8 +128,8 @@ const memberItems = computed<GroupMemberLite[]>(() =>
(member) => (member) =>
member.userId !== selfUserId.value && member.userId !== selfUserId.value &&
member.status !== CommonStatusEnum.DISABLE && member.status !== CommonStatusEnum.DISABLE &&
!!member.showNickName && !!member.showName &&
member.showNickName.startsWith(props.searchText) 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 { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore' import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore' 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 { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants' import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import { import {
@ -430,7 +430,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
const friend = friendStore.getFriend(member.userId) const friend = friendStore.getFriend(member.userId)
return { return {
userId: member.userId, userId: member.userId,
showNickName: getMemberShowName(member, friend), showName: getMemberDisplayName(member, friend),
nickname: member.nickname, nickname: member.nickname,
avatar: member.avatar, avatar: member.avatar,
status: member.status status: member.status
@ -548,7 +548,7 @@ function onMentionSelect(member: GroupMemberLite) {
span.className = 'mention-token' span.className = 'mention-token'
span.dataset.id = String(member.userId) span.dataset.id = String(member.userId)
span.contentEditable = 'false' span.contentEditable = 'false'
span.textContent = `@${member.showNickName}` span.textContent = `@${member.showName}`
mentionRange.insertNode(span) mentionRange.insertNode(span)
// token editor contenteditable=false token // token editor contenteditable=false token
// DOM walk // DOM walk

View File

@ -159,11 +159,7 @@
> >
<UserAvatar <UserAvatar
:url="getAvatar(message)" :url="getAvatar(message)"
:name=" :name="senderRealNicknameOf(message)"
message.selfSend
? userStore.getUser?.nickname
: message.senderNickName || '对方'
"
:size="36" :size="36"
:clickable="false" :clickable="false"
/> />
@ -172,11 +168,7 @@
class="flex justify-between items-start text-12px text-[var(--el-text-color-secondary)]" class="flex justify-between items-start text-12px text-[var(--el-text-color-secondary)]"
> >
<span class="font-medium text-[var(--el-text-color-regular)]"> <span class="font-medium text-[var(--el-text-color-regular)]">
{{ {{ senderDisplayNameOf(message) }}
message.selfSend
? userStore.getUser?.nickname || ''
: message.senderNickName || ''
}}
</span> </span>
<span class="im-message-history__meta relative flex-shrink-0"> <span class="im-message-history__meta relative flex-shrink-0">
<span class="block text-right">{{ formatTime(message.sendTime) }}</span> <span class="block text-right">{{ formatTime(message.sendTime) }}</span>
@ -257,7 +249,7 @@
v-else-if="message.type === ImMessageType.RECALL" v-else-if="message.type === ImMessageType.RECALL"
class="text-sm italic text-[var(--el-text-color-secondary)]" class="text-sm italic text-[var(--el-text-color-secondary)]"
> >
{{ buildRecallTip(message.senderNickName || '', !!message.selfSend) }} {{ recallTipOf(message) }}
</div> </div>
<!-- 兜底 --> <!-- 兜底 -->
@ -308,18 +300,24 @@ import { getPrivateMessageList as apiGetPrivateMessageList } from '@/api/im/mess
import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group' import { getGroupMessageList as apiGetGroupMessageList } from '@/api/im/message/group'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
import { useMessagePuller } from '../../../../composables/useMessagePuller' import { useFriendStore } from '../../../../store/friendStore'
import { ImConversationType, ImMessageType } from '../../../../../utils/constants' 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 { import {
parseMessage, parseMessage,
buildRecallTip,
resolveTipText, resolveTipText,
type TextMessage, type TextMessage,
type ImageMessage, type ImageMessage,
type FileMessage, type FileMessage,
type AudioMessage type AudioMessage
} from '../../../../../utils/message' } from '@/views/im/utils/message'
import type { Message } from '../../../../types' import type { Message } from '@/views/im/home/types'
import UserAvatar from '../../../../components/UserAvatar.vue' import UserAvatar from '../../../../components/UserAvatar.vue'
import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue' import GroupMember, { type GroupMemberLite } from '../../../../components/GroupMember.vue'
@ -337,6 +335,7 @@ const emit = defineEmits<{
const userStore = useUserStore() const userStore = useUserStore()
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const friendStore = useFriendStore()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller() const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const message = useMessage() const message = useMessage()
@ -349,6 +348,34 @@ const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP) const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const allMessages = computed<Message[]>(() => conversation.value?.messages || []) 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 [] return []
} }
const group = groupStore.getGroup(conversation.value.targetId) const group = groupStore.getGroup(conversation.value.targetId)
const all = (group?.members || []).map((member) => ({ const all = (group?.members || []).map((member) => {
userId: member.userId, const friend = friendStore.getFriend(member.userId)
showNickName: member.displayUserName || member.nickname, return {
showImage: member.avatar, userId: member.userId,
status: member.status showName: getMemberDisplayName(member, friend),
})) nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
const trimmedKeyword = memberSearchKeyword.value.trim() const trimmedKeyword = memberSearchKeyword.value.trim()
if (!trimmedKeyword) { if (!trimmedKeyword) {
return all return all
} }
return all.filter((member) => member.showNickName.includes(trimmedKeyword)) return all.filter((member) => member.showName.includes(trimmedKeyword))
}) })
/** 群成员 picker 选择:落 activeFilter + 关 popover + 清搜索词 */ /** 群成员 picker 选择:落 activeFilter + 关 popover + 清搜索词 */
@ -460,7 +491,7 @@ function onMemberSelect(member: GroupMemberLite) {
activeFilter.value = { activeFilter.value = {
kind: 'member', kind: 'member',
userId: member.userId, userId: member.userId,
nickname: member.showNickName nickname: member.showName
} }
memberPopoverVisible.value = false memberPopoverVisible.value = false
memberSearchKeyword.value = '' memberSearchKeyword.value = ''
@ -536,7 +567,7 @@ async function loadEarlier() {
const maxId = Number.isFinite(earliestId) ? earliestId : undefined const maxId = Number.isFinite(earliestId) ? earliestId : undefined
// 3. list / useMessagePuller // 3. list / useMessagePuller
// convert Message沿 senderNickName // convert Message puller
let earlier: Message[] = [] let earlier: Message[] = []
if (isGroup.value) { if (isGroup.value) {
const list = await apiGetGroupMessageList({ const list = await apiGetGroupMessageList({
@ -645,7 +676,7 @@ function textSnippetOf(message: Message): string {
case ImMessageType.VIDEO: case ImMessageType.VIDEO:
return '[视频]' return '[视频]'
case ImMessageType.RECALL: case ImMessageType.RECALL:
return buildRecallTip(message.senderNickName || '', !!message.selfSend) return recallTipOf(message)
default: default:
return '' return ''
} }

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import {
} from '@/api/im/friend' } from '@/api/im/friend'
import { useConversationStore } from './conversationStore' import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants' import { ImConversationType } from '../../utils/constants'
import { getFriendDisplayName } from '../../utils/user'
import type { Friend } from '../types' import type { Friend } from '../types'
/** /**
@ -61,7 +62,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
for (const f of this.friends) { for (const f of this.friends) {
conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, { conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, {
name: f.nickname, name: getFriendDisplayName(f),
avatar: f.avatar, avatar: f.avatar,
muted: f.muted muted: f.muted
}) })
@ -120,8 +121,9 @@ export const useFriendStore = defineStore('imFriendStore', {
} }
// 同步对应私聊会话的展示 // 同步对应私聊会话的展示
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const merged = this.getFriend(friend.friendUserId)
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, { conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
name: friend.nickname, name: merged ? getFriendDisplayName(merged) : friend.nickname,
avatar: friend.avatar, avatar: friend.avatar,
muted: friend.muted 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() { clear() {
this.friends = [] this.friends = []
@ -164,6 +186,7 @@ function convertFriend(vo: ImFriendRespVO): Friend {
nickname: vo.nickname || String(vo.friendUserId), nickname: vo.nickname || String(vo.friendUserId),
avatar: vo.avatar, avatar: vo.avatar,
muted: !!vo.muted, muted: !!vo.muted,
displayName: vo.displayName || '',
status: vo.status, status: vo.status,
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined, addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).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 { playAudioTip } from '../../utils/message'
import { useConversationStore } from './conversationStore' import { useConversationStore } from './conversationStore'
import { useFriendStore } from './friendStore' import { useFriendStore } from './friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore' import { useGroupStore } from './groupStore'
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private' import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group' import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
@ -17,11 +18,13 @@ import type {
Message Message
} from '../types' } from '../types'
/** WebSocket 私聊 DTO -> 前端 MessagesendTime 转毫秒senderNickName 由调用方按好友信息补 */ /**
* WebSocket DTO -> Message
* utils/user /
*/
const convertPrivateMessage = ( const convertPrivateMessage = (
websocketMessage: ImPrivateMessageDTO, websocketMessage: ImPrivateMessageDTO,
currentUserId: number, currentUserId: number
senderNickName: string
): Message => ({ ): Message => ({
id: websocketMessage.id, id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId, clientMessageId: websocketMessage.clientMessageId,
@ -30,16 +33,18 @@ const convertPrivateMessage = (
status: websocketMessage.status, status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(), sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId, senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.receiverId, targetId: websocketMessage.receiverId,
selfSend: websocketMessage.senderId === currentUserId selfSend: websocketMessage.senderId === currentUserId
}) })
/** WebSocket 群聊 DTO -> 前端 Message群消息额外带 atUserIds / receiverUserIds给 @ 标记和回执用 */ /**
* WebSocket DTO -> Message
* atUserIds / receiverUserIds @
* receiptStatus / readCount UI
*/
const convertGroupMessage = ( const convertGroupMessage = (
websocketMessage: ImGroupMessageDTO, websocketMessage: ImGroupMessageDTO,
currentUserId: number, currentUserId: number
senderNickName: string
): Message => ({ ): Message => ({
id: websocketMessage.id, id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId, clientMessageId: websocketMessage.clientMessageId,
@ -48,11 +53,12 @@ const convertGroupMessage = (
status: websocketMessage.status, status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(), sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId, senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.groupId, targetId: websocketMessage.groupId,
selfSend: websocketMessage.senderId === currentUserId, selfSend: websocketMessage.senderId === currentUserId,
atUserIds: websocketMessage.atUserIds || [], 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) { if (!friend) {
friendStore.loadFriendInfo(peerId).catch(() => undefined) friendStore.loadFriendInfo(peerId).catch(() => undefined)
} }
// 会话标题永远跟「对端」走(不管谁发的消息);这里只算一次给 insertMessage 用
const peerDisplayName = friend ? getFriendDisplayName(friend) : ''
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage // 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态),不让它作为新消息进列表 // 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态),不让它作为新消息进列表
@ -291,20 +299,18 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.recallMessage( conversationStore.recallMessage(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
websocketMessage.content, websocketMessage.content
friend?.nickname || '',
selfSend
) )
return return
} }
// 4. 后端 DTO → 前端 Message // 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
const message = convertPrivateMessage(websocketMessage, currentUserId, friend?.nickname || '') const message = convertPrivateMessage(websocketMessage, currentUserId)
conversationStore.insertMessage( conversationStore.insertMessage(
{ {
type: ImConversationType.PRIVATE, type: ImConversationType.PRIVATE,
targetId: peerId, targetId: peerId,
name: friend?.nickname || String(peerId), name: peerDisplayName || String(peerId),
avatar: friend?.avatar || '' avatar: friend?.avatar || ''
}, },
message message
@ -361,13 +367,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}, },
/** /**
* + handlePrivateMessage senderNickName * + handlePrivateMessage
* *
* *
* 1. 线 * 1. 线
* 2. + senderNickName * 2.
* 3. TIP * 3. TIP
* 4. Message + at * 4. Message + at
* 5. lastMessageId * 5. lastMessageId
*/ */
handleGroupMessage(websocketMessage: ImGroupMessageDTO) { handleGroupMessage(websocketMessage: ImGroupMessageDTO) {
@ -390,9 +396,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!group) { if (!group) {
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined) 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}` // 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`
// 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态) // 这里拦截下来改走 recallMessage把原消息更新为 RECALL 态)
@ -400,15 +403,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.recallMessage( conversationStore.recallMessage(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
websocketMessage.content, websocketMessage.content
senderNickName,
selfSend
) )
return return
} }
// 4. 后端 DTO → 前端 Message // 4. 后端 DTO → 前端 Message:发送人名渲染时实时算,不写入消息字段
const message = convertGroupMessage(websocketMessage, currentUserId, senderNickName) const message = convertGroupMessage(websocketMessage, currentUserId)
conversationStore.insertMessage( conversationStore.insertMessage(
{ {
type: ImConversationType.GROUP, type: ImConversationType.GROUP,

View File

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

View File

@ -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 * TIP_TEXT content id
* content `{"messageId": 123}` messageId 0 tip * 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)
}