2026-08-14 bdaa472dc215775e5d306b7046af0bc1111c718d
src/views/im/home/pages/contact/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<script lang="ts" setup>
import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { confirm } from '#/packages/effects/common-ui/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/db'
import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { UserInfo } from '../../components/user'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import FriendList from './friend-list.vue'
import FriendRequestDetail from './friend-request-detail.vue'
import FriendRequestList from './friend-request-list.vue'
import GroupDetail from './group-detail.vue'
import GroupList from './group-list.vue'
defineOptions({ name: 'ImContactPage' })
const router = useRouter()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
/** ç”¨ type åˆ¤åˆ«é€‰ä¸­æ˜¯å¥½å‹ / ç¾¤èŠ / å¥½å‹ç”³è¯· */
type Selection =
  | { friend: FriendLite; type: 'friend'; }
  | { group: GroupLite; type: 'group'; }
  | { request: FriendRequest; type: 'request'; }
const selection = ref<null | Selection>(null)
const keyword = ref('')
/** é€‰ä¸­ç”³è¯·è¯¦æƒ…:详情用 store é‡Œçš„æœ€æ–°å‰¯æœ¬ï¼ˆåŒæ„ / æ‹’绝后状态会变) */
const currentRequest = computed<FriendRequest>(() => {
  const req = selection.value?.type === 'request' ? selection.value.request : null
  if (!req) {
    return {} as FriendRequest
  }
  return friendStore.getFriendRequest(req.id) || req
})
/** æˆ‘相关的申请列表(用 friendStore é‡Œçš„实时副本,便于通知到达后自动刷新) */
const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests)
/** å¥½å‹åˆ—表的展示快照:附带后端算好的拼音,给 FriendList åšå­—母分桶 / æ‹¼éŸ³æœç´¢ */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
const groups = computed<GroupLite[]>(() =>
  // é€šè®¯å½•只展示当前仍在群的;已退群历史群只留在 store é‡Œä¾›æ¶ˆæ¯å±•示群名 / å¤´åƒï¼Œä¸è¿›é€šè®¯å½•
  groupStore.groups
    .filter((group: Group) => !isGroupQuit(group))
    .map((group: Group) => ({
      id: group.id,
      name: group.name,
      showGroupName: getGroupDisplayName(group), // ä¼˜å…ˆç”¨ç¾¤å¤‡æ³¨ groupRemark,没设置时回落到原群名;避免点"进入群聊"时把已同步的备注会话名刷回原名
      showImage: group.avatar,
      showImageThumb: group.avatar,
      memberCount: group.memberCount
    }))
)
/**
 * store åˆ—表变化时同步 selection æŒçš„对象副本:对端推送 / è·¨ç«¯åŠ¨ä½œæ”¹ store åŽï¼Œå³ä¾§è¯¦æƒ…能跟上:
 * - å‘½ä¸­åˆ™æ›¿æ¢ä¸ºæœ€æ–°å¼•用,资料 / å¤‡æ³¨ / ç”³è¯·çŠ¶æ€å˜æ›´ç«‹åˆ»åæ˜ 
 * - æ‰¾ä¸åˆ°ï¼ˆåˆ é™¤ / æ‹’绝 / å·²é€šè¿‡ç­‰è®©è®°å½•消失)则置 null æ”¶èµ·è¯¦æƒ…
 */
