gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
<script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group'
import type { GroupLite } from '../../../../types'
 
import { computed, nextTick, provide, ref, watch } from 'vue'
 
import { IconifyIcon as Icon } from '#/packages/icons/src'
 
import { message, Popover, Tooltip } from 'ant-design-vue'
 
import { createCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImRtcCallMediaType, ImRtcCallStatus } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import { resolveCallEndReasonText } from '#/views/im/utils/message'
import { getGroupDisplayName, getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user'
 
import { GroupMuteMemberDialog } from '../../../../components/group'
import {
  RtcCallMemberPickerDialog,
  RtcGroupCallBanner
} from '../../../../components/rtc'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { useRtcStore } from '../../../../store/rtcStore'
import { useImUiStore } from '../../../../store/uiStore'
import { ConversationGroupSide } from '../conversation'
import { ConversationPrivateSide } from '../conversation'
import { MessageInput } from '../input'
import { MessageMultiSelectBar } from '../input'
import { MessageForwardDialog } from './forward'
import { MessageMergeDetailDialog } from './forward'
import {
  IM_FORWARD_DIALOG_KEY,
  IM_MERGE_DETAIL_DIALOG_KEY,
  IM_RTC_REDIAL_KEY
} from './forward/keys'
import GroupPinnedMessage from './group-pinned-message.vue'
import GroupRequestPending from './group-request-pending.vue'
import MessageHistory from './message-history.vue'
import MessageItem from './message-item.vue'
 
defineOptions({ name: 'ImMessagePanel' })
 
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const groupStore = useGroupStore()
const rtcStore = useRtcStore()
const listRef = ref<HTMLElement>()
 
// ==================== 转发 / 合并消息详情:本地 dialog 浮层 ====================
// MessageItem / MessageMultiSelectBar / MessageHistory 通过 inject 触发;不挂全局 store
 
const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>()
const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>()
 
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts))
provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content))
provide(IM_RTC_REDIAL_KEY, (mediaType: number) => {
  if (isPrivate.value) {
    void startPrivateCall(mediaType)
  }
}) // 私聊 RTC_CALL_END 气泡点击重拨;MessageItem 注入后调用
 
// ==================== 多选模式 ====================
// 模块级单例 state(composable);本组件仅做切会话退出 + template 显隐判定
 
const multiSelect = useMessageMultiSelect()
const voicePlayer = useVoicePlayer()
 
/** 切会话退出多选 + 停语音;避免上一会话的勾选 / 播放态泄漏到新会话(type+targetId 一起监听,私聊与群聊 id 同号时也能触发) */
watch(
  () => [
    conversationStore.activeConversation?.type,
    conversationStore.activeConversation?.targetId
  ],
  () => {
    multiSelect.exit()
    voicePlayer.stop()
  }
)
 
const messages = computed(() => {
  const conversation = conversationStore.activeConversation
  return conversation
    ? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId))
    : []
})
const isGroup = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
const isPrivate = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
)
const isChannel = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
 
/** 当前激活会话是否历史退群群:禁群通话、隐藏群申请横幅等操作入口;聊天历史、群名头像照常展示 */
const isQuitGroup = computed(() => {
  const conversation = conversationStore.activeConversation
  return (
    conversation?.type === ImConversationType.GROUP &&
    isGroupQuit(groupStore.getGroup(conversation.targetId))
  )
})
 
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
const showNotFriendBanner = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
    return false
  }
  return !friendStore.isActiveFriend(conversation.targetId)
})
 
/** 点击「对方还不是你的朋友」胶囊:打开 UserInfoCard,引导用户重新添加 */
function handleNotFriendClick(event: MouseEvent) {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
  uiStore.openUserInfoCard(
    {
      id: conversation.targetId,
      nickname: conversation.name,
      avatar: conversation.avatar
    },
    { x: rect.left, y: rect.bottom + 4 }
  )
}
 
