From 4039d50f086600706462d5e2e45b3397aa870d9c Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 24 七月 2026 17:09:40 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0

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

diff --git a/src/views/im/home/pages/conversation/components/message/message-item.vue b/src/views/im/home/pages/conversation/components/message/message-item.vue
new file mode 100644
index 0000000..fa456f8
--- /dev/null
+++ b/src/views/im/home/pages/conversation/components/message/message-item.vue
@@ -0,0 +1,1055 @@
+<script lang="ts" setup>
+import type { GroupMemberLite } from '../../../../components/group'
+import type { Message } from '../../../../types'
+
+import { computed, inject } from 'vue'
+
+import { confirm } from '#/packages/effects/common-ui/src'
+import { IconifyIcon as Icon } from '#/packages/icons/src'
+import { useUserStore } from '#/packages/stores/src'
+
+import { useClipboard } from '@vueuse/core'
+import { message as antdMessage, Tag } from 'ant-design-vue'
+
+import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '#/api/im/group'
+import { removeGroupMember } from '#/api/im/group/member'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+import {
+  MESSAGE_GROUP_READ_ENABLED,
+  MESSAGE_PRIVATE_READ_ENABLED,
+  MESSAGE_RECALL_WINDOW_MS,
+  MESSAGE_TIME_TIP_GAP_MS
+} from '#/views/im/utils/config'
+import {
+  ImContentType,
+  ImConversationType,
+  ImForwardMode,
+  ImFriendAddSource,
+  ImGroupMemberRole,
+  ImMessageReceiptStatus,
+  ImMessageStatus,
+  isFriendChatTip,
+  isGroupNotification,
+  isMediaMessageType,
+  isNormalMessage,
+  isRtcCallTip
+} from '#/views/im/utils/constants'
+import { buildRecallTipSegments } from '#/views/im/utils/conversation'
+import {
+  buildQuoteFromMessage,
+  type CardMessage,
+  extractAddableFace,
+  getQuoteFromMessage,
+  type MentionCandidate,
+  parseMessage,
+  type TextMessage
+} from '#/views/im/utils/message'
+import {
+  parseRtcCallPayload,
+  resolveFriendNotificationSegments,
+  resolveGroupNotificationSegments,
+  resolveRtcCallPrivateBubbleText,
+  resolveRtcCallTipSegments
+} from '#/views/im/utils/message'
+import { formatTimeTip } from '#/views/im/utils/time'
+import {
+  getMemberDisplayName,
+  getMentionCandidates,
+  getSenderDisplayName,
+  getSenderRealNickname,
+  isGroupQuit
+} from '#/views/im/utils/user'
+
+import { UserAvatar } from '../../../../components/user'
+import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
+import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
+import { useMessageSender } from '../../../../composables/useMessageSender'
+import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
+import { useConversationStore } from '../../../../store/conversationStore'
+import { useFaceStore } from '../../../../store/faceStore'
+import { useFriendStore } from '../../../../store/friendStore'
+import { useGroupStore } from '../../../../store/groupStore'
+import { useMessageStore } from '../../../../store/messageStore'
+import { useImUiStore } from '../../../../store/uiStore'
+import {
+  IM_FORWARD_DIALOG_KEY,
+  IM_MERGE_DETAIL_DIALOG_KEY,
+  IM_RTC_REDIAL_KEY
+} from './forward/keys'
+import MessageBubble from './message-bubble.vue'
+import MessageReadStatus from './message-read-status.vue'
+import ReplyPreview from './reply-preview.vue'
+import TipSegments from './tip-segments.vue'
+
+defineOptions({ name: 'ImMessageItem' })
+
+const props = defineProps<{
+  message: Message
+  /** 鍒楄〃涓殑涓婁竴鏉℃秷鎭紝鐢ㄤ簬鍒ゆ柇鏄惁瑕佸湪褰撳墠娑堟伅涓婃柟娓叉煋鏃堕棿鍒嗛殧鏉★紱涓嶄紶鎸夊垪琛ㄧ涓�鏉″鐞� */
+  prevMessage?: Message
+}>()
+
+const emit = defineEmits<{
+  /** 寮曠敤鍧楃偣鍑� 鈫� MessagePanel 婊氬畾浣� + 楂樹寒 */
+  locate: [messageId: number]
+  /** 绂佽█锛氶渶瑕佺埗缁勪欢鎵撳紑鏃堕暱閫夋嫨寮圭獥 */
+  mute: [groupId: number, userId: number, displayName: string]
+  /** 鏁版嵁鍙樻洿鍚庡埛鏂扮兢淇℃伅 */
+  reload: []
+}>()
+
+// ==================== Stores / Hooks ====================
+
+const userStore = useUserStore()
+const conversationStore = useConversationStore()
+const messageStore = useMessageStore()
+const groupStore = useGroupStore()
+const friendStore = useFriendStore()
+const faceStore = useFaceStore()
+const uiStore = useImUiStore()
+const { recall, sendRaw } = useMessageSender()
+const { uploadAndSendMedia } = useMediaUploader()
+const muteOverlay = useMuteOverlay()
+// legacy:true 鍏煎 HTTP 鐜锛屾病鏈� navigator.clipboard 鏃堕檷绾у埌 execCommand
+const { copy: copyToClipboard } = useClipboard({ legacy: true })
+
+// ==================== 鍐呭绫诲瀷鍒ゆ柇 ====================
+
+/** 鏄惁鍦ㄥ綋鍓嶆秷鎭笂鏂规覆鏌撴椂闂村垎闅旀潯锛氬垪琛ㄧ涓�鏉� / 璺濅笂涓�鏉¤秴杩囬槇鍊硷紱缂� sendTime 涓嶆覆鏌擄紱棰戦亾绱犳潗姣忔潯閮芥樉绀� */
+const shouldShowTimeTip = computed(() => {
+  if (!props.message.sendTime) {
+    return false
+  }
+  if (isMaterial.value) {
+    return true
+  }
+  if (!props.prevMessage?.sendTime) {
+    return true
+  }
+  return props.message.sendTime - props.prevMessage.sendTime > MESSAGE_TIME_TIP_GAP_MS
+})
+
+/** 浠� MessageItem 鑷韩浠嶈鐢ㄥ埌鐨� type 鍒ゅ畾锛堝叾瀹冨垎鏀凡涓嬫矇鍒� MessageBubble锛� */
+const isVoice = computed(() => props.message.type === ImContentType.VOICE)
+const isMerge = computed(() => props.message.type === ImContentType.MERGE)
+/**
+ * 棰戦亾绱犳潗鍦ㄣ�岄閬撲細璇濄�嶅唴浠垮井淇″叕浼楀彿鏍峰紡锛堝眳涓� + 鏃犲ご鍍忥級锛�
+ * 绉佽亰 / 缇よ亰閲岃杞彂杩囨潵鐨勭礌鏉愭寜 selfSend 璧版爣鍑嗘皵娉″竷灞�锛堣嚜宸卞彸銆佸鏂瑰乏銆佸甫澶村儚锛�
+ */
+const isMaterial = computed(
+  () =>
+    props.message.type === ImContentType.MATERIAL &&
+    conversationStore.activeConversation?.type === ImConversationType.CHANNEL
+)
+
+/** 褰撳墠鏄惁鍦ㄥ叕浼楀彿 / 棰戦亾浼氳瘽鍐咃細闄愬埗鍙抽敭鑿滃崟鍙睍绀鸿浆鍙� / 鍒犻櫎 */
+const isChannelConversation = computed(
+  () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
+)
+
+// ==================== 浜嬩欢娑堟伅锛堟挙鍥� / 濂藉弸 / 缇ゅ箍鎾級 ====================
+// 杩欎笁绫讳笉璧版櫘閫氭皵娉★紝娓叉煋鎴愬眳涓伆鑹� tip锛涘垽鏂� + 鏂囨閰嶅鏀句竴璧凤紝鏂板绗洓绫讳簨浠跺彧闇�鍦ㄦ湰鍧楁敼瀹�
+
+/** 鏄惁宸叉挙鍥烇細pull / WS 涓よ矾閮戒細璋� recallMessage 鎶婂師娑堟伅鏇存柊涓� type=RECALL锛屾覆鏌撳彧闇�璇嗗埆 type */
+const isRecall = computed(() => props.message.type === ImContentType.RECALL)
+
+/** 鎾ゅ洖鎻愮ず segments锛氫緷璧� activeConversation 瀹炴椂绠� sender 鍚� */
+const recallTipSegments = computed(() => {
+  const conversation = conversationStore.activeConversation
+  return buildRecallTipSegments(
+    props.message.senderId,
+    props.message.selfSend,
+    conversation?.type ?? 0,
+    conversation?.targetId ?? 0
+  )
+})
+
+/** 鏄惁浼氳瘽鍐呭ソ鍙嬩簨浠舵皵娉★紙FRIEND_ADD / FRIEND_DELETE锛� */
+const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type))
+
+/** 濂藉弸浜嬩欢 segments */
+const friendChatTipSegments = computed(() => resolveFriendNotificationSegments(props.message))
+
+/** 鏄惁缇ゅ箍鎾簨浠讹紙GROUP_CREATE..GROUP_BANNED 娈典綅锛屾帓闄� GROUP_MEMBER_SETTING_UPDATE 涓汉淇″彿锛� */
+const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))
+
+/** 缇ゅ箍鎾簨浠� segments */
+const groupNotificationSegments = computed(() =>
+  resolveGroupNotificationSegments(props.message, (id: number) =>
+    getSenderDisplayName(id, ImConversationType.GROUP, props.message.targetId ?? 0)
+  )
+)
+
+/** 绉佽亰 RTC_CALL_END 璧般�屽噯姘旀场銆嶏紙宸﹀彸鍒嗗竷 + 鐢佃瘽鍥炬爣 + 鏂囨锛夛紱闈炵鑱婂満鏅负 null */
+const rtcCallEndPrivatePayload = computed(() => {
+  if (props.message.type !== ImContentType.RTC_CALL_END) {
+    return null
+  }
+  const payload = parseRtcCallPayload(props.message.content)
+  return payload?.conversationType === ImConversationType.PRIVATE ? payload : null
+})
+
+/** 鏄惁绉佽亰閫氳瘽姘旀场 */
+const isRtcCallPrivateBubbleMessage = computed(() => rtcCallEndPrivatePayload.value !== null)
+
+/** 绉佽亰閫氳瘽姘旀场鏂囨锛堟寜 operatorUserId 鏄惁褰撳墠鐢ㄦ埛鍖哄垎锛涘榻愬井淇′袱绔笉鍚岃瑙掞級 */
+const rtcCallPrivateBubbleText = computed(() =>
+  resolveRtcCallPrivateBubbleText(rtcCallEndPrivatePayload.value)
+)
+
+/** 鏄惁浼氳瘽鍐呯兢閫氳瘽浜嬩欢灞呬腑 tip */
+const isRtcCallTipMessage = computed(() => {
+  if (!isRtcCallTip(props.message.type)) {
+    return false
+  }
+  const payload = parseRtcCallPayload(props.message.content)
+  if (payload?.conversationType !== ImConversationType.GROUP) {
+    return false
+  }
+  return !isRtcCallPrivateBubbleMessage.value
+})
+
+/** 閫氳瘽浜嬩欢 segments锛涗粎缇よ亰 tip 鐢� */
+const rtcCallTipSegments = computed(() => resolveRtcCallTipSegments(props.message))
+
+// ==================== 娑堟伅鍐呭瑙f瀽 / payload ====================
+
+/** 寮曠敤瀵硅薄锛氭皵娉″唴宓屽叆灞曠ず锛涢潪寮曠敤娑堟伅杩斿洖 null锛屾ā鏉� v-if 涓嶆覆鏌� */
+const quote = computed(() => getQuoteFromMessage(props.message.content))
+
+const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY) // MessagePanel 娉ㄥ叆鐨勫脊绐楄Е鍙戝嚱鏁�
+const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
+const redialRtcCall = inject(IM_RTC_REDIAL_KEY)
+
+/** 绉佽亰 RTC_CALL_END 姘旀场鐐瑰嚮锛氱敤鍚屾 mediaType 閲嶆嫧 */
+function handleRtcCallBubbleClick() {
+  const mediaType = rtcCallEndPrivatePayload.value?.mediaType
+  if (mediaType == null) {
+    return
+  }
+  redialRtcCall?.(mediaType)
+}
+
+const multiSelect = useMessageMultiSelect() // 澶氶�夋ā寮忥細妯″潡绾у崟渚� composable
+
+/** 鍚堝苟娑堟伅姘旀场鐐瑰嚮锛氭墦寮�璇︽儏寮圭獥锛堝祵濂楀悎骞剁敱寮圭獥鍐呴儴 push 鏍堬級 */
+function handleMergeOpen(content: string) {
+  openMergeDetail?.(content)
+}
+
+/** 鏂囨湰姘旀场 @ mention 鍊欓�夊悕瀛楋細浠呯兢娑堟伅鏈夋晥锛屾寜 atUserIds 鍙嶆煡缇ゆ垚鍛樼湡瀹炴樀绉帮紱闈� TEXT 涓嶈蛋 store 璇伙紝璁� getMentionCandidates 鐩存帴杩斿洖绋冲畾绌烘暟缁� */
+const textMentions = computed<MentionCandidate[]>(() => {
+  if (props.message.type !== ImContentType.TEXT) {
+    return getMentionCandidates(undefined, null)
+  }
+  return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation)
+})
+
+/** 鍚嶇墖鐐瑰嚮锛氱敤鎴峰悕鐗囧脊 UserInfoCard锛岀兢鍚嶇墖寮� GroupInfoCard锛涘叾瀹� targetType锛堝惈鏀瑰寘鑴忔暟鎹級蹇界暐 */
+function handleCardClick(card: CardMessage, e: MouseEvent) {
+  if (!card?.targetId) {
+    return
+  }
+  if (card.targetType === ImConversationType.PRIVATE) {
+    uiStore.openUserInfoCardAtEvent(
+      { id: card.targetId, nickname: card.name, avatar: card.avatar },
+      e,
+      ImFriendAddSource.CARD
+    )
+    return
+  }
+  if (card.targetType === ImConversationType.GROUP) {
+    uiStore.openGroupInfoCardAtEvent(
+      {
+        id: card.targetId,
+        name: card.name,
+        showImage: card.avatar,
+        memberCount: card.memberCount
+      },
+      e
+    )
+  }
+}
+
+/** 濯掍綋涓婁紶涓細MessageBubble 鑷覆鏌撻伄缃� / 杩涘害鏉★紱澶栧眰鍙敤浣� showSendingLoading 鍒ゅ畾 */
+const isUploading = computed(() => props.message.uploadProgress != null)
+
+/**
+ * 鏄惁鍦ㄦ皵娉″熬閮ㄦ樉绀恒�屽彂閫佷腑銆峫oading 杞湀
+ *
+ * 鍥剧墖 / 瑙嗛 / 鏂囦欢姘旀场鍐呭祵宸叉湁杩涘害鍙嶉锛堥伄缃� / 杩涘害鏉★級锛屽灞� loading 涓嶅啀鍙犲姞锛�
+ * 璇煶姘旀场鍙湁楹﹀厠椋� + 鏃堕暱锛屾棤鍐呭祵杩涘害锛屽繀椤讳繚鐣欏灞� loading 璁╃敤鎴锋劅鐭ユ鍦ㄥ彂閫�
+ */
+const showSendingLoading = computed(
+  () => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value)
+)
+
+// ==================== 鍙戦�佷汉 / 宸茶 / @ ====================
+
+/** 缇よ亰 + 瀵规柟娑堟伅 鏃讹紝鍦ㄦ皵娉′笂鏂规樉绀哄彂閫佽�呮樀绉� */
+const showSenderName = computed(() => {
+  if (props.message.selfSend) {
+    return false
+  }
+  return conversationStore.activeConversation?.type === ImConversationType.GROUP
+})
+
+/** 鍙戦�佽�呭ご鍍忥紱绉佽亰鐨� conversation.avatar 灏辨槸瀵规柟澶村儚锛坥penConversation 鍏ュ弬绾﹀畾锛� */
+const senderAvatar = computed(() => {
+  const conversation = conversationStore.activeConversation
+  if (!conversation || props.message.selfSend) {
+    return ''
+  }
+  if (conversation.type === ImConversationType.GROUP) {
+    const group = groupStore.getGroup(conversation.targetId)
+    return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
+  }
+  return conversation.avatar || ''
+})
+
+/** 澶村儚鑹插崱 fallback 鏂囨湰锛氭案杩滄槸鐪熷疄鏄电О锛屼笉鎺哄娉� */
+const senderRealNickname = computed(() => {
+  const conversation = conversationStore.activeConversation
+  return getSenderRealNickname(
+    props.message.senderId,
+    conversation?.type ?? 0,
+    conversation?.targetId ?? 0
+  )
+})
+
+/** 姘旀场涓婃柟鍙戦�佷汉鏄剧ず鍚嶏紙浠呯兢鑱婂鏂规秷鎭樉绀猴級锛氬ソ鍙嬪娉� > 缇ゅ娉� > 鐪熷疄鏄电О */
+const senderDisplayName = computed(() => {
+  const conversation = conversationStore.activeConversation
+  return getSenderDisplayName(
+    props.message.senderId,
+    conversation?.type ?? 0,
+    conversation?.targetId ?? 0
+  )
+})
+
+/** 绉佽亰銆屽凡璇� / 鏈銆嶆�侊紙浠呭鑷繁鍙戦�佺殑绉佽亰娑堟伅灞曠ず锛涚鑱婂凡璇诲叏灞�鍏抽棴鏃朵笉鍐嶅睍绀猴級 */
+const privateReadLabel = computed(() => {
+  if (!MESSAGE_PRIVATE_READ_ENABLED) {
+    return ''
+  }
+  if (!props.message.selfSend) {
+    return ''
+  }
+  if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) {
+    return ''
+  }
+  if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) {
+    return '宸茶'
+  }
+  if (props.message.receiptStatus === ImMessageReceiptStatus.PENDING) {
+    return '鏈'
+  }
+  return ''
+})
+
+/** 鏄惁闇�瑕佹樉绀虹兢鍥炴墽 popover锛氳嚜宸卞彂鐨勭兢娑堟伅涓斿悗绔紑鍚簡鍥炴墽锛圢O_RECEIPT 琛ㄧず鍙戦�佹椂鏈姹傚洖鎵э紝涓嶆覆鏌擄紱缇ゅ凡璇诲叏灞�鍏抽棴鏃剁粺涓�涓嶅睍绀猴級 */
+const showGroupReadStatus = computed(() => {
+  if (!MESSAGE_GROUP_READ_ENABLED) {
+    return false
+  }
+  if (!props.message.selfSend) {
+    return false
+  }
+  if (conversationStore.activeConversation?.type !== ImConversationType.GROUP) {
+    return false
+  }
+  const status = props.message.receiptStatus
+  if (status === undefined || status === null) {
+    return false
+  }
+  return status !== ImMessageReceiptStatus.NO_RECEIPT
+})
+
+/** 褰撳墠缇ゆ垚鍛橈紙渚� MessageReadStatus 璁$畻鏈鍚嶅崟锛涙湭鍔犺浇瀹屾椂鍏滃簳绌烘暟缁勪笉娓叉煋锛� */
+const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
+  const conversation = conversationStore.activeConversation
+  if (!conversation || conversation.type !== ImConversationType.GROUP) {
+    return []
+  }
+  const group = groupStore.getGroup(conversation.targetId)
+  return (group?.members || []).map((member) => {
+    const friend = friendStore.getFriend(member.userId)
+    return {
+      userId: member.userId,
+      showName: getMemberDisplayName(member, friend),
+      nickname: member.nickname,
+      avatar: member.avatar,
+      status: member.status
+    }
+  })
+})
+
+/** 鏄惁 @鎴戯紙缇ゆ秷鎭睍绀哄皬寰芥爣锛� */
+const isAtMe = computed(() => {
+  const myId = getCurrentUserId()
+  if (!myId) {
+    return false
+  }
+  return (props.message.atUserIds || []).includes(myId)
+})
+
+// ==================== 鍙抽敭鑿滃崟 / 鎿嶄綔 ====================
+
+// 鍙抽敭鑿滃崟 key 甯搁噺锛沺ush 绔拰鍒嗗彂绔粠鍚屼竴澶勫彇锛宼ypo 缂栬瘧鏈熷氨鑳芥姄
+const MENU_KEYS = {
+  COPY: 'COPY',
+  REPLY: 'REPLY',
+  FORWARD: 'FORWARD',
+  MULTI_SELECT: 'MULTI_SELECT',
+  PIN: 'PIN',
+  ADD_TO_FACE: 'ADD_TO_FACE',
+  MUTE: 'MUTE',
+  UNMUTE: 'UNMUTE',
+  KICK: 'KICK',
+  RECALL: 'RECALL',
+  DELETE: 'DELETE'
+} as const
+type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
+
+/**
+ * 鍙抽敭鑿滃崟椤癸細
+ * - 寮曠敤锛氬凡钀藉簱 + 鏈挙鍥炵殑娑堟伅鍙紩鐢紝寮曠敤鍧楀啓鍏ヤ細璇濊崏绋�
+ * - 鎾ゅ洖 / 鍒犻櫎锛氫簰鏂ワ紱鑷繁鍙戦�� + 宸茶惤搴� + 鏈挙鍥� + 2 鍒嗛挓鍐呮樉绀恒�屾挙鍥炪�嶏紙鎺ㄦ湇鍔″櫒锛夛紝鍏跺畠鏄剧ず銆屽垹闄ゃ�嶏紙浠呮湰鍦版竻锛�
+ *
+ * 濂藉弸浜嬩欢姘旀场鎬佷笉寮硅彍鍗�
+ */
+async function handleContextMenu(e: MouseEvent) {
+  if (isFriendChatTipMessage.value) {
+    return
+  }
+  // 澶氶�夋ā寮忎笅涓嶅脊鑿滃崟锛氬彸閿偣鍑诲氨褰撳垏鎹㈠嬀閫夛紝涓庡崟鍑昏涓轰竴鑷�
+  if (isInMultiSelect.value) {
+    if (canForward.value) {
+      multiSelect.toggle(props.message)
+    }
+    return
+  }
+
+  type MenuItem = {
+    danger?: boolean
+    disabled?: boolean
+    divided?: boolean
+    icon?: string
+    key: MenuKey
+    name: string
+  }
+  // 棰戦亾锛堝叕浼楀彿锛変細璇濆崟鍚戞秷鎭細鍙繚鐣欒浆鍙� + 鏈湴鍒犻櫎锛涗笉鏀寔澶嶅埗 / 寮曠敤 / 澶氶�� / 鎾ゅ洖 / 缇ょ鐞嗘搷浣�
+  if (isChannelConversation.value) {
+    const channelItems: MenuItem[] = []
+    if (canForward.value) {
+      channelItems.push({
+        key: MENU_KEYS.FORWARD,
+        name: '杞彂',
+        icon: 'ant-design:share-alt-outlined'
+      })
+    }
+    channelItems.push({
+      key: MENU_KEYS.DELETE,
+      name: '鍒犻櫎',
+      icon: 'ant-design:delete-outlined',
+      divided: true,
+      danger: true
+    })
+    uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, channelItems, async (item) => {
+      if (item.key === MENU_KEYS.FORWARD) {
+        handleForward()
+      } else if (item.key === MENU_KEYS.DELETE) {
+        handleDelete()
+      }
+    })
+    return
+  }
+
+  const items: MenuItem[] = []
+  // 銆屽鍒躲�嶏細浠呮枃鏈秷鎭敮鎸侊紱鏀惧湪绗竴椤癸紝瀵归綈寰俊妗岄潰鍙抽敭涔犳儻
+  if (props.message.type === ImContentType.TEXT) {
+    items.push({
+      key: MENU_KEYS.COPY,
+      name: '澶嶅埗',
+      icon: 'ant-design:copy-outlined'
+    })
+  }
+  // 銆屽紩鐢ㄣ�嶏細宸茶惤搴� + 鏈挙鍥� + 闈炲悎骞惰浆鍙戯紱MERGE 鍐呭祵蹇収鍦ㄥ紩鐢ㄩ瑙堥噷鏃犳硶闄嶇骇灞曠ず
+  if (!!props.message.id && !isRecall.value && !isMerge.value) {
+    items.push({
+      key: MENU_KEYS.REPLY,
+      name: '寮曠敤',
+      icon: 'bxs:quote-alt-left'
+    })
+  }
+  // 銆岃浆鍙戙�嶃�屽閫夈�嶏細宸茶惤搴� + 鏅�氭秷鎭� + 鏈挙鍥烇紱瑙﹀彂 ForwardDialog / 杩涘叆澶氶�夋ā寮�
+  if (canForward.value) {
+    items.push({
+      key: MENU_KEYS.FORWARD,
+      name: '杞彂',
+      icon: 'ant-design:share-alt-outlined'
+    }, {
+      key: MENU_KEYS.MULTI_SELECT,
+      name: '澶氶��',
+      icon: 'ant-design:check-square-outlined'
+    })
+  }
+  // 銆岀疆椤躲�嶏細浠呯兢鑱� + 鏅�氭秷鎭� + 宸茶惤搴� + 鏈挙鍥� + 缇や富鎴栫鐞嗗憳锛涘凡缃《涓嶅啀灞曠ず锛岀敱缃《闈㈡澘鐨勩�岀Щ闄ゃ�嶅叆鍙f壙鎺�
+  // 涓嶆湰鍦伴鍒や笂闄愶紝璁╁悗绔牎楠岋紝瓒呴檺鏃堕�氳繃 error toast 鍙嶉
+  if (canPin.value) {
+    items.push({
+      key: MENU_KEYS.PIN,
+      name: '缃《',
+      icon: 'ant-design:pushpin-outlined'
+    })
+  }
+  // 銆屾坊鍔犲埌琛ㄦ儏銆嶏細FACE / IMAGE 娑堟伅 + 宸茶惤搴� + 鏈挙鍥烇紱鍐欏叆涓汉琛ㄦ儏鍖咃紝瀵圭収寰俊銆屾坊鍔犲埌琛ㄦ儏銆�
+  if (canAddToFace.value) {
+    items.push({
+      key: MENU_KEYS.ADD_TO_FACE,
+      name: '娣诲姞鍒拌〃鎯�',
+      icon: 'ant-design:smile-outlined'
+    })
+  }
+  // 銆岀瑷� / 瑙g / 绉婚櫎銆嶏細缇よ亰 + 闈炶嚜宸辨秷鎭� + 鎴戞槸缇や富鎴栫鐞嗗憳
+  if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
+    const senderMember = currentGroup.value.members?.find(
+      (m) => m.userId === props.message.senderId
+    )
+    const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date()
+    if (isMuted) {
+      items.push({
+        key: MENU_KEYS.UNMUTE,
+        name: '瑙i櫎绂佽█',
+        icon: 'ant-design:audio-outlined',
+        divided: true
+      })
+    } else {
+      items.push({
+        key: MENU_KEYS.MUTE,
+        name: '绂佽█',
+        icon: 'ant-design:audio-muted-outlined',
+        divided: true
+      })
+    }
+    items.push({
+      key: MENU_KEYS.KICK,
+      name: '绉婚櫎',
+      icon: 'ant-design:user-delete-outlined',
+      danger: true
+    })
+  }
+  // 銆屾挙鍥� / 鍒犻櫎銆嶄簩閫変竴锛�
+  // - 鑷繁鍙戦�� + 宸茶惤搴� + 鏈挙鍥� + 鍦ㄦ挙鍥炵獥鍙e唴 鈫� 鎾ゅ洖锛堟帹鏈嶅姟鍣ㄦ妸娑堟伅鎬佺疆 RECALL锛�
+  // - 鍏跺畠锛堝鏂规秷鎭� / 宸叉挙鍥� / 瓒呭嚭鎾ゅ洖绐楀彛锛夆啋 鍒犻櫎锛堜粎鏈湴娓咃紝涓嶅姩鍚庣锛�
+  // divided 鎶婅繖涓�椤瑰拰涓婇潰鐨勩�屽紩鐢ㄣ�嶉殧寮�锛宒anger 鏄剧孩瀵归綈寰俊
+  const canRecall =
+    props.message.selfSend &&
+    !!props.message.id &&
+    !isRecall.value &&
+    Date.now() - props.message.sendTime <= MESSAGE_RECALL_WINDOW_MS
+  if (canRecall) {
+    items.push({
+      key: MENU_KEYS.RECALL,
+      name: '鎾ゅ洖',
+      icon: 'ant-design:undo-outlined',
+      divided: true,
+      danger: true
+    })
+  } else {
+    items.push({
+      key: MENU_KEYS.DELETE,
+      name: '鍒犻櫎',
+      icon: 'ant-design:delete-outlined',
+      divided: true,
+      danger: true
+    })
+  }
+
+  // 鎶婅彍鍗曟覆鏌撲氦缁欏叏灞� uiStore锛堝崟渚嬶紝閬垮厤姣忔潯娑堟伅閮芥寕涓�浠借彍鍗� DOM锛�
+  const menuHandlers: Record<MenuKey, () => Promise<void> | void> = {
+    [MENU_KEYS.COPY]: handleCopy,
+    [MENU_KEYS.REPLY]: handleReply,
+    [MENU_KEYS.FORWARD]: handleForward,
+    [MENU_KEYS.MULTI_SELECT]: handleEnterMultiSelect,
+    [MENU_KEYS.PIN]: handlePin,
+    [MENU_KEYS.ADD_TO_FACE]: handleAddToFace,
+    [MENU_KEYS.MUTE]: handleMute,
+    [MENU_KEYS.UNMUTE]: handleUnmute,
+    [MENU_KEYS.KICK]: handleKick,
+    [MENU_KEYS.RECALL]: handleRecall,
+    [MENU_KEYS.DELETE]: handleDelete
+  }
+  uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
+    await menuHandlers[item.key as MenuKey]?.()
+  })
+}
+
+/** 鏄惁鍙�屾坊鍔犲埌琛ㄦ儏銆嶏細FACE / IMAGE 娑堟伅 + 宸茶惤搴� + 鏈挙鍥烇紙GIF / 闈欏浘閮藉厑璁革級 */
+const canAddToFace = computed(() => {
+  if (isRecall.value || !props.message.id) {
+    return false
+  }
+  return extractAddableFace(props.message) !== null
+})
+
+/** 娣诲姞鍒颁釜浜鸿〃鎯咃細浠� message 鎶� url + 灏哄 + name 鍐欏叆涓汉琛ㄦ儏搴� */
+async function handleAddToFace() {
+  const payload = extractAddableFace(props.message)
+  if (!payload) {
+    return
+  }
+  const data = await faceStore.addFaceUserItem(payload)
+  if (data) {
+    antdMessage.success('宸叉坊鍔犲埌琛ㄦ儏')
+  }
+}
+
+/** 褰撳墠婵�娲讳細璇濆搴旂殑缇わ紙绉佽亰鍦烘櫙涓� undefined锛� */
+const currentGroup = computed(() => {
+  const conversation = conversationStore.activeConversation
+  if (!conversation || conversation.type !== ImConversationType.GROUP) {
+    return undefined
+  }
+  return groupStore.getGroup(conversation.targetId)
+})
+
+/** 褰撳墠鐢ㄦ埛鍦ㄨ缇ら噷鐨勮鑹诧紱绉佽亰鎴栭潪缇ゆ垚鍛� 鈫� undefined */
+const myGroupRole = computed(() => {
+  const myId = getCurrentUserId()
+  return currentGroup.value?.members?.find((m) => m.userId === myId)?.role
+})
+
+/** 鏄惁鍙鐞嗚娑堟伅鍙戦�佷汉锛氭垜鐨勮鑹查珮浜庣洰鏍囪鑹诧紙缇や富 > 绠$悊鍛� > 鏅�氭垚鍛橈級锛涚洰鏍囪鑹叉湭鐭ユ椂涓嶅睍绀� */
+const canManageSender = computed(() => {
+  // 鍘嗗彶閫�缇ょ兢锛氭湰鍦板彲鑳芥畫鐣欐垚鍛樼紦瀛橈紝鏄惧紡鎺掗櫎锛岄伩鍏嶅彸閿粛鍑恒�岀瑷� / 绉婚櫎銆�
+  if (!currentGroup.value || isGroupQuit(currentGroup.value) || !myGroupRole.value) {
+    return false
+  }
+  const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
+  // 鎴愬憳缂撳瓨涓嶅畬鏁存椂涓嶅睍绀虹鐞嗘搷浣滐紝绛夋垚鍛樻暟鎹ˉ榻愬悗鍐嶅垽鏂�
+  if (!senderMember?.role) {
+    return false
+  }
+  // 瑙掕壊鏁板�硷細1=缇や富 2=绠$悊鍛� 3=鏅�氭垚鍛橈紱鏁板�艰秺灏忔潈闄愯秺楂�
+  return myGroupRole.value < senderMember.role
+})
+
+/** 鏄惁鍏佽杞彂 / 澶氶�夛細鏅�氭秷鎭� + 宸茶惤搴� + 鏈挙鍥烇紱鏈湴鍗犱綅 / 鎾ゅ洖 / 浜嬩欢娑堟伅涓�寰嬩笉鍙� */
+const canForward = computed(
+  () => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value
+)
+
+/** 澶氶�夋ā寮忔槸鍚︽縺娲伙紱鍒囨崲浼氳瘽鐢� index.vue 涓诲姩 exitMultiSelect */
+const isInMultiSelect = computed(() => multiSelect.state.active)
+
+/** 褰撳墠娑堟伅鏄惁宸插嬀閫� */
+const isMessageChecked = computed(() => {
+  if (!isInMultiSelect.value) {
+    return false
+  }
+  return multiSelect.selectedIdSet.value.has(props.message.clientMessageId)
+})
+
+/** 澶氶�夋ā寮忎笅鐐瑰嚮姘旀场锛氬彲杞彂鐨勬秷鎭垏鎹㈠嬀閫夛紱浜嬩欢 / 鎾ゅ洖 / 鍗犱綅绛変笉鍝嶅簲锛堢洿鎺ュ悆鎺夌偣鍑婚伩鍏嶈Е鍙戝浘鐗囬瑙堢瓑锛� */
+function handleMultiSelectClick(e: MouseEvent) {
+  if (!isInMultiSelect.value) {
+    return
+  }
+  e.preventDefault()
+  e.stopPropagation()
+  if (!canForward.value) {
+    return
+  }
+  multiSelect.toggle(props.message)
+}
+
+/** 鏄惁鍏佽缃《锛堝凡缃《娑堟伅涓嶅啀灞曠ず鑿滃崟椤癸紝鐢辩疆椤堕潰鏉跨殑銆岀Щ闄ゃ�嶅叆鍙f壙鎺ワ級锛氱兢鑱� + 鏅�氭秷鎭� + 宸茶惤搴� + 鏈挙鍥� + 缇や富鎴栫鐞嗗憳 + 鏈疆椤� */
+const canPin = computed(
+  () =>
+    !!currentGroup.value &&
+    !isGroupQuit(currentGroup.value) &&
+    isNormalMessage(props.message.type) &&
+    !!props.message.id &&
+    !isRecall.value &&
+    (myGroupRole.value === ImGroupMemberRole.OWNER ||
+      myGroupRole.value === ImGroupMemberRole.ADMIN) &&
+    !currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id)
+)
+
+/** 缃《娑堟伅锛氫簩娆$‘璁� 鈫� 璋冨悗绔� pin-message锛涘悗绔箍鎾� GROUP_MESSAGE_PIN锛屾湰绔� dispatcher 鎷夋渶鏂� pinnedMessages */
+async function handlePin() {
+  const group = currentGroup.value
+  if (!group || !props.message.id) {
+    return
+  }
+  try {
+    await confirm('灏嗗湪褰撳墠缇ゆ垚鍛樼殑鑱婂ぉ涓疆椤�', '缃《娑堟伅')
+    await apiPinGroupMessage({ id: group.id, messageId: props.message.id })
+    antdMessage.success('宸茬疆椤�')
+  } catch {}
+}
+
+/** 澶嶅埗鏂囨湰娑堟伅锛氳В鍑� content 瀛楁鍐欏叆鍓创鏉匡紝鎻愮ず銆屽唴瀹瑰凡澶嶅埗鍒板壀璐存澘銆� */
+async function handleCopy() {
+  const text = parseMessage<TextMessage>(props.message.content)?.content
+  if (!text) {
+    return
+  }
+  await copyToClipboard(text)
+  antdMessage.success('鍐呭宸插鍒跺埌鍓创鏉�')
+}
+
+/** 杩涘叆寮曠敤妯″紡锛氭妸褰撳墠娑堟伅鏋勯�犳垚 QuoteMessage 鍐欏叆浼氳瘽鑽夌 */
+function handleReply() {
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  conversationStore.setConversationReplyDraft(conversation, buildQuoteFromMessage(props.message))
+}
+
+/** 杞彂褰撳墠娑堟伅锛氭墦寮� ForwardDialog锛堝崟鏉℃ā寮忥紱mode=single 鍗冲師鏍疯浆锛� */
+function handleForward() {
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  openForwardDialog?.({
+    mode: ImForwardMode.SINGLE,
+    messages: [props.message],
+    sourceConversation: conversation
+  })
+}
+
+/** 杩涘叆澶氶�夋ā寮忥紝鍒濆鍕鹃�夊綋鍓嶆秷鎭� */
+function handleEnterMultiSelect() {
+  multiSelect.enter(props.message)
+}
+
+/**
+ * 鎾ゅ洖娑堟伅锛氬脊纭妗� 鈫� 璋� useMessageSender.recall 鈫� 鍚庣閫氳繃 WS RECALL 浜嬩欢鎺ㄥ洖锛�
+ * websocketStore 鎶婂搴� message 鐨� type 鏀规垚 RECALL锛孶I 鑷姩鍒囧埌"XX 鎾ゅ洖浜嗕竴鏉℃秷鎭�"
+ *
+ * 涓嶅仛涔愯鎾ゅ洖锛氬け璐� / 瓒呮椂 / 鍚庣鎷掔粷鏃舵湰绔姸鎬佸彲鑳戒笌鏈嶅姟绔紓绉伙紝缁熶竴璁� WS 鍥炴帹鏈�绋�
+ */
+async function handleRecall() {
+  try {
+    await confirm('纭畾瑕佹挙鍥炶繖鏉℃秷鎭悧锛�', '鎾ゅ洖娑堟伅')
+    await recall(props.message)
+  } catch {}
+}
+
+/**
+ * 澶辫触娑堟伅鐐瑰嚮閲嶈瘯
+ *
+ * - 濯掍綋娑堟伅锛坕mage / file / voice / video锛夛細_localFile 鍦ㄥ唴瀛樺氨閲嶈蛋 uploadAndSendMedia锛堥噸鏂颁笂浼� + 鍗犱綅 + 杩涘害锛�
+ * - 鏂囨湰娑堟伅锛氬鐢ㄥ師 clientMessageId + status 鍥炴粴鍒� SENDING锛岃蛋 existingClientMessageId 璺緞璁╂湇鍔$鎸� cmid 骞傜瓑
+ *
+ * 濯掍綋绫诲瀷鑻� _localFile 宸蹭涪锛堢悊璁轰笂 IDB 鎭㈠闃舵灏辫 drop锛岃繘涓嶅埌杩欓噷锛涗繚闄╄捣瑙佷粛璧版枃鏈厹搴曪級鍒欐寜鏂囨湰璺緞閲嶅彂锛�
+ * 鍚庣鎷掔粷澶辨晥 blob URL 鏃跺啀娆� FAILED锛岀敤鎴峰彲鍙抽敭鍒犻櫎
+ *
+ * 涓嶈繕鍘熷師 receipt锛氱兢鍥炴墽鏄彂閫佹椂鐨勬墿灞曢�夐」銆佷笉浼氭寔涔呭寲鍒� message锛屽己琛岀寽娴嬪彲鑳戒笌鍘熸剰涓嶇锛�
+ * 榛樿鎸�"鏃犲洖鎵�"閲嶅彂锛岀粷澶у鏁板満鏅鍚堥鏈燂紝瑕佸洖鎵у氨閲嶆柊鍙戜竴娆℃洿鐩磋
+ */
+async function handleResend() {
+  if (props.message.status !== ImMessageStatus.FAILED) {
+    return
+  }
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  // 绂佽█ / 灏佺鏃舵嫤鎴細閬垮厤閲嶈瘯缁曡繃 MessageInput 鐨� muteOverlay 鍙堣蛋涓�娆� sendRaw 璁╁悗绔嫆
+  if (muteOverlay.value) {
+    return
+  }
+  const message = props.message
+  const file = message._localFile
+
+  // 濯掍綋绫诲瀷 + _localFile 鍦� 鈫� 閲嶈蛋 uploadAndSendMedia锛泃ype 鍒嗗彂 + 鏃у厓鏁版嵁澶嶇敤缁熶竴鍦� mediaTypeHandlers 琛ㄩ噷
+  if (isMediaMessageType(message.type) && file) {
+    const handler = mediaTypeHandlers[message.type]
+    if (handler) {
+      const oldQuote = getQuoteFromMessage(message.content) ?? undefined
+      const context = handler.extractResendContext(message.content)
+      await uploadAndSendMedia({
+        file,
+        type: message.type,
+        quote: oldQuote,
+        conversation,
+        context,
+        existingClientMessageId: message.clientMessageId
+      })
+      return
+    }
+  }
+
+  // 鏂囨湰绫诲瀷 / 濯掍綋绫诲瀷浣� _localFile 宸蹭涪锛氭妸 FAILED 鍗犱綅鍥炴粴鍒� SENDING锛屽鐢� clientMessageId 璁╂湇鍔$鎸� cmid 骞傜瓑鍘婚噸
+  messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
+    status: ImMessageStatus.SENDING
+  })
+  await sendRaw(message.type, message.content, {
+    atUserIds: message.atUserIds,
+    existingClientMessageId: message.clientMessageId
+  })
+}
+
+/**
+ * 鍒犻櫎娑堟伅锛氭湰鍦拌蒋鍒狅紝浠呬粠 messageStore 绉婚櫎锛屼笉璋冨悗绔�
+ * 鍖哄埆浜�"鎾ゅ洖"锛氭湇鍔$娌″姩锛屽绔櫥褰曟椂鍏跺畠瀹㈡埛绔� / 缇ら噷鍏朵粬浜轰緷鐒惰兘鐪嬪埌杩欐潯
+ */
+/** 绂佽█锛歟mit 缁欑埗缁勪欢鎵撳紑鏃堕暱閫夋嫨寮圭獥锛堥伩鍏� MessageItem 杩囬噸锛� */
+function handleMute() {
+  const group = currentGroup.value
+  if (!group) {
+    return
+  }
+  const name = senderDisplayName.value || '璇ユ垚鍛�'
+  emit('mute', group.id, props.message.senderId, name)
+}
+
+/** 瑙i櫎绂佽█ */
+async function handleUnmute() {
+  const group = currentGroup.value
+  if (!group) {
+    return
+  }
+  try {
+    await confirm('纭畾瑙i櫎璇ユ垚鍛樼殑绂佽█鍚楋紵', '瑙i櫎绂佽█')
+    await cancelMuteMember({ id: group.id, userId: props.message.senderId })
+    antdMessage.success('宸茶В闄ょ瑷�')
+    emit('reload')
+  } catch {}
+}
+
+/** 绉婚櫎缇ゆ垚鍛� */
+async function handleKick() {
+  const group = currentGroup.value
+  if (!group) {
+    return
+  }
+  const name = senderDisplayName.value || '璇ユ垚鍛�'
+  try {
+    await confirm(`纭畾灏嗐��${name}銆嶇Щ鍑虹兢鑱婂悧锛焋, '绉婚櫎鎴愬憳')
+    await removeGroupMember({ groupId: group.id, memberUserIds: [props.message.senderId] })
+    antdMessage.success('宸茬Щ闄�')
+    emit('reload')
+  } catch {}
+}
+
+function handleDelete() {
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  messageStore.removeMessage(conversation.type, conversation.targetId, {
+    id: props.message.id,
+    clientMessageId: props.message.clientMessageId
+  })
+}
+</script>
+
+<template>
+  <!-- 鏃堕棿鍒嗛殧鏉★細鍒楄〃绗竴鏉� / 璺濅笂涓�鏉¤秴杩囬槇鍊兼椂灞呬腑鏄剧ず鐏拌壊鏃堕棿 -->
+  <div
+    v-if="shouldShowTimeTip"
+    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-disabled)]"
+  >
+    {{ formatTimeTip(message.sendTime) }}
+  </div>
+
+  <!-- 濂藉弸浼氳瘽浜嬩欢锛團RIEND_ADD / FRIEND_DELETE锛夛細璺熺兢骞挎挱浜嬩欢鍚岀伆鑹叉牱寮忥紝鏂囨鍥哄畾 -->
+  <div
+    v-if="isFriendChatTipMessage"
+    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
+  >
+    <TipSegments :segments="friendChatTipSegments" />
+  </div>
+
+  <!-- 缇ゅ箍鎾簨浠讹細璺熷ソ鍙嬩簨浠跺悓鐏拌壊鏍峰紡锛宮ention 娈垫寕鐐瑰嚮寮� UserInfoCard -->
+  <div
+    v-else-if="isGroupNotificationMessage"
+    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
+  >
+    <TipSegments :segments="groupNotificationSegments" />
+  </div>
+
+  <!-- 缇ら�氳瘽浜嬩欢锛圧TC_CALL_START / RTC_CALL_END锛夛細灞呬腑鐏拌壊 tip 涓ゆ寮� -->
+  <div
+    v-else-if="isRtcCallTipMessage"
+    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
+  >
+    <TipSegments :segments="rtcCallTipSegments" />
+  </div>
+
+  <!-- 绉佽亰閫氳瘽缁撴潫锛圧TC_CALL_END锛夛細浠垮井淇°�屽噯姘旀场銆嶏紱鎸� selfSend 宸﹀彸鍒嗗竷锛涚數璇濆浘鏍� + 鏂囨 -->
+  <div v-else-if="isRtcCallPrivateBubbleMessage" class="flex gap-2 items-start px-4 py-2">
+    <div
+      class="flex flex-1 min-w-0 gap-2 items-start"
+      :class="{ 'flex-row-reverse': message.selfSend }"
+    >
+      <UserAvatar
+        :id="message.selfSend ? getCurrentUserId() : message.senderId"
+        :name="senderRealNickname"
+        :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar"
+        :size="36"
+      />
+      <div
+        class="flex gap-2 items-center px-3.5 py-2 text-sm rounded-lg cursor-pointer"
+        :class="
+          message.selfSend
+            ? 'text-black bg-[#95ec69]'
+            : 'text-[var(--ant-color-text)] bg-[var(--ant-color-fill-secondary)]'
+        "
+        @click="handleRtcCallBubbleClick"
+      >
+        <Icon icon="ant-design:phone-outlined" :size="16" class="rotate-[135deg] flex-shrink-0" />
+        <span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span>
+      </div>
+    </div>
+  </div>
+
+  <!-- 鎾ゅ洖娑堟伅锛氭暣琛岀伆鑹� tip锛宻ender 鍚嶆鍙偣鍑� -->
+  <div
+    v-else-if="isRecall"
+    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
+  >
+    <TipSegments :segments="recallTipSegments" />
+  </div>
+
+  <!-- 鏅�氭秷鎭皵娉� -->
+  <div
+    v-else
+    class="flex gap-2 items-start px-4 py-2"
+    :class="{ 'cursor-pointer': isInMultiSelect }"
+    @contextmenu.prevent="handleContextMenu"
+    @click.capture="handleMultiSelectClick"
+  >
+    <!-- 澶氶�夋寚绀猴細姘歌繙鍦ㄦ暣琛屾渶宸︿晶锛堣嚜宸� / 瀵规柟娑堟伅涓�鑷达級锛屼笉鍙備笌 selfSend reverse -->
+    <span
+      v-if="isInMultiSelect"
+      class="flex flex-shrink-0 items-center justify-center mt-3 w-5 h-5 rounded-full transition-colors"
+      :class="
+        isMessageChecked
+          ? 'bg-[#07c160]'
+          : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
+      "
+    >
+      <Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" />
+    </span>
+
+    <!-- 娑堟伅琛岋細澶村儚 + 姘旀场鍐呴儴鎸� selfSend reverse锛涢閬撶礌鏉愪豢鍏紬鍙锋牱寮忓眳涓笖涓嶆樉绀哄ご鍍� -->
+    <div
+      class="flex flex-1 min-w-0 gap-2 items-start"
+      :class="{ 'flex-row-reverse': message.selfSend, 'justify-center': isMaterial }"
+    >
+      <!-- 澶村儚锛氱偣鍑诲脊 UserInfoCard 鐢� UserAvatar 鍐呴儴鎵挎帴锛涢閬撶礌鏉愭秷鎭笉鏄剧ず澶村儚 -->
+      <UserAvatar
+        v-if="!isMaterial"
+        :id="message.selfSend ? getCurrentUserId() : message.senderId"
+        :name="senderRealNickname"
+        :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar"
+        :size="36"
+      />
+
+      <div
+        class="flex flex-col gap-0.5"
+        :class="[
+          message.selfSend ? 'items-end' : '',
+          // 鍏紬鍙蜂細璇濆唴绱犳潗锛氬浐瀹� 360 瀹斤紙瀵归綈寰俊鍏紬鍙峰崱鐗囷級锛涘叾瀹冿紙鍚鑱� / 缇よ亰杞彂鐨勭礌鏉愶級锛�70% 涓婇檺锛屾皵娉¤嚜宸辨拺瀹藉害
+          isMaterial ? 'w-[360px]' : 'max-w-[70%]'
+        ]"
+      >
+        <!-- 缇よ亰瀵规柟娑堟伅锛氭皵娉′笂鏂规樉绀哄彂閫佽�呮樀绉� -->
+        <div
+          v-if="showSenderName"
+          class="mb-0.5 text-12px text-[var(--ant-color-text-secondary)] leading-tight"
+        >
+          {{ senderDisplayName }}
+        </div>
+        <div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
+          <!-- 娑堟伅鍐呭锛氭寜 type 璧� 9 绫绘皵娉★紝缁熶竴鐢� MessageBubble 娓叉煋 -->
+          <MessageBubble
+            :type="message.type"
+            :content="message.content"
+            :self-send="message.selfSend"
+            :upload-progress="message.uploadProgress"
+            :mentions="textMentions"
+            @click-card="handleCardClick"
+            @open-merge="handleMergeOpen"
+          />
+
+          <!-- 鐘舵�佸尯锛氳嚜宸辨秷鎭睍绀哄彂閫佺姸鎬� + 宸茶/缇ゅ洖鎵э紱瀵规柟娑堟伅 + @鑷繁鏃跺睍绀� @寰芥爣 -->
+          <div class="flex gap-1.5 items-center text-base">
+            <template v-if="message.selfSend">
+              <Icon
+                v-if="showSendingLoading"
+                icon="ant-design:loading-outlined"
+                class="im-loading-spin"
+              />
+              <Icon
+                v-else-if="message.status === ImMessageStatus.FAILED"
+                icon="ant-design:warning-filled"
+                color="#f56c6c"
+                class="cursor-pointer"
+                title="鍙戦�佸け璐ワ紝鐐瑰嚮閲嶈瘯"
+                @click="handleResend"
+              />
+              <!-- 宸茶鎬侊紙绉佽亰锛� -->
+              <span
+                v-else-if="privateReadLabel"
+                class="text-12px whitespace-nowrap"
+                :class="
+                  message.receiptStatus === ImMessageReceiptStatus.DONE
+                    ? 'text-[#409eff]'
+                    : 'text-[var(--ant-color-text-secondary)]'
+                "
+              >
+                {{ privateReadLabel }}
+              </span>
+              <!-- 缇ゅ洖鎵э細鐐瑰嚮寮� popover 灞曠ず宸茶 / 鏈鎴愬憳鍒楄〃 -->
+              <MessageReadStatus
+                v-else-if="showGroupReadStatus"
+                :message="message"
+                :group-id="conversationStore.activeConversation?.targetId || 0"
+                :group-members="groupMembersForReadStatus"
+                class="text-12px whitespace-nowrap text-[var(--ant-color-text-secondary)]"
+              />
+            </template>
+            <!-- 缇� @ 鎴戞彁绀� -->
+            <Tag
+              v-if="!message.selfSend && isAtMe"
+              danger
+              size="small"
+              class="ml-1"
+            >
+              @鎴�
+            </Tag>
+          </div>
+        </div>
+        <!-- 寮曠敤鍧楋細姘旀场涓嬫柟锛宻elfSend 鏃剁珫绾垮湪鍙充晶 -->
+        <ReplyPreview
+          v-if="quote"
+          :quote="quote"
+          clickable
+          :mirrored="message.selfSend"
+          class="max-w-[280px]"
+          @locate="emit('locate', $event)"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+/* @keyframes 闇�瑕� SCSS 澹版槑锛汼ENDING 鐘舵�佺殑杞湀鍔ㄧ敾 */
+.im-loading-spin {
+  animation: im-loading-spin 1s linear infinite;
+}
+@keyframes im-loading-spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

--
Gitblit v1.9.3