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

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

View File

@ -10,8 +10,8 @@
>
<UserAvatar
:size="avatarSize"
:name="member.showNickName"
:url="member.showImage"
:name="member.nickname"
:url="member.avatar"
:clickable="clickable"
:id="member.userId"
/>
@ -19,7 +19,7 @@
class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--el-text-color-regular)]"
:style="{ lineHeight: height + 'px' }"
>
{{ member.showNickName }}
{{ member.showName }}
</div>
</div>
</template>
@ -34,10 +34,9 @@ defineOptions({ name: 'ImGroupMember' })
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts) */
export interface GroupMemberLite {
userId: number // 用户编号;特殊值见 IM_AT_ALL_USER_ID(@ 全体成员)
showNickName: string // 展示昵称:优先群备注,再群昵称,再用户昵称
showImage?: string
// 群成员状态:直接透传 GroupMember.status;消费方(过滤已退群成员等)按
// CommonStatusEnum.DISABLE 自行判断,不在 producer 端预翻译成 quit
nickname: string // 真实昵称:永远是用户的 nickname,专给 UserAvatar 色卡用,保证同一个人色卡首字母在所有界面一致
showName: string // 展示昵称:好友备注 > 用户群备注(displayUserName) > 真实昵称(nickname),给"显示给用户看"的位置用(行内文字、@候选标签等)
avatar?: string
status?: number
}

View File