/**
 * 群聊 header 显示的人数:优先 groupStore.memberCount(无需等成员列表),无值再回退 members.length
 *
 * 之所以不直接用 groupMembers.value.length:成员列表是按需懒加载的,刚切到群时未加载完,
 * 而 groupInfo.memberCount 跟群信息一起来,能更早显示人数避免"先空再蹦"
 */
const headerMemberCount = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return 0
  }
  const group = groupStore.getGroup(conversation.targetId)
  return group?.memberCount ?? group?.members?.length ?? 0
})
 
/** 顶部副标题:仅当群备注 ≠ 原群名时显示原群名(对齐微信 PC 双行 header) */
const headerSubtitle = computed(() => {
  const remark = groupInfo.value?.groupRemark
  const name = groupInfo.value?.name
  return remark && name && remark !== name ? name : ''
})
 
const BOTTOM_THRESHOLD = 80 // "是否停留在底部"的阈值:距离底部 < 80px 视为底部
const showJumpToBottom = ref(false) // 当前是否已不在底部(显示"回到底部"按钮)
const newMessageCount = ref(0) // 不在底部期间累计的新消息数
 
/**
 * 当前激活的群详情:优先 groupStore(带详细字段),未加载完时用 activeConversation 兜底
 *
 * groupStore 是按需懒加载的,初次进群时 ensureGroupData 触发后才会有完整数据;
 * 兜底字段(name / avatar)保证 header 不会"闪空",notice / ownerId / memberCount
 * 必须等 store 就位才有值(这些字段在 conversation 里没有)
 */
const groupInfo = computed<
  | (GroupLite & {
      groupRemark?: string
      notice?: string
      ownerId?: number
      remarkNickName?: string
    })
  | undefined
>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return undefined
  }
  const group = groupStore.getGroup(conversation.targetId)
  const selfMember = group?.members?.find((member) => member.userId === getCurrentUserId())
  const showGroupName = group ? getGroupDisplayName(group) : conversation.name
  return {
    id: conversation.targetId,
    name: group?.name || conversation.name,
    showGroupName,
    showImage: group?.avatar || conversation.avatar,
    notice: group?.notice,
    remarkNickName: selfMember?.displayUserName,
    groupRemark: group?.groupRemark,
    ownerId: group?.ownerUserId,
    memberCount: group?.memberCount,
    joinApproval: group?.joinApproval
  }
})
 
/** 群成员列表:直接取 groupStore 缓存,map 成 GroupMemberLite 给下游消费(@-mention / 邀请等) */
const groupMembers = computed<GroupMemberLite[]>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return []
  }
  const group = groupStore.getGroup(conversation.targetId)
  return (group?.members || []).map((member) => {
    // 显示名走「好友备注 > 群备注 > 真实昵称」三级;头像走 nickname 保稳定
    const friend = friendStore.getFriend(member.userId)
    return {
      userId: member.userId,
      showName: getMemberDisplayName(member, friend),
      nickname: member.nickname,
      avatar: member.avatar,
      status: member.status,
      role: member.role
    }
  })
})
 
/** 切换到群会话时同步群信息 + 成员 */
async function ensureGroupData(groupId: number) {
  // 远程拉群信息(群名 / 公告 / 群主等元数据)
  await groupStore.fetchGroupInfo(groupId).catch((error) => {
    console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error)
  })
  if (isGroupQuit(groupStore.getGroup(groupId))) {
    return
  }
 
  // 先从 IDB 同步加载群成员,让首帧立即出成员名 / 头像
  await groupStore.loadGroupMemberList(groupId).catch((error) => {
    console.warn('[IM MessagePanel] loadGroupMemberList 失败', { groupId }, error)
    return null
  })
  const group = groupStore.getGroup(groupId)
  if (!group?.membersLoaded || group.membersExpired) {
    groupStore.fetchGroupMemberList(groupId, true).catch((error) => {
      console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error)
    })
  }
}
 
/** 群信息抽屉里点"刷新":强拉一次最新群元数据 + 群成员 */
function reloadGroupData() {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return
  }
  groupStore.fetchGroupInfo(conversation.targetId, true)
  groupStore.fetchGroupMemberList(conversation.targetId, true)
}
 
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>() // 历史消息抽屉 ref:「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const pendingMediaType = ref<null | number>(null) // 群通话发起:成员选择弹窗打开期间临时持有的 mediaType
 
