(() => {
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 ''
}
diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue
index 3ef223961..15eff899f 100644
--- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue
+++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue
@@ -40,11 +40,7 @@
点头像弹 UserInfoCard 由 UserAvatar 内部承接 -->
@@ -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 }}
@@ -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(() => {
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
+ }
+ })
})
/** 是否 @我(群消息展示小徽标) */
diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue
index 5271a09e6..be22ff914 100644
--- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue
+++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue
@@ -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"
/>
@@ -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(() => {
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 窄接口 */
diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts
index 6c0e95fe8..699877c3e 100644
--- a/src/views/im/home/store/conversationStore.ts
+++ b/src/views/im/home/store/conversationStore.ts
@@ -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(messageInfo.content)?.content ?? ''
- case ImMessageType.TIP_TEXT:
- // TIP_TEXT 后端常发裸字符串(群解散 / 退群 / 踢人),不能按 TextMessage JSON 解析,否则摘要变空
- return resolveTipText(messageInfo.content)
- default:
- return parseMessage(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)
},
diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts
index eba08013a..aa650bb8d 100644
--- a/src/views/im/home/store/friendStore.ts
+++ b/src/views/im/home/store/friendStore.ts
@@ -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
diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts
index e1cb27ddc..e6c745dd8 100644
--- a/src/views/im/home/store/websocketStore.ts
+++ b/src/views/im/home/store/websocketStore.ts
@@ -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 -> 前端 Message:sendTime 转毫秒;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,
diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts
index c2016235b..a638e1f59 100644
--- a/src/views/im/home/types/index.ts
+++ b/src/views/im/home/types/index.ts
@@ -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 // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts
new file mode 100644
index 000000000..07184c430
--- /dev/null
+++ b/src/views/im/utils/conversation.ts
@@ -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(message.content)?.content ?? ''
+ case ImMessageType.TIP_TEXT:
+ // TIP_TEXT 后端常发裸字符串(群解散 / 退群 / 踢人),不能按 TextMessage JSON 解析,否则摘要变空
+ return resolveTipText(message.content)
+ default:
+ return parseMessage(message.content)?.content ?? ''
+ }
+}
diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts
index d5478a53c..e7e90833d 100644
--- a/src/views/im/utils/message.ts
+++ b/src/views/im/utils/message.ts
@@ -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)
diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts
new file mode 100644
index 000000000..02ba052cd
--- /dev/null
+++ b/src/views/im/utils/user.ts
@@ -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'
+
+/**
+ * 取好友备注:删好友(DISABLE)也保留——displayName 是「我对这个人的私人称呼」,属于我的数据,
+ * 不该跟好友关系一起清掉。删了再加回来时备注自然延续,历史消息里也仍以备注辨识
+ */
+function resolveRemark(friend?: Pick | null): string {
+ return friend?.displayName || ''
+}
+
+/** 私聊好友显示名:备注 > 真实昵称 */
+export function getFriendDisplayName(
+ friend: Pick
+): string {
+ return resolveRemark(friend) || friend.nickname
+}
+
+/**
+ * 群成员显示名:好友备注 > 用户群备注(displayUserName) > 真实昵称
+ *
+ * WeChat 优先级:好友备注是"我"对该成员的私人称呼,最高优先;其次是 ta 在群内自定义昵称;最后真实昵称兜底。
+ * 调用方拿到 friend 才传入,没拿到(陌生人)就只用 member 字段降级
+ */
+export function getMemberDisplayName(
+ member: { displayUserName?: string; nickname: string },
+ friend?: Pick | 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.nickname(self 也是 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)
+}