watch(
  friends,
  (list) => {
    const selected = selection.value
    if (selected?.type !== 'friend') {
      return
    }
    const fresh = list.find((friend) => friend.id === selected.friend.id)
    if (!fresh) {
      selection.value = null
    } else if (fresh !== selected.friend) {
      selection.value = { type: 'friend', friend: fresh }
    }
  },
  { deep: true }
)
watch(
  groups,
  (list) => {
    const selected = selection.value
    if (selected?.type !== 'group') {
      return
    }
    const fresh = list.find((group) => group.id === selected.group.id)
    if (!fresh) {
      selection.value = null
    } else if (fresh !== selected.group) {
      selection.value = { type: 'group', group: fresh }
    }
  },
  { deep: true }
)
watch(
  friendRequests,
  (list) => {
    const selected = selection.value
    if (selected?.type !== 'request') {
      return
    }
    const fresh = list.find((request) => request.id === selected.request.id)
    if (!fresh) {
      selection.value = null
    } else if (fresh !== selected.request) {
      selection.value = { type: 'request', request: fresh }
    }
  },
  { deep: true }
)
const friendUser = computed<null | User>(() => {
  if (selection.value?.type !== 'friend') {
    return null
  }
  const friend = selection.value.friend
  return {
    id: friend.id,
    nickname: friend.nickname,
    avatar: friend.avatar
  }
})
onMounted(async () => {
  await Promise.all([
    friendStore.fetchFriendList(),
    friendStore.fetchFriendRequestList(),
    groupStore.fetchGroupList()
  ])
})
/** é€‰ä¸­å¥½å‹ â†’ åˆ‡åˆ°å¥½å‹è¯¦æƒ… */
function handleSelectFriend(friend: FriendLite) {
  selection.value = { type: 'friend', friend }
}
/** é€‰ä¸­ç¾¤èŠ â†’ åˆ‡åˆ°ç¾¤è¯¦æƒ… */
function handleSelectGroup(group: GroupLite) {
  selection.value = { type: 'group', group }
}
/** é€‰ä¸­å¥½å‹ç”³è¯· â†’ åˆ‡åˆ°ã€Œæ–°çš„æœ‹å‹ã€è¯¦æƒ… */
function handleSelectRequest(request: FriendRequest) {
  selection.value = { type: 'request', request }
}
/** ç”³è¯·è¯¦æƒ…里点「发消息」:直接进与对端的私聊会话 */
function handleChatPeer(peerUserId: number) {
  const friend = friendStore.getFriend(peerUserId)
  const conversationName = friend ? getFriendDisplayName(friend) : String(peerUserId)
  conversationStore.openConversation(
    peerUserId,
    ImConversationType.PRIVATE,
    conversationName,
    friend?.avatar || '',
    { silent: !!friend?.silent }
  )
  router.push({ name: 'ImHomeConversation' })
}
/** è¿›å…¥ä¸Žè¯¥å¥½å‹çš„私聊会话 */
function handleChatFriend(friend: FriendLite) {
  // ä»Ž friendStore åŒæ­¥å¤‡æ³¨ + å…æ‰“扰,避免新建会话用过期数据
  const entry = friendStore.getFriend(friend.id)
  const conversationName = entry ? getFriendDisplayName(entry) : friend.nickname
  conversationStore.openConversation(
    friend.id,
    ImConversationType.PRIVATE,
    conversationName,
    friend.avatar || '',
    { silent: !!entry?.silent }
  )
  router.push({ name: 'ImHomeConversation' })
}
/** è¿›å…¥è¯¥ç¾¤çš„群聊会话 */
function handleChatGroup(group: GroupLite) {
  const entry = groupStore.getGroup(group.id)
  conversationStore.openConversation(
    group.id,
    ImConversationType.GROUP,
    group.showGroupName || group.name || '',
    group.showImage || group.showImageThumb || '',
    { silent: !!entry?.silent }
  )
  router.push({ name: 'ImHomeConversation' })
}
/** åˆ é™¤å¥½å‹ï¼šäºŒæ¬¡ç¡®è®¤ â†’ store è½åº“ â†’ æ¸…空当前选中 */
async function handleDeleteFriend(friend: FriendLite) {
  try {
    await confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人')
    // friendStore.deleteFriend å†…部已经级联清理对应私聊会话
    await friendStore.deleteFriend(friend.id)
    if (selection.value?.type === 'friend' && selection.value.friend.id === friend.id) {
      selection.value = null
    }
    message.success('已删除好友')
  } catch {}
}
/** å¤‡æ³¨å·²ä¿å­˜ï¼šUserInfo å†…部已经走完 friendStore è½åº“ + æç¤ºï¼Œæœ¬ä¾§åªè´Ÿè´£åŒæ­¥ selection æŒçš„æ—§ FriendLite å‰¯æœ¬ */
function onRemarkSaved(displayName: string) {
  if (selection.value?.type === 'friend') {
    selection.value.friend.displayName = displayName
  }
}
</script>
<template>
  <!--
    é€šè®¯å½• Tab æ•´é¡µï¼ˆå‚考微信 PC é€šè®¯å½•)
    - å·¦ï¼šæœç´¢ + GroupList / FriendList ä¸¤ä¸ªæŠ˜å åˆ†ç»„(好友按字母分桶)
    - å³ï¼šé€‰ä¸­é¡¹çš„详情,好友走共享 <UserInfo>(relation=friend),群聊走 GroupDetail
    - æœ¬é¡µä»…做:选中分发 + æ•°æ®æºè½¬æ¢ + è·¨ç»„件事件落 store
  -->
  <div class="flex flex-1 h-full min-w-0 bg-[var(--ant-color-bg-container)]">
    <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth">
      <!-- é¡¶éƒ¨ï¼šä»…搜索框;h-14 ä¸Žæ¶ˆæ¯ Tab é¡¶éƒ¨å¯¹é½ï¼Œé¿å…åˆ‡æ¢æ—¶æœç´¢æ¡†ä¸Šä¸‹æŠ–动 -->
      <div
        class="flex flex-shrink-0 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>
      </div>
      <!-- åˆ—表主体:拆 FriendRequestList / GroupList / FriendList ä¸‰ä¸ªå­ç»„件,各自管理折叠 + è¿‡æ»¤ï¼›æœ¬é¡µåªé€ä¼ é€‰ä¸­æ€ -->
      <!-- overflow-y-auto:联系人多时本列表可滚动(父 ResizableAside ä¸æä¾›æ»šåŠ¨ï¼‰ï¼Œå¯¹é½ Vue3 çš„ el-scrollbar -->
      <div class="flex-1 overflow-y-auto">
        <FriendRequestList
          :requests="friendRequests"
          :active-id="selection?.type === 'request' ? selection.request.id : undefined"
          @select="handleSelectRequest"
        />
        <GroupList
          :groups="groups"
          :keyword="keyword"
          :active-id="selection?.type === 'group' ? selection.group.id : undefined"
          @select="handleSelectGroup"
        />
        <FriendList
          :friends="friends"
          :keyword="keyword"
          :active-id="selection?.type === 'friend' ? selection.friend.id : undefined"
          @select="handleSelectFriend"
          @chat="handleChatFriend"
          @delete="handleDeleteFriend"
        />
      </div>
    </ResizableAside>
    <!-- å³ä¾§è¯¦æƒ…区 -->
    <div class="flex-1 min-w-0">
      <!-- ç©ºæ€ï¼šå ä½å›¾æ ‡ + æç¤ºæ–‡æ¡ˆ -->
      <div
        v-if="!selection"
        class="flex flex-col items-center justify-center h-full gap-3 text-[var(--ant-color-text-secondary)]"
      >
        <Icon
          icon="ant-design:contacts-outlined"
          :size="64"
          class="text-[var(--ant-color-text-placeholder)]"
        />
        <span class="text-sm">在左侧选择好友或群聊查看详情</span>
      </div>
      <!-- å¥½å‹è¯¦æƒ… -->
      <div v-else-if="selection.type === 'friend'" class="flex justify-center pt-12 px-6">
        <div class="w-full max-w-[320px]">
          <UserInfo
            :user="friendUser"
            :display-name="selection.friend.displayName || ''"
            relation="friend"
            @chat="handleChatFriend(selection.friend)"
            @deleted="selection = null"
            @saved="onRemarkSaved"
          />
        </div>
      </div>
      <!-- ç¾¤è¯¦æƒ… -->
      <GroupDetail
        v-else-if="selection.type === 'group'"
        :group="selection.group"
        @chat="handleChatGroup"
      />
      <!-- æ–°çš„æœ‹å‹ - ç”³è¯·è¯¦æƒ… -->
      <FriendRequestDetail
        v-else-if="selection.type === 'request'"
        :request="currentRequest"
        @chat="handleChatPeer"
      />
    </div>
  </div>
</template>