/** 消息右键菜单「禁言」→ 打开时长选择弹窗 */
function handleMuteMember(groupId: number, userId: number, displayName: string) {
  muteMemberDialogRef.value?.open(groupId, userId, displayName)
}
 
/** 信息抽屉的 toggle:跟 header 上 3 点图标按钮共用 */
function toggleSide() {
  sideVisible.value = !sideVisible.value
}
 
const callPopoverVisible = ref(false) // 私聊通话入口:popover 触发;点 语音 / 视频 直接发起
const callInviting = ref(false) // 通话发起中
async function startPrivateCall(mediaType: number) {
  callPopoverVisible.value = false
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  await doInvite({
    conversationType: ImConversationType.PRIVATE,
    mediaType,
    inviteeIds: [conversation.targetId]
  })
}
 
/** 群通话入口:默认语音直接弹选人;与微信群通话一致,进通话后用户按需开摄像头 */
function handleGroupCall() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  pendingMediaType.value = ImRtcCallMediaType.VOICE
  callMemberPickerRef.value?.open({ groupId: conversation.targetId, mode: 'invite' })
}
 
/** 选人弹窗确认;带选中 ID 发起群通话 */
async function onCallMemberPicked(selectedIds: number[]) {
  const conversation = conversationStore.activeConversation
  const mediaType = pendingMediaType.value
  pendingMediaType.value = null
  if (!conversation || mediaType == null || selectedIds.length === 0) {
    return
  }
  await doInvite({
    conversationType: ImConversationType.GROUP,
    mediaType,
    groupId: conversation.targetId,
    inviteeIds: selectedIds
  })
}
 
/** 实际调 create 接口;统一处理成功 / ENDED(如忙线立即结束) */
async function doInvite(reqVO: {
  conversationType: number
  groupId?: number
  inviteeIds: number[]
  mediaType: number
}) {
  if (callInviting.value) {
    return
  }
  if (rtcStore.isActive) {
    message.warning('当前已有通话')
    return
  }
  callInviting.value = true
  try {
    const data = await createCall(reqVO)
    // 后端已 INSERT + 立即 end(如忙线):toast 提示,不进 INVITING 阶段;chat tip 由 RTC_CALL_END 推送写入消息流
    if (data.status === ImRtcCallStatus.ENDED) {
      message.warning(resolveCallEndReasonText(data.endReason))
      return
    }
    // 正常进入 INVITING 阶段:走 store 逻辑发起通话,后续状态更新 / 消息流更新由 RTC 模块监听推送处理
    rtcStore.startInviting(data)
  } finally {
    callInviting.value = false
  }
}
 
/** 当前私聊对应的好友(抽屉头部展示用) */
const privateFriend = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
    return undefined
  }
  return friendStore.getFriend(conversation.targetId)
})
 
/** 计算距离底部的像素 */
function distanceFromBottom(): number {
  const el = listRef.value
  if (!el) {
    return 0
  }
  return el.scrollHeight - el.scrollTop - el.clientHeight
}
 
/**
 * 消息列表滚动事件:刷新"是否在底部"状态
 * - 在底部:隐藏"回到底部"浮窗 + 清掉"未读新消息"计数
 * - 不在底部:显示"回到底部"浮窗,新消息会累计到 newMessageCount
 */
function handleScroll() {
  const dist = distanceFromBottom()
  const atBottom = dist <= BOTTOM_THRESHOLD
  showJumpToBottom.value = !atBottom
  if (atBottom) {
    newMessageCount.value = 0
  }
}
 
/**
 * 滚到底部:切会话 / 收到新消息(且当前在底部)/ 用户主动点「回到底部」 都走这里
 *
 * smooth=true 走平滑动画,适合用户主动点击;初始 / 自动滚动用 auto,避免用户感知到动画拖拽
 */
