gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
<script lang="ts" setup>
import type { Conversation } from '../../types'
 
import { computed, nextTick, ref, watch } from 'vue'
 
import { IconifyIcon as Icon } from '#/packages/icons/src'
 
import { Button, Dropdown, Input, Menu } from 'ant-design-vue'
 
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { StorageKeys } from '../../../utils/db'
import { getGroupDisplayName } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { FriendAddDialog } from '../../components/friend'
import { GroupCreateDialog } from '../../components/group'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import { ConversationItem, MessagePanel } from './components'
 
defineOptions({ name: 'ImMessagePage' })
 
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const uiStore = useImUiStore()
 
// ==================== 会话列表 ====================
 
const keyword = ref('')
 
const sortedConversations = computed(() => conversationStore.getSortedConversationList)
 
/** 顶部搜索框过滤会话:只按 name 模糊匹配,避免命中 lastContent 等次要字段干扰 */
const filteredConversations = computed(() =>
  filterConversationsByKeyword(sortedConversations.value, keyword.value)
)
 
// ==================== 置顶相关 ====================
 
const PINNED_FOLD_THRESHOLD = 3 // 置顶超过该数量时显示折叠入口;以下数量直接铺开(避免单条置顶就出折叠头视觉太重)
 
// 置顶折叠展开态:localStorage 持久化,刷新后保留用户上次的选择,对齐微信
const pinnedExpanded = ref(
  localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
)
 
/** toggle + 写盘 */
function togglePinnedExpanded() {
  pinnedExpanded.value = !pinnedExpanded.value
  localStorage.setItem(
    StorageKeys.localStorage.conversationPinnedExpanded,
    String(pinnedExpanded.value)
  )
}
 
/** 置顶会话:单独切片,给折叠头计数 + 折叠区渲染用 */
const pinnedConversations = computed(() => filteredConversations.value.filter((c) => c.top))
 
/** 非置顶会话:折叠态下始终铺开在折叠头之下 */
const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top))
 
/**
 * 置顶分两堆:visible(折叠头之上 = 未读 + 当前激活)/ foldable(折叠头之下);一次 partition 完成
 *
 * 当前激活会话也"钉"在 visible:避免点开未读置顶 → 立刻被读 → 列表一闪重排回折叠的体验
 */
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 { visible, foldable }
})
 
/** 折叠时只渲 visible(未读 / 激活穿透);展开时渲全部 —— 展开后不分组,避免点击折叠区跨组上跳 */
const renderedPinnedConversations = computed(() =>
  pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible
)
 
/** 置顶折叠时是否上浮到折叠头之上:仅以数字徽标为准;免打扰即便有未读也只展示小红点,不参与上浮 */
function hasUnreadBadge(conversation: Conversation): boolean {
  return !conversation.silent && (conversation.unreadCount || 0) > 0
}
 
/** 是否为当前激活会话 */
function isActiveConversation(conversation: Conversation): boolean {
  const active = conversationStore.activeConversation
  return !!active && getConversationKey(active) === getConversationKey(conversation)
}
 
/**
 * 是否进入"分组模式"(独立浅底 + 可能有折叠头):搜索时不分组(用户在找人,别再让折叠挡住);
 * 置顶数 < 阈值也不分组,避免单条置顶就出折叠头视觉太重
 */
const showPinnedSection = computed(
  () => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD
)
 
// ==================== 添加朋友 ====================
 
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // 添加朋友弹窗 ref:右上角 +-下拉「添加朋友」入口调 open() 触发
 
// ==================== 建群相关 ====================
 
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // 发起群聊弹窗 ref:handleOpenCreateGroup 调 open() 打开
 
/** 打开发起群聊弹窗:无锁定项的全局入口 */
function handleOpenCreateGroup() {
  createGroupDialogRef.value?.open()
}
 
/** 处理建群成功 */
function handleGroupCreated(groupId: number) {
  // GroupCreateDialog 已经 upsertGroup 把新群写进 store,这里只 get + 打开会话
  const group = groupStore.getGroup(groupId)
  if (!group) {
    return
  }
  conversationStore.openConversation(
    groupId,
    ImConversationType.GROUP,
    getGroupDisplayName(group),
    group.avatar || '',
    { silent: !!group.silent }
  )
}
 
// ==================== 滚动到下一个未读 ====================
// 工具栏「消息」Tab 二次点击 → uiStore.nextUnreadJumpNonce 递增 → 本页 watch 滚动到下一条未读
// 含免打扰会话(小红点也算未读);通过维护 lastJumpedConversationKey 让连续点击顺序穿过整个未读列表
 
/** 上次命中的未读会话 key;为空时从未读列表头开始 */
let lastJumpedConversationKey: null | string = null
 