@ -59,6 +59,7 @@ import { useUserStore } from '@/store/modules/user'
import { useImUiStore } from '../store/uiStore'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { ImConversationType } from '../../utils/constants'
import type { UserInfo } from '../types'
import UserAvatar from './UserAvatar.vue'
@ -136,14 +137,17 @@ function handleSendMessage() {
if (!user.value) {
return
}
// 同步 friendStore 里的 muted,避免新建的私聊会话丢失"消息免打扰"状态(与 FriendPage.onChat 行为一致)
const friendEntry = friendStore.getFriend(user.value.id)
// 取 friendStore 里的最新备注 / 免打扰,避免新建会话用过期数据
const friend = friendStore.getFriend(user.value.id)
const conversationName = friend
? getFriendDisplayName(friend)
: user.value.nickname || ''
conversationStore.openConversation(
user.value.id,
ImConversationType.PRIVATE,
user.value.nickname || '',
conversationName,
user.value.avatar || '',
{ muted: !!friendEntry?.muted }
{ muted: !!friend?.muted }
)
// 跳转到聊天 tab(如果已经在了就算了)
if (router.currentRoute.value.name !== 'ImHomeConversation') {

View File

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

View File

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

View File

@ -59,7 +59,8 @@ onMounted(async () => {
// ========== 2. 远端通信 + 数据同步 ==========
// 2.1 建立 WebSocket 长连接(跨 Tab 持续保持,不因路由切换断开)
wsStore.connect()
// 2.2 预拉好友 / 群列表:必须 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))

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@
<Icon icon="ep:user-filled" :size="18" />
</div>
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
{{ allItem.showNickName }}
{{ allItem.showName }}
</span>
</div>
@ -117,8 +117,7 @@ const allItem = computed<GroupMemberLite | null>(() => {
// @所有人 是个伪成员,nickname 给 IM_AT_ALL_NICKNAME 让头像 :name 行为对齐普通成员
return {
userId: IM_AT_ALL_USER_ID,
// TODO @AI:改成 displayName 会更好
showNickName: IM_AT_ALL_NICKNAME,
showName: IM_AT_ALL_NICKNAME,
nickname: IM_AT_ALL_NICKNAME
}
})
@ -129,8 +128,8 @@ const memberItems = computed<GroupMemberLite[]>(() =>
(member) =>
member.userId !== selfUserId.value &&
member.status !== CommonStatusEnum.DISABLE &&
!!member.showNickName &&
member.showNickName.startsWith(props.searchText)
!!member.showName &&
member.showName.startsWith(props.searchText)
)
)

View File

@ -124,7 +124,7 @@ import { updateFile } from '@/api/infra/file'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { getMemberShowName } from '@/views/im/utils/user'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
@ -430,7 +430,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showNickName: getMemberShowName(member, friend),
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
@ -548,7 +548,7 @@ function onMentionSelect(member: GroupMemberLite) {
span.className = 'mention-token'
span.dataset.id = String(member.userId)
span.contentEditable = 'false'
span.textContent = `@${member.showNickName}`
span.textContent = `@${member.showName}`
mentionRange.insertNode(span)
// token 在 editor 首位时,contenteditable=false 边缘会让光标无法挪到 token 前
// 补一个零宽空格 ​ 当锚点;DOM walk 时会被滤掉,不进入发送内容

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import {
} from '@/api/im/friend'
import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants'
import { getFriendDisplayName } from '../../utils/user'
import type { Friend } from '../types'
/**
@ -61,7 +62,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const conversationStore = useConversationStore()
for (const f of this.friends) {
conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, {
name: f.nickname,
name: getFriendDisplayName(f),
avatar: f.avatar,
muted: f.muted
})
@ -120,8 +121,9 @@ export const useFriendStore = defineStore('imFriendStore', {
}
// 同步对应私聊会话的展示
const conversationStore = useConversationStore()
const merged = this.getFriend(friend.friendUserId)
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
name: friend.nickname,
name: merged ? getFriendDisplayName(merged) : friend.nickname,
avatar: friend.avatar,
muted: friend.muted
})
@ -149,6 +151,26 @@ export const useFriendStore = defineStore('imFriendStore', {
}
},
/**
* 修改好友展示备注(仅自己可见)
*
* 走后端 /im/friend/update 接口;保存成功后再同步本地 friend + 会话列表 name,
* 失败就直接抛给上层,让 UI 决定是否回滚 / 提示用户
*/
async setDisplayName(friendUserId: number, displayName: string) {
const value = displayName.trim()
// 后端的 displayName 语义:null/undefined = 不改,"" = 清空,所以这里直接传 value(可能是空串)
await apiUpdateFriend({ friendUserId, displayName: value })
const friend = this.getFriend(friendUserId)
if (friend) {
friend.displayName = value
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
name: getFriendDisplayName(friend)
})
}
},
/** 切换用户时清空 */
clear() {
this.friends = []
@ -164,6 +186,7 @@ function convertFriend(vo: ImFriendRespVO): Friend {
nickname: vo.nickname || String(vo.friendUserId),
avatar: vo.avatar,
muted: !!vo.muted,
displayName: vo.displayName || '',
status: vo.status,
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined

View File

@ -7,6 +7,7 @@ import { ImWebSocketMessageType, ImMessageType, ImConversationType } from '../..
import { playAudioTip } from '../../utils/message'
import { useConversationStore } from './conversationStore'
import { useFriendStore } from './friendStore'
import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
@ -17,11 +18,13 @@ import type {
Message
} from '../types'
/** WebSocket 私聊 DTO -> 前端 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,

View File

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

View File

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

View File

@ -107,14 +107,6 @@ export const resolveTipText = (content: string): string => {
// ==================== 撤回 ====================
/**
* 生成本地「撤回提示消息」的展示内容
* 对齐后端 ImMessageType.TIP_TEXT(21) 语义:用灰色系统文案展示。
*/
export const buildRecallTip = (senderName: string, selfSend: boolean): string => {
return selfSend ? '你撤回了一条消息' : `${senderName || '对方'} 撤回了一条消息`
}
/**
* 从后端下发的撤回 TIP_TEXT content 中解析出被撤回的原消息 id
* content 形如 `{"messageId": 123}`,若不含 messageId 则返回 0(表示这条不是撤回 tip)

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

@ -0,0 +1,141 @@
// ====================================================================
// IM 用户展示名 utility
// ====================================================================
// 职责:统一回答"某个用户在 UI 上应该叫什么名字"。
// 拆两层:
// 1. 纯派生(getFriendDisplayName / getMemberDisplayName)—— 输入 friend / member 对象,
// 不查 store,给 store 内部 / 各种组装点复用,避免逻辑散落
// 2. 上下文感知(getSenderDisplayName / getSenderRealNickname)—— 渲染时按
// conversation 上下文实时查 friendStore / groupStore / userStore,让备注 / 群昵称 /
// 真实昵称变更后所有历史消息立即刷新(不再写"快照"到 message 字段里)
//
// 命名约定:函数名一律使用 displayName,与 friend.displayName / member.displayUserName 字段对齐
// ====================================================================
import { useUserStore } from '@/store/modules/user'
import { ImConversationType } from './constants'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'
import type { Friend } from '../home/types'
/**
* 取好友备注:删好友(DISABLE)也保留——displayName 是「我对这个人的私人称呼」,属于我的数据,
* 不该跟好友关系一起清掉。删了再加回来时备注自然延续,历史消息里也仍以备注辨识
*/
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.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)
}