diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 05018e58a..7ed0db17a 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -115,9 +115,10 @@ import Icon from '@/components/Icon/src/Icon.vue' import { useConversationStore } from '../../../../store/conversationStore' import { useFriendStore } from '../../../../store/friendStore' -import { getMemberDisplayName } from '../../../../../utils/user' +import { getMemberDisplayName } from '@/views/im/utils/user' import { useGroupStore } from '../../../../store/groupStore' -import { ImConversationType } from '../../../../../utils/constants' +import { ImConversationType } from '@/views/im/utils/constants' +import { getConversationKey } from '@/views/im/utils/conversation' import { CommonStatusEnum } from '@/utils/constants' import MessageItem from './MessageItem.vue' import MessageInput from '../input/MessageInput.vue' @@ -145,7 +146,7 @@ const isGroup = computed( */ const messageInputKey = computed(() => { const conv = conversationStore.activeConversation - return conv ? `${conv.type}-${conv.targetId}` : 'none' + return conv ? getConversationKey(conv) : 'none' }) /** "是否停留在底部"的阈值:距离底部 < 80px 视为底部 */ diff --git a/src/views/im/home/pages/conversation/index.vue b/src/views/im/home/pages/conversation/index.vue index 6d39c6161..6172916ed 100644 --- a/src/views/im/home/pages/conversation/index.vue +++ b/src/views/im/home/pages/conversation/index.vue @@ -37,20 +37,20 @@
@@ -59,7 +59,7 @@ {{ pinnedExpanded ? '折叠置顶聊天' - : `${foldablePinnedConversations.length} 个置顶聊天` + : `${pinnedGroups.foldable.length} 个置顶聊天` }} @@ -107,6 +107,7 @@ import { useFriendStore } from '../../store/friendStore' import { useGroupStore } from '../../store/groupStore' import { StorageKeys } from '../../../utils/storage' import { ImConversationType } from '../../../utils/constants' +import { getConversationKey } from '../../../utils/conversation' import { CommonStatusEnum } from '@/utils/constants' import type { Conversation, Friend, FriendLite } from '../../types' import ResizableAside from '../../components/ResizableAside.vue' @@ -160,24 +161,29 @@ const pinnedConversations = computed(() => filteredConversations.value.filter((c /** 非置顶会话:折叠态下始终铺开在折叠头之下 */ const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top)) -/** 置顶 + 无未读 / 免打扰且非激活:折叠时藏在折叠头之下,决定折叠头计数 + 是否要显示折叠头 */ -const foldablePinnedConversations = computed(() => - pinnedConversations.value.filter((c) => !isActiveConversation(c) && !hasUnreadBadge(c)) -) - /** - * 折叠时只渲未读 + 当前激活(穿透折叠);展开时渲全部置顶 + * 置顶分两堆:visible(折叠头之上 = 未读 + 当前激活)/ foldable(折叠头之下);一次 partition 完成 * - * 展开后不沿用「visible 在前 + foldable 在后」的分组:会让点击折叠区某条跨组上跳、 - * 上一条激活的会从 visible 掉到 foldable,视觉上像"互换位置"——按 lastSendTime 自然顺序铺最稳 + * 当前激活会话也"钉"在 visible:避免点开未读置顶 → 立刻被读 → 列表一闪重排回折叠的体验 */ -const renderedPinnedConversations = computed(() => { - if (pinnedExpanded.value) { - return pinnedConversations.value +const pinnedGroups = computed(() => { + const visible: Conversation[] = [] + const foldable: Conversation[] = [] + for (const conversation of pinnedConversations.value) { + if (isActiveConversation(conversation) || hasUnreadBadge(conversation)) { + visible.push(conversation) + } else { + foldable.push(conversation) + } } - return pinnedConversations.value.filter((c) => isActiveConversation(c) || hasUnreadBadge(c)) + return { visible, foldable } }) +/** 折叠时只渲 visible(未读 / 激活穿透);展开时渲全部 —— 展开后不分组,避免点击折叠区跨组上跳 */ +const renderedPinnedConversations = computed(() => + pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible +) + /** 与会话项右上角红点的可见条件保持一致:免打扰不亮,无未读不亮 */ function hasUnreadBadge(conversation: Conversation): boolean { return !conversation.muted && (conversation.unreadCount || 0) > 0 @@ -186,7 +192,7 @@ function hasUnreadBadge(conversation: Conversation): boolean { /** 是否为当前激活会话 */ function isActiveConversation(conversation: Conversation): boolean { const active = conversationStore.activeConversation - return !!active && active.type === conversation.type && active.targetId === conversation.targetId + return !!active && getConversationKey(active) === getConversationKey(conversation) } /** diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts index cf993cf32..680547bff 100644 --- a/src/views/im/utils/conversation.ts +++ b/src/views/im/utils/conversation.ts @@ -11,6 +11,11 @@ import { parseMessage, resolveTipText, type TextMessage } from './message' import { getSenderDisplayName } from './user' import type { Message } from '../home/types' +/** 会话主键:`type-targetId` 拼成稳定字符串,给 v-for :key、active 比对、map key 等场景共用 */ +export function getConversationKey(conversation: { type: number; targetId: number }): string { + return `${conversation.type}-${conversation.targetId}` +} + /** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */ export function buildRecallTip( senderId: number, diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts index a0fbf77e8..07b2128fa 100644 --- a/src/views/im/utils/storage.ts +++ b/src/views/im/utils/storage.ts @@ -54,7 +54,9 @@ export const StorageKeys = { `groupMembers:${userId}:${groupId}`, /** 侧边栏宽度(localStorage);三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */ - asideWidth: 'im:aside' + asideWidth: 'im:aside', + /** 会话列表置顶折叠展开态(localStorage);轻量 UI 偏好。 */ + conversationPinnedExpanded: 'im:conversation:pinnedExpanded' } as const /** 取当前登录用户编号;返回 0 表示未登录,调用方一律早 return 不写无主 key */