/** 滚动到下一个未读会话(含免打扰);目标被搜索框过滤或藏在置顶折叠区时先解除拦截再滚 */
async function jumpToNextUnread() {
  // 含免打扰的全量未读会话;空则直接返回
  const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0)
  if (unreadList.length === 0) {
    return
  }
  // 从上次命中那条往后推一位;首次或上次目标已读完不在列表里时,refIndex=-1,从头开始
  const refIndex = lastJumpedConversationKey
    ? unreadList.findIndex((c) => getConversationKey(c) === lastJumpedConversationKey)
    : -1
  const target = unreadList[(refIndex + 1) % unreadList.length]
  if (!target) {
    return
  }
  const key = getConversationKey(target)
  lastJumpedConversationKey = key
 
  // 目标被搜索关键字过滤掉:清空 keyword 让它重新进入可见列表
  if (keyword.value && !filteredConversations.value.some((c) => getConversationKey(c) === key)) {
    keyword.value = ''
  }
  // 目标藏在置顶折叠区:临时展开;不写 localStorage,刷新后还原折叠态
  const inFoldable = pinnedGroups.value.foldable.some((c) => getConversationKey(c) === key)
  if (inFoldable && !pinnedExpanded.value) {
    pinnedExpanded.value = true
  }
 
  // 等 keyword / pinnedExpanded 变化反应到 DOM 后再去取目标元素
  await nextTick()
  const el = document.querySelector(`[data-conversation-key="${key}"]`) as HTMLElement | null
  if (!el) {
    return
  }
  // block: 'start' 把目标会话顶到列表可视区第一行;对齐微信"切到下一个未读=列表的第一条"
  el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
 
watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
</script>
 
<template>
  <!-- 消息 Tab:左侧会话列表 + 右侧聊天面板 -->
  <div class="flex flex-1 min-w-0 h-full">
    <!-- 左侧会话列表(可拖拽宽度) -->
    <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth">
      <!-- 顶部:搜索框 + "+" 号下拉(对齐微信 PC:发起群聊 / 添加朋友);h-14 与右侧 MessagePanel 头部对齐 -->
      <div
        class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]"
      >
        <Input v-model:value="keyword" placeholder="搜索" allow-clear class="flex-1">
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
        <Dropdown :trigger="['click']" placement="bottom">
          <Button size="small" shape="circle">
            <Icon icon="ant-design:plus-outlined" />
          </Button>
          <template #overlay>
            <Menu>
              <Menu.Item @click="handleOpenCreateGroup">
                <span class="inline-flex items-center gap-2">
                  <Icon icon="ant-design:message-outlined" :size="16" />
                  <span>发起群聊</span>
                </span>
              </Menu.Item>
              <Menu.Item @click="friendAddDialogRef?.open()">
                <span class="inline-flex items-center gap-2">
                  <Icon icon="ant-design:user-add-outlined" :size="16" />
                  <span>添加朋友</span>
                </span>
              </Menu.Item>
            </Menu>
          </template>
        </Dropdown>
      </div>
 
      <!-- 会话列表主体 -->
      <div class="flex-1 overflow-y-auto">
        <!-- 置顶会话:数量 < 阈值或搜索中直接铺开;否则进入分组模式(独立浅底 + 可选折叠头) -->
        <template v-if="!showPinnedSection">
          <ConversationItem
            v-for="conversation in pinnedConversations"
            :key="getConversationKey(conversation)"
            :conversation="conversation"
          />
        </template>
        <div v-else class="bg-[var(--ant-color-fill-secondary)]">
          <ConversationItem
            v-for="conversation in renderedPinnedConversations"
            :key="getConversationKey(conversation)"
            :conversation="conversation"
          />
 
          <!-- 折叠头:放在置顶区底部对齐 WeChat mac;展开 / 折叠态共用,仅在还有"可折叠"内容、或当前已展开时出现 -->
          <div
            v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded"
            class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--ant-color-text)] border-y border-y-solid border-[var(--im-border-color-lighter)] hover:bg-[var(--ant-color-fill)]"
            @click="togglePinnedExpanded"
          >
            <span class="flex items-center gap-1.5">
              <Icon icon="ant-design:menu-outlined" :size="14" />
              {{
                pinnedExpanded
                  ? '折叠置顶聊天'
                  : `${pinnedGroups.foldable.length} 个置顶聊天`
              }}
            </span>
            <Icon
              :icon="pinnedExpanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
              :size="11"
              class="text-[var(--ant-color-text-placeholder)]"
            />
          </div>
        </div>
 
        <!-- 普通会话 -->
        <ConversationItem
          v-for="conversation in normalConversations"
          :key="getConversationKey(conversation)"
          :conversation="conversation"
        />
 
        <div
          v-if="filteredConversations.length === 0"
          class="flex items-center justify-center py-10 text-sm text-[var(--ant-color-text-secondary)]"
        >
          {{ keyword ? '没有满足条件的会话' : '暂无会话' }}
        </div>
      </div>
    </ResizableAside>
 
    <!-- 右侧聊天面板 -->
    <MessagePanel />
 
    <!-- 添加朋友 / 发起群聊弹窗 -->
    <FriendAddDialog ref="friendAddDialogRef" />
    <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
  </div>
</template>