From cb9cd49627b65a4c0e137e08063271a8cefe1826 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期四, 23 七月 2026 17:48:49 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0

---
 src/views/im/home/pages/conversation/components/message/message-panel.vue |  805 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 805 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/pages/conversation/components/message/message-panel.vue b/src/views/im/home/pages/conversation/components/message/message-panel.vue
new file mode 100644
index 0000000..ff1c133
--- /dev/null
+++ b/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 姘旀场鐐瑰嚮閲嶆嫧锛汳essageItem 娉ㄥ叆鍚庤皟鐢�
+
+// ==================== 澶氶�夋ā寮� ====================
+// 妯″潡绾у崟渚� state锛坈omposable锛夛紱鏈粍浠朵粎鍋氬垏浼氳瘽閫�鍑� + 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
+)
+
+/** 褰撳墠婵�娲讳細璇濇槸鍚﹀巻鍙查��缇ょ兢锛氱缇ら�氳瘽銆侀殣钘忕兢鐢宠妯箙绛夋搷浣滃叆鍙o紱鑱婂ぉ鍘嗗彶銆佺兢鍚嶅ご鍍忕収甯稿睍绀� */
+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 瑙﹀彂鍚庢墠浼氭湁瀹屾暣鏁版嵁锛�
+ * 鍏滃簳瀛楁锛坣ame / avatar锛変繚璇� header 涓嶄細"闂┖"锛宯otice / 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 缂撳瓨锛宮ap 鎴� 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锛氥�岃亰澶╁巻鍙层�峣con / 鎶藉眽銆屾煡鎵捐亰澶╁唴瀹广�嶅叆鍙i兘璋� 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) // 绉佽亰閫氳瘽鍏ュ彛锛歱opover 瑙﹀彂锛涚偣 璇煶 / 瑙嗛 鐩存帴鍙戣捣
+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 闃舵锛沜hat tip 鐢� RTC_CALL_END 鎺ㄩ�佸啓鍏ユ秷鎭祦
+    if (data.status === ImRtcCallStatus.ENDED) {
+      message.warning(resolveCallEndReasonText(data.endReason))
+      return
+    }
+    // 姝e父杩涘叆 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 瑙﹀彂婊氬姩锛泂mooth 浠� user 涓诲姩鐐广�屽洖鍒板簳閮ㄣ�嶇敤锛屽垵濮� / 鑷姩婊氳蛋 auto 閬垮厤鍔ㄧ敾鎷栨嫿鎰�
+  listRef.value.scrollTo({
+    top: listRef.value.scrollHeight,
+    behavior: smooth ? 'smooth' : 'auto'
+  })
+  newMessageCount.value = 0
+  showJumpToBottom.value = false
+  // 1.3 璁颁笅銆屾湡鏈涘仠涓嬬殑 scrollTop銆嶏紱鍥剧墖 / 瑙嗛鍔犺浇瀹屽簳閮ㄤ細涓婄Щ锛宻crollTop 娌″姩灏辫鏄庣敤鎴锋病鎵嬪姩婊氳蛋
+  //     涓嶈兘鐢� distanceFromBottom 鍒ゆ柇锛氬簳閮ㄤ笂绉讳細璁� distance 鍙樺ぇ锛岃璇垽涓恒�岀敤鎴锋粴璧颁簡銆嶇洿鎺ユ斁寮冭ˉ婊�
+  const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
+
+  // 2. 绛夊獟浣撳姞杞藉畬鍚庤ˉ婊氬埌鐪熷疄搴曢儴
+  // 2.1 绛夊鍣ㄥ唴鏈姞杞藉畬鐨勫浘鐗� / 瑙嗛鍏冩暟鎹紱鍔犺浇瀹屽悗 scrollHeight 浼氬闀垮埌鐪熷疄搴曢儴
+  await waitMediaSettled()
+  if (!listRef.value) {
+    return
+  }
+  // 2.2 浠呭湪鐢ㄦ埛娌℃墜鍔ㄦ粴璧版椂锛坰crollTop 浠嶈创杩� expectedScrollTop锛夋墠琛ユ粴锛岄伩鍏嶇瓑寰呮湡闂寸敤鎴蜂笂缈昏鎵撴柇
+  if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
+    return
+  }
+  // 2.3 琛ユ粴鍒版柊搴曢儴
+  listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
+}
+
+/**
+ * 绛夊緟瀹瑰櫒鍐呮湭鍔犺浇瀹岀殑鍥剧墖 / 瑙嗛锛涙渶澶氱瓑 2s 闃叉瓒呭ぇ璧勬簮鎶婃暣涓粴鍔ㄨ窡杩涘崱浣�
+ *
+ * 浠呭叧蹇冨厓鏁版嵁锛坙oadedmetadata / img.complete锛夛紝涓嶇瓑鐪熸瑙g爜锛屽洜涓哄昂瀵稿绠� scrollHeight 灏辫
+ */
+function waitMediaSettled(): Promise<void> {
+  // 1. 鏀堕泦瀹瑰櫒鍐呮湭鍔犺浇瀹岀殑鍥剧墖 / 瑙嗛
+  if (!listRef.value) {
+    return Promise.resolve()
+  }
+  // 1.1 涓�娆℃壂 img + video锛屾寜 element 绫诲瀷鍒嗗埆鐪嬨�宑omplete / 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锛宻crollIntoView({ block: center }) 璁╂秷鎭惤鍒拌鍙d腑閮�
+ * 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>
+            <!-- 閫氳瘽鍏ュ彛锛氱鑱婂脊銆岃闊� / 瑙嗛銆峱opover锛涚兢鑱婄洿鎺ヨ繘閫変汉寮圭獥 -->
+            <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锛宻crollIntoView + 鍔犻珮浜� 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锛沞l-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 灞呬腑锛歠it-content 瀹藉害涓嶄細鎾戞弧锛宼ransform 姘村钩 -50% 鍋忕Щ锛涘悓鏃� transition opacity 鍜� transform 涓や釜灞炴�� */
+.message-panel__jump-bottom {
+  transform: translateX(-50%);
+  transition:
+    opacity 0.2s,
+    transform 0.2s;
+}
+
+/* JS 鍒囨崲 highlight class + background-color transition 鑱斿姩锛汳essageHistory銆屽畾浣嶃�嶈烦杩囨潵鏃剁煭鏆傞珮浜� 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>

--
Gitblit v1.9.3