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