async function scrollToBottom(smooth = false) {
  // 1. 滚到当前 scrollHeight 的底部(图片 / 视频还在加载时只是大致到底)
  // 1.1 等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight,否则差最后一条的位置
  await nextTick()
  if (!listRef.value) {
    return
  }
  // 1.2 触发滚动;smooth 仅 user 主动点「回到底部」用,初始 / 自动滚走 auto 避免动画拖拽感
  listRef.value.scrollTo({
    top: listRef.value.scrollHeight,
    behavior: smooth ? 'smooth' : 'auto'
  })
  newMessageCount.value = 0
  showJumpToBottom.value = false
  // 1.3 记下「期望停下的 scrollTop」;图片 / 视频加载完底部会上移,scrollTop 没动就说明用户没手动滚走
  //     不能用 distanceFromBottom 判断:底部上移会让 distance 变大,被误判为「用户滚走了」直接放弃补滚
  const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
 
  // 2. 等媒体加载完后补滚到真实底部
  // 2.1 等容器内未加载完的图片 / 视频元数据;加载完后 scrollHeight 会增长到真实底部
  await waitMediaSettled()
  if (!listRef.value) {
    return
  }
  // 2.2 仅在用户没手动滚走时(scrollTop 仍贴近 expectedScrollTop)才补滚,避免等待期间用户上翻被打断
  if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
    return
  }
  // 2.3 补滚到新底部
  listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
}
 
/**
 * 等待容器内未加载完的图片 / 视频;最多等 2s 防止超大资源把整个滚动跟进卡住
 *
 * 仅关心元数据(loadedmetadata / img.complete),不等真正解码,因为尺寸够算 scrollHeight 就行
 */
function waitMediaSettled(): Promise<void> {
  // 1. 收集容器内未加载完的图片 / 视频
  if (!listRef.value) {
    return Promise.resolve()
  }
  // 1.1 一次扫 img + video,按 element 类型分别看「complete / readyState」过滤 pending
  const pendingMedia = [...listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')].filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1))
  // 1.2 没有 pending 直接返回,省掉 Promise.race / setTimeout 闭包构造
  if (pendingMedia.length === 0) {
    return Promise.resolve()
  }
 
  // 2. 等所有 pending 资源 load / error,最长 2s 兜底
  // 2.1 每个 element 都监听对应 loadedEvent + error,任一触发即 resolve(不让单条失败卡 race)
  const loadAll = Promise.all(
    pendingMedia.map(
      (el) =>
        new Promise<void>((resolve) => {
          const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata'
          el.addEventListener(loadedEvent, () => resolve(), { once: true })
          el.addEventListener('error', () => resolve(), { once: true })
        })
    )
  ).then(() => undefined)
  // 2.2 2s 超时兜底,防止超大资源 / 网络挂起把整个滚动跟进永久卡住
  const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000))
  return Promise.race([loadAll, timeout])
}
 
/**
 * 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发
 *
 * 1. 先关掉历史弹窗(避免 scroll 时遮挡 + dialog 关闭后让聊天面板拿回焦点)
 * 2. nextTick 等弹窗 leave 动画 / 列表渲染稳定后再查 DOM
 * 3. 按 data-message-id 找 wrapper,scrollIntoView({ block: center }) 让消息落到视口中部
 * 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
 * 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
 */
