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 */