✨ feat(im): 优化代码,移除 message 里的 name 存储,避免更新困难。(为 friend、group 独立存储做准备)
parent
f0fc144e8a
commit
de39bc7fc1
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送任意类型的消息(底层实现)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}:
|
||||
{{ lastSenderDisplayName }}:
|
||||
</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
|
||||
})
|
||||
|
||||
/** 会话列表 "@ 我" / "@ 全体成员" 红字提示 */
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 时会被滤掉,不进入发送内容
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 是否 @我(群消息展示小徽标) */
|
||||
|
|
|
|||
|
|
@ -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 窄接口 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 转换)
|
||||
|
|
|
|||
|
|
@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue