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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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>