gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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>