async function handleLocate(messageId: number) {
  if (!messageId) {
    return
  }
  await nextTick()
  if (!listRef.value) {
    return
  }
  const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`)
  if (!target) {
    message.warning('原消息不在视野')
    return
  }
  target.scrollIntoView({ behavior: 'smooth', block: 'center' })
  target.classList.add('message-panel__message-anchor--highlight')
  setTimeout(() => {
    target.classList.remove('message-panel__message-anchor--highlight')
  }, 1600)
}
 
/**
 * 消息数量变化时的滚动跟进策略
 * - 当前在底部 → 自动滚到新底部(用户在"实时跟读",体验上跟微信一致)
 * - 不在底部 → 累计 newMessageCount,让 sticky 浮窗显示"X 条新消息",让用户主动点
 */
watch(
  () => messages.value.length,
  (newLen, oldLen) => {
    // 仅处理新增(delta > 0);删除 / 撤回让 length 减少时不动滚动状态
    const delta = (newLen || 0) - (oldLen || 0)
    if (delta <= 0) {
      return
    }
    // 用 BOTTOM_THRESHOLD(80px)做容差:用户稍微往上翻几行就视作"不在底部",
    // 否则一直 auto-scroll 会把人正在读的内容顶走,体验糟糕
    const dist = distanceFromBottom()
    if (dist <= BOTTOM_THRESHOLD) {
      scrollToBottom()
    } else {
      newMessageCount.value += delta
      showJumpToBottom.value = true
    }
  }
)
 
/**
 * 切换会话:清空"在不在底部"相关状态、强制滚到底部、群会话预拉资料
 *
 * type+targetId 一起监听:私聊与群聊 id 同号时切换也能触发;immediate:true 让首次进入页面就能初始化
 */
watch(
  () => [
    conversationStore.activeConversation?.type,
    conversationStore.activeConversation?.targetId
  ],
  ([type, targetId]) => {
    // 切会话时上一会话的「未读累计 + 浮窗显示」必须清掉,否则会带到新会话里看起来很突兀
    newMessageCount.value = 0
    showJumpToBottom.value = false
    // 抽屉里展示的群信息 / 好友信息属于上一会话,切会话时统一关掉
    sideVisible.value = false
    scrollToBottom()
    // 仅群聊预拉详情 / 成员(私聊对端在首屏 fetchFriendList 时就拉了)
    if (targetId && type === ImConversationType.GROUP) {
      ensureGroupData(targetId)
    }
  },
  { immediate: true }
)
</script>
 
<template>
  <div class="flex flex-1 flex-col min-w-0 bg-[var(--ant-color-fill-secondary)]">
    <template v-if="conversationStore.activeConversation">
      <!-- 顶部 header:第一行群名 + 右侧图标,第二行嵌入置顶气泡(仅群聊 + 有置顶) -->
      <div
        class="flex flex-shrink-0 flex-col bg-[var(--ant-color-fill-secondary)] border-b border-b-solid border-[var(--im-border-color-lighter)]"
      >
        <div class="flex items-center justify-between h-14 px-5">
          <span class="flex flex-col min-w-0">
            <span class="flex items-baseline gap-1.5 min-w-0">
              <span
                class="overflow-hidden text-base font-medium truncate text-[var(--ant-color-text)]"
              >
                {{ conversationStore.activeConversation?.name || '' }}
              </span>
              <span
                v-if="isGroup && headerMemberCount > 0"
                class="flex-shrink-0 text-sm text-[var(--ant-color-text-secondary)]"
              >
                ({{ headerMemberCount }})
              </span>
            </span>
            <!-- 副标题:备注 ≠ 群名时展示原群名,提示用户当前看到的主名是自己设的备注 -->
            <span
              v-if="headerSubtitle"
              class="overflow-hidden text-xs truncate text-[var(--ant-color-text-secondary)]"
            >
              {{ headerSubtitle }}
            </span>
          </span>
          <div class="flex gap-2 items-center">
            <!-- 聊天历史 -->
            <Tooltip title="聊天历史" placement="bottom">
              <Icon
                icon="ep:chat-dot-round"
                class="message-panel__header-icon cursor-pointer"
                @click="historyDialogRef?.open()"
              />
            </Tooltip>
            <!-- 通话入口:私聊弹「语音 / 视频」popover;群聊直接进选人弹窗 -->
            <Popover
              v-if="isPrivate"
              v-model:open="callPopoverVisible"
              placement="bottomRight"
              :overlay-style="{ width: '140px' }"
              trigger="click"
              overlay-class-name="message-panel__call-popover"
            >
              <Icon
                icon="ant-design:phone-outlined"
                class="message-panel__header-icon cursor-pointer"
              />
              <template #content>
                <div class="flex flex-col gap-0.5">
                  <div
                    class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]"
                    @click="startPrivateCall(ImRtcCallMediaType.VOICE)"
                  >
                    <Icon icon="ant-design:phone-outlined" :size="16" />
                    <span>语音通话</span>
                  </div>
                  <div
                    class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]"
                    @click="startPrivateCall(ImRtcCallMediaType.VIDEO)"
                  >
                    <Icon icon="ant-design:video-camera-outlined" :size="16" />
                    <span>视频通话</span>
                  </div>
                </div>
              </template>
            </Popover>
            <Tooltip v-else-if="!isQuitGroup" title="通话" placement="bottom">
              <Icon
                icon="ant-design:phone-outlined"
                class="message-panel__header-icon cursor-pointer"
                @click="handleGroupCall"
              />
            </Tooltip>
            <!-- 信息抽屉入口:群聊有,私聊仅在对方是好友时显示(非好友会话抽屉内容为空,没意义) -->
            <Tooltip
              v-if="isGroup || (isPrivate && !showNotFriendBanner)"
              :title="isGroup ? '群聊信息' : '聊天信息'"
              placement="bottom"
            >
              <Icon
                icon="ant-design:ellipsis-outlined"
                class="message-panel__header-icon cursor-pointer"
                @click="toggleSide"
              />
            </Tooltip>
          </div>
        </div>
 
        <!-- 群通话胶囊条:仅群聊 + 该群有活跃通话时显示;点击展开看成员 + 加入按钮 -->
        <RtcGroupCallBanner
          v-if="isGroup && !isQuitGroup && conversationStore.activeConversation"
          :group-id="conversationStore.activeConversation.targetId"
        />
 
        <!-- 群置顶消息:第二行嵌入 header;仅群聊 + 有置顶时显示 -->
        <GroupPinnedMessage
          v-if="isGroup && conversationStore.activeConversation"
          :group-id="conversationStore.activeConversation.targetId"
          @locate="handleLocate"
        />
        <!-- 群顶部「待处理加群申请」横幅:仅群聊 + owner / admin + count > 0 时显示 -->
        <GroupRequestPending
          v-if="isGroup && !isQuitGroup && conversationStore.activeConversation"
          :group-id="conversationStore.activeConversation.targetId"
        />
        <!-- 私聊:对方不再是有效好友(我删了对方 / 从未加过;单边设计下「被对方删除」本端 friendStore 不更新故不会触发);胶囊嵌在 header 内(跟群置顶同级),点击弹 UserInfoCard -->
        <div
          v-if="showNotFriendBanner"
          class="flex flex-shrink-0 items-start px-4 pb-2 bg-[var(--ant-color-fill-secondary)]"
        >
          <div
            class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--ant-color-text)] bg-[var(--ant-color-warning-bg)] transition-colors hover:bg-[var(--ant-color-warning-bg-hover)]"
            @click="handleNotFriendClick"
          >
            <span
              class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--ant-color-warning)] flex-shrink-0"
            >
              <Icon icon="ant-design:user-outlined" :size="11" />
            </span>
            <span>对方还不是你的朋友</span>
            <Icon icon="ep:arrow-right" :size="12" class="text-[var(--ant-color-text-secondary)]" />
          </div>
        </div>
      </div>
 
      <!-- 中间:消息列表 -->
      <div
        ref="listRef"
        class="relative flex-1 py-2 overflow-y-auto bg-[var(--ant-color-bg-layout)]"
        @scroll="handleScroll"
      >
        <div
          v-if="messages.length === 0"
          class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]"
        >
          暂无消息
        </div>
        <!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector
             找到这层 wrapper,scrollIntoView + 加高亮 class;本地占位消息跳过 -->
        <div
          v-for="(msg, index) in messages"
          :key="msg.id || msg.clientMessageId"
          :data-message-id="msg.id || ''"
          class="message-panel__message-anchor"
        >
          <MessageItem
            :message="msg"
            :prev-message="messages[index - 1]"
            @locate="handleLocate"
            @mute="handleMuteMember"
            @reload="reloadGroupData"
          />
        </div>
 
        <!-- 回到底部浮动按钮(滚动不在底部时显示) -->
        <transition name="message-panel__jump-fade">
          <div
            v-if="showJumpToBottom"
            class="message-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]"
            @click="scrollToBottom(true)"
          >
            <Icon icon="ant-design:down-outlined" :size="14" />
            <span v-if="newMessageCount > 0" class="font-medium">
              {{ newMessageCount > 99 ? '99+' : newMessageCount }} 条新消息
            </span>
            <span v-else>回到底部</span>
          </div>
        </transition>
      </div>
 
      <!-- 底部:输入框(频道单向消息无需输入框);多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
      <div v-if="!isChannel" class="relative">
        <MessageInput />
        <MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
      </div>
 
      <!-- 右侧信息抽屉:群聊 / 私聊各自一份 -->
      <ConversationGroupSide
        v-if="isGroup"
        v-model="sideVisible"
        :group="groupInfo"
        :conversation="conversationStore.activeConversation"
        :members="groupMembers"
        @reload="reloadGroupData"
        @open-history="historyDialogRef?.open()"
      />
      <ConversationPrivateSide
        v-else
        v-model="sideVisible"
        :conversation="conversationStore.activeConversation"
        :friend="privateFriend"
        @open-history="historyDialogRef?.open()"
      />
 
      <!-- 历史消息抽屉 -->
      <MessageHistory ref="historyDialogRef" @locate="handleLocate" />
 
      <!-- 转发弹窗 / 合并消息详情:在 MessagePanel 子树内挂载,子组件通过 inject 触发 -->
      <MessageForwardDialog ref="forwardDialogRef" />
      <MessageMergeDetailDialog ref="mergeDetailDialogRef" />
 
      <!-- 禁言时长选择弹窗 -->
      <GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
 
      <!-- 群通话成员选择弹窗 -->
      <RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
    </template>
    <div
      v-else
      class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]"
    >
      <span>选择一个会话开始聊天</span>
    </div>
  </div>
</template>
 
<style scoped>
/* :deep 穿透 el-icon 子组件 svg;el-icon 全局规则 .el-icon{color:var(--color,inherit)} 优先级更高,
   用 :deep + !important 锁色;颜色取 Element Plus 主题变量,暗色模式自动切到更亮的灰 */
.message-panel__header-icon,
.message-panel__header-icon :deep(svg) {
  width: 20px;
  height: 20px;
  color: var(--ant-color-text) !important;
  fill: currentColor !important;
  transition: color 0.15s;
}
 
.message-panel__header-icon {
  box-sizing: content-box;
  padding: 4px;
}
.message-panel__header-icon:hover,
.message-panel__header-icon:hover :deep(svg) {
  color: var(--ant-color-primary) !important;
}
 
/* sticky + translate 居中:fit-content 宽度不会撑满,transform 水平 -50% 偏移;同时 transition opacity 和 transform 两个属性 */
.message-panel__jump-bottom {
  transform: translateX(-50%);
  transition:
    opacity 0.2s,
    transform 0.2s;
}
 
/* JS 切换 highlight class + background-color transition 联动;MessageHistory「定位」跳过来时短暂高亮 1.6s */
.message-panel__message-anchor {
  transition: background-color 0.6s ease;
}
.message-panel__message-anchor--highlight {
  background-color: var(--ant-color-warning-bg);
}
 
/* Vue <transition> 钩子类名固定,必须 SCSS 声明;回到底部按钮淡入淡出 */
.message-panel__jump-fade-enter-active,
.message-panel__jump-fade-leave-active {
  transition:
    opacity 0.2s,
    transform 0.2s;
}
 
.message-panel__jump-fade-enter-from,
.message-panel__jump-fade-leave-to {
  opacity: 0;
  transform: translate(-50%, 20px);
}
</style>
 
<style>
/* el-popover 全局样式:紧贴菜单的小 padding;非 scoped 才能命中 popper-class */
.message-panel__call-popover.el-popover.el-popper {
  min-width: auto;
  padding: 6px;
}
</style>