From de39bc7fc165242e302ddc47c38af6f9d154ab79 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 28 Apr 2026 23:32:40 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=8C=E7=A7=BB=E9=99=A4=20message=20?= =?UTF-8?q?=E9=87=8C=E7=9A=84=20name=20=E5=AD=98=E5=82=A8=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=9B=B4=E6=96=B0=E5=9B=B0=E9=9A=BE=E3=80=82?= =?UTF-8?q?=EF=BC=88=E4=B8=BA=20friend=E3=80=81group=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=81=9A=E5=87=86=E5=A4=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/im/home/components/GroupMember.vue | 13 +- src/views/im/home/components/UserInfoCard.vue | 12 +- .../im/home/composables/useMessagePuller.ts | 26 +--- .../im/home/composables/useMessageSender.ts | 29 ++-- src/views/im/home/index.vue | 3 +- .../conversation/ConversationGroupSide.vue | 2 +- .../conversation/ConversationItem.vue | 49 ++++-- .../conversation/ConversationPrivateSide.vue | 4 +- .../components/input/MentionPicker.vue | 9 +- .../components/input/MessageInput.vue | 6 +- .../components/message/MessageHistory.vue | 83 +++++++---- .../components/message/MessageItem.vue | 68 ++++++--- .../components/message/MessagePanel.vue | 21 ++- src/views/im/home/store/conversationStore.ts | 112 +++++++------- src/views/im/home/store/friendStore.ts | 27 +++- src/views/im/home/store/websocketStore.ts | 53 +++---- src/views/im/home/types/index.ts | 15 +- src/views/im/utils/conversation.ts | 71 +++++++++ src/views/im/utils/message.ts | 8 - src/views/im/utils/user.ts | 141 ++++++++++++++++++ 20 files changed, 540 insertions(+), 212 deletions(-) create mode 100644 src/views/im/utils/conversation.ts create mode 100644 src/views/im/utils/user.ts diff --git a/src/views/im/home/components/GroupMember.vue b/src/views/im/home/components/GroupMember.vue index c1afc5daa..b893588b9 100644 --- a/src/views/im/home/components/GroupMember.vue +++ b/src/views/im/home/components/GroupMember.vue @@ -10,8 +10,8 @@ > @@ -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 }} @@ -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 } diff --git a/src/views/im/home/components/UserInfoCard.vue b/src/views/im/home/components/UserInfoCard.vue index 9750dbfd5..92320bcc1 100644 --- a/src/views/im/home/components/UserInfoCard.vue +++ b/src/views/im/home/components/UserInfoCard.vue @@ -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') { diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index c1f7d1a4b..715f658cd 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -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 diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 8a9f922e5..51eb538fc 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -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 + } + } /** * 发送任意类型的消息(底层实现) diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index df20ac594..c7cb187ba 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -59,7 +59,8 @@ onMounted(async () => { // ========== 2. 远端通信 + 数据同步 ========== // 2.1 建立 WebSocket 长连接(跨 Tab 持续保持,不因路由切换断开) wsStore.connect() - // 2.2 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 friendStore / groupStore 补 senderNickName 和会话 name/avatar + // 2.2 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 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)) diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue index 0a47396b5..c54167a49 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationGroupSide.vue @@ -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) ) ) diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue index 5b2883e7f..73a9d6ecd 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationItem.vue @@ -44,17 +44,17 @@
{{ atText }} - + - {{ conversation.senderNickName }}:  + {{ lastSenderDisplayName }}:  - {{ conversation.lastContent }} + {{ lastContentDisplay }} 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 }) /** 会话列表 "@ 我" / "@ 全体成员" 红字提示 */ diff --git a/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue b/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue index 495b90731..cafa2ee5e 100644 --- a/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue +++ b/src/views/im/home/pages/conversation/components/conversation/ConversationPrivateSide.vue @@ -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('') diff --git a/src/views/im/home/pages/conversation/components/input/MentionPicker.vue b/src/views/im/home/pages/conversation/components/input/MentionPicker.vue index 29b8350fe..fb09cd64d 100644 --- a/src/views/im/home/pages/conversation/components/input/MentionPicker.vue +++ b/src/views/im/home/pages/conversation/components/input/MentionPicker.vue @@ -28,7 +28,7 @@
- {{ allItem.showNickName }} + {{ allItem.showName }} @@ -117,8 +117,7 @@ const allItem = computed(() => { // @所有人 是个伪成员,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(() => (member) => member.userId !== selfUserId.value && member.status !== CommonStatusEnum.DISABLE && - !!member.showNickName && - member.showNickName.startsWith(props.searchText) + !!member.showName && + member.showName.startsWith(props.searchText) ) ) diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index dba452b33..6fa5fc48c 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -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(() => { 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 时会被滤掉,不进入发送内容 diff --git a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue index f41f5e37c..08ad657fa 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageHistory.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageHistory.vue @@ -159,11 +159,7 @@ > @@ -172,11 +168,7 @@ class="flex justify-between items-start text-12px text-[var(--el-text-color-secondary)]" > - {{ - message.selfSend - ? userStore.getUser?.nickname || '' - : message.senderNickName || '' - }} + {{ senderDisplayNameOf(message) }} {{ formatTime(message.sendTime) }} @@ -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) }} @@ -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(() => 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(() => { 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) +}