gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/message/message-history.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,807 @@
<script lang="ts" setup>
import type { Message } from '#/views/im/home/types'
import { computed, inject, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useUserStore } from '#/packages/stores/src'
import { Button, Calendar, Input, Modal, Popover, Tag } from 'ant-design-vue'
import dayjs from 'dayjs'
import { getGroupMessageList as apiGetGroupMessageList } from '#/api/im/message/group'
import { getPrivateMessageList as apiGetPrivateMessageList } from '#/api/im/message/private'
import { useMessagePuller } from '#/views/im/home/composables/useMessagePuller'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import {
  ImContentType,
  ImConversationType,
  isFriendChatTip,
  isGroupNotification
} from '#/views/im/utils/constants'
import {
  buildFacePreviewText,
  buildRecallTip,
  buildRecallTipSegments,
  getConversationKey
} from '#/views/im/utils/conversation'
import { getClientConversationId } from '#/views/im/utils/db'
import {
  resolveFriendNotificationSegments,
  resolveFriendNotificationText,
  resolveGroupNotificationSegments
} from '#/views/im/utils/message'
import {
  type CardMessage,
  type FaceMessage,
  type FileMessage,
  getCardLabelInfo,
  type MergeMessage,
  parseMessage,
  type TextMessage
} from '#/views/im/utils/message'
import { formatHistoryTime } from '#/views/im/utils/time'
import {
  getMemberDisplayName,
  getSenderDisplayName,
  getSenderRealNickname
} from '#/views/im/utils/user'
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
import MessageBubble from './message-bubble.vue'
import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageHistory' })
const emit = defineEmits<{
  // åŽ†å²æ¶ˆæ¯è¡Œä¸Šçš„"定位"按钮:通知父组件 MessagePanel æ»šåˆ°å¯¹åº”消息位置 + å…³æŽ‰è‡ªå·±
  locate: [messageId: number]
}>()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
const voicePlayer = useVoicePlayer()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const visible = ref(false)
defineExpose({
  /** æ‰“开历史消息抽屉 */
  open() {
    visible.value = true
  }
})
const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const allMessages = computed<Message[]>(() =>
  conversation.value
    ? messageStore.getMessages(
        getClientConversationId(conversation.value.type, conversation.value.targetId)
      )
    : []
)
/** å•条消息的发送人显示名:渲染时按 conversation ä¸Šä¸‹æ–‡èµ° WeChat ä¼˜å…ˆçº§å®žæ—¶ç®— */
function senderDisplayNameOf(message: Message): string {
  return getSenderDisplayName(
    message.senderId,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
/** ç¾¤å¹¿æ’­äº‹ä»¶ segments çš„æˆå‘˜åè§£æžå™¨ï¼›æŒ‰å½“前会话 targetId èµ° getSenderDisplayName */
function resolveGroupMemberName(message: Message): (userId: number) => string {
  return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
}
/** å•条消息的发送人真实昵称:给 UserAvatar è‰²å¡ / alt ç”¨ï¼Œæ°¸è¿œæ˜¯ nickname ä¸æŽºå¤‡æ³¨ */
function senderRealNicknameOf(message: Message): string {
  return getSenderRealNickname(
    message.senderId,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
/** å•条撤回消息的 tip æ–‡æ¡ˆï¼šbuildRecallTip å†…部按 conversation ä¸Šä¸‹æ–‡å®žæ—¶ç®— sender å */
function recallTipOf(message: Message): string {
  return buildRecallTip(
    message.senderId,
    message.selfSend,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
/** å•条撤回消息的 tip segments:sender åæ®µæŒ‚可点击 mention */
function recallTipSegmentsOf(message: Message) {
  return buildRecallTipSegments(
    message.senderId,
    message.selfSend,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
// ==================== æ ‡é¢˜ ====================
/**
 * å¼¹çª—标题:参考微信 PC
 * - ç§èŠï¼šä¸Ž"X"的聊天记录
 * - ç¾¤èŠï¼š"X ç¾¤"的聊天记录(N)
 */
const title = computed(() => {
  if (!conversation.value) {
    return '聊天记录'
  }
  const name = conversation.value.name
  if (isGroup.value) {
    return `"${name}"的聊天记录(${allMessages.value.length})`
  }
  return `与"${name}"的聊天记录`
})
// ==================== æœç´¢ + ç­›é€‰ chip ====================
/** å½“前激活的筛选条件 â€”— å• chip æ¨¡å¼ï¼ˆåŒæ—¶åª 1 ä¸ªï¼‰ï¼Œç‚¹å‡» tab è½æ–°å€¼ï¼ŒÃ— æ¸…空 */
type ActiveFilter =
  | { day: string; kind: 'date'; } // YYYY-MM-DD
  | { kind: 'file' }
  | { kind: 'image' }
  | { kind: 'member'; nickname: string; userId: number; }
  | { kind: 'voice' }
const keyword = ref('')
const activeFilter = ref<ActiveFilter | null>(null)
/** chip æ–‡æ¡ˆï¼šæ—¥æœŸ / ç¾¤æˆå‘˜ å¤šå¸¦å€¼ï¼Œå…¶ä»–直接 tab å */
const filterChipLabel = computed(() => {
  if (!activeFilter.value) {
    return ''
  }
  switch (activeFilter.value.kind) {
    case 'date': {
      return `日期:${activeFilter.value.day}`
    }
    case 'file': {
      return '文件'
    }
    case 'image': {
      return '图片'
    }
    case 'member': {
      return `@${activeFilter.value.nickname}`
    }
    case 'voice': {
      return '语音'
    }
    default: {
      return ''
    }
  }
})
/** ç‚¹ tab è½ç­›é€‰ï¼›åŒ kind é‡å¤ç‚¹å‡» â†’ å½“ toggle å…³æŽ‰ï¼ˆé¿å…è¿·æƒ‘) */
function setFilter(filter: ActiveFilter) {
  if (
    activeFilter.value?.kind === filter.kind &&
    filter.kind !== 'date' &&
    filter.kind !== 'member'
  ) {
    activeFilter.value = null
    return
  }
  activeFilter.value = filter
}
/** chip Ã— å…³é—­ / é‡ç½® */
function clearFilter() {
  activeFilter.value = null
}
// ==================== æ—¥æœŸ popover ====================
const datePopoverVisible = ref(false)
const datePickerValue = ref(dayjs())
/** æ—¥æœŸ popover ç¡®å®šï¼šæŠŠ Date â†’ YYYY-MM-DD è½åˆ° activeFilter,关 popover */
function onDateConfirm() {
  if (!datePickerValue.value) {
    datePopoverVisible.value = false
    return
  }
  const day = datePickerValue.value.format('YYYY-MM-DD')
  activeFilter.value = { kind: 'date', day }
  datePopoverVisible.value = false
}
// ==================== ç¾¤æˆå‘˜ popover ====================
const memberPopoverVisible = ref(false)
const memberSearchKeyword = ref('')
/** ç¾¤æˆå‘˜ picker åˆ—表:从 groupStore æ‹‰ + é€‚配 GroupMemberLite + å…³é”®å­—过滤 */
const filteredMembersForPicker = computed<GroupMemberLite[]>(() => {
  if (!isGroup.value || !conversation.value) {
    return []
  }
  const group = groupStore.getGroup(conversation.value.targetId)
  const all = (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 trimmedKeyword = memberSearchKeyword.value.trim()
  if (!trimmedKeyword) {
    return all
  }
  return all.filter((member) => member.showName.includes(trimmedKeyword))
})
/** ç¾¤æˆå‘˜ picker é€‰æ‹©ï¼šè½ activeFilter + å…³ popover + æ¸…搜索词 */
function onMemberSelect(member: GroupMemberLite) {
  activeFilter.value = {
    kind: 'member',
    userId: member.userId,
    nickname: member.showName
  }
  memberPopoverVisible.value = false
  memberSearchKeyword.value = ''
}
// ==================== åˆ—表过滤 ====================
/** activeFilter å‘½ä¸­ï¼šé»˜è®¤æ— ç­›é€‰æ—¶å…¨éƒ¨å‘½ä¸­ */
function matchesActiveFilter(message: Message): boolean {
  if (!activeFilter.value) {
    return true
  }
  switch (activeFilter.value.kind) {
    case 'date': {
      return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day
    }
    case 'file': {
      return message.type === ImContentType.FILE
    }
    case 'image': {
      return message.type === ImContentType.IMAGE
    }
    case 'member': {
      return message.senderId === activeFilter.value.userId
    }
    case 'voice': {
      return message.type === ImContentType.VOICE
    }
    default: {
      return true
    }
  }
}
/**
 * å½“前列表:先 activeFilter è¿‡æ»¤ã€å† keyword æ¨¡ç³Šå‘½ä¸­ã€æœ€åŽ reverse(最新在前)
 *
 * å…³é”®å­—命中走 textSnippetOf â€”— æ–‡æœ¬æ‹¿åŽŸæ–‡ã€åª’ä½“æ‹¿"[图片]"等占位词、文件拿文件名
 */
const currentList = computed<Message[]>(() => {
  const trimmedKeyword = keyword.value.trim()
  let list = allMessages.value.filter((message) => matchesActiveFilter(message))
  if (trimmedKeyword) {
    list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword))
  }
  return list.toReversed()
})
// ==================== åŠ è½½æ›´æ—©æ¶ˆæ¯ ====================
const HISTORY_PAGE_SIZE = 50
const loadingMore = ref(false)
const hasMore = ref(true)
/**
 * åŠ è½½æ›´æ—©æ¶ˆæ¯ï¼šæ‹¿å½“å‰æœ€æ—©ä¸€æ¡ id ä½œ maxId(不含),调 list æŽ¥å£æ‹‰ä¸€é¡µ + convert + prepend
 *
 * - æœªå¯¹æŽ¥ list æŽ¥å£çš„ type / keyword / sender è¿‡æ»¤å‚数:后端只支持 maxId + limit æ¸¸æ ‡åˆ†é¡µï¼Œ
 *   tab ç­›é€‰åœ¨å‰ç«¯åšï¼ˆæ•°æ®æ¥å›žåˆ°æœ¬åœ°åŽè¿‡æ»¤ï¼‰
 * - æœ¬åœ°å ä½è·³è¿‡ï¼šåŽç«¯æ²¡æ³•按 messageId æŸ¥
 * - è¿”回数量 < limit è§†ä¸ºåˆ°é¡¶
 */
async function loadEarlier() {
  // é‡å…¥ / åˆ°é¡¶ / æ— ä¼šè¯ æ—©é€€ï¼šé¿å…é‡å¤è¯·æ±‚或在 conversation åˆ‡æ¢é—´éš™è§¦å‘
  if (loadingMore.value || !hasMore.value || !conversation.value) {
    return
  }
  // ä»… PRIVATE / GROUP èµ°åˆ†é¡µæŽ¥å£ï¼›CHANNEL å•向广播、没有 list æŽ¥å£ï¼Œè½åˆ° else ä¼šè¯¯è°ƒç§èŠæŽ¥å£ï¼ˆreceiverId ä¼  channelId)
  const requestedType = conversation.value.type
  if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
    return
  }
  // å¿«ç…§å½“前会话主键:await æœŸé—´ç”¨æˆ·åˆ‡èµ° / å…³é—­é¢æ¿æ—¶ä¸¢å¼ƒå“åº”,避免旧会话历史被 prepend åˆ°æ–°ä¼šè¯é€ æˆä¸²å·
  const requestedKey = getConversationKey(conversation.value)
  const requestedTargetId = conversation.value.targetId
  const requestedIsGroup = requestedType === ImConversationType.GROUP
  loadingMore.value = true
  try {
    // ç®— maxId(不含,作为后端游标):取当前会话本地缓存里最早一条服务端 id;
    // æœ¬åœ°ä¹è§‚占位消息没有服务端 id,要剔除
    // å…¨æ˜¯å ä½ / åˆ—表为空时 reduce ä¸æ›´æ–°åˆå€¼ï¼ˆPOSITIVE_INFINITY),转成 undefined â†’ åŽç«¯ä»Žæœ€æ–°æ‹‰
    let earliestId = Number.POSITIVE_INFINITY
    for (const message of allMessages.value) {
      if (message.id && message.id > 0) {
        earliestId = Math.min(earliestId, message.id)
      }
    }
    const maxId = Number.isFinite(earliestId) ? earliestId : undefined
    // è°ƒåŽç«¯ list æŽ¥å£ï¼šç§èŠ / ç¾¤èŠæŽ¥å£ç­¾åä¸åŒï¼Œåˆ†æ”¯è°ƒåº¦ï¼›è¿”回结果用 useMessagePuller
    // æš´éœ²çš„ convert å‡½æ•°è½¬æˆæœ¬åœ° Message(与 puller åŒä¸€ä»½å­—段映射,避免分歧)
    let earlier: Message[] = []
    let pageLength = 0
    if (requestedIsGroup) {
      const list = await apiGetGroupMessageList({
        groupId: requestedTargetId,
        maxId,
        limit: HISTORY_PAGE_SIZE
      })
      earlier = (list || []).map((message) => convertGroupMessage(message))
      pageLength = list?.length ?? 0
    } else {
      const list = await apiGetPrivateMessageList({
        receiverId: requestedTargetId,
        maxId,
        limit: HISTORY_PAGE_SIZE
      })
      earlier = (list || []).map((message) => convertPrivateMessage(message))
      pageLength = list?.length ?? 0
    }
    // await æœŸé—´ active å¯èƒ½è¢«å¤–部置 null / æ¢ä¸»é”®ï¼šç›´æŽ¥ä¸¢å¼ƒå“åº”;不更新 hasMore(旧会话到顶不代表新会话到顶)也不 prepend
    if (!conversation.value || getConversationKey(conversation.value) !== requestedKey) {
      return
    }
    // è¿”回数量 < limit è§†ä¸ºåˆ°é¡¶ â€”— å…³é—­"加载更早"按钮,避免后续点击空跑接口
    if (pageLength < HISTORY_PAGE_SIZE) {
      hasMore.value = false
    }
    // åˆå¹¶åˆ° messageStore:prependMessageList å†…部去重 + å‡åºåˆå¹¶ + è½ IndexedDB;
    // ä¸»èŠå¤©é¢æ¿çš„ messages æ˜¯åŒä¸€ä»½å¼•用,老消息也会一起出现在主面板里(符合预期)
    messageStore.prependMessageList(requestedType, requestedTargetId, earlier)
  } finally {
    loadingMore.value = false
  }
}
// ==================== å¼¹çª—打开 reset ====================
/** å¼¹çª—打开时把上次的 chip / æœç´¢ / åŠ è½½çŠ¶æ€éƒ½æ¸…å¹²å‡€ï¼Œé¿å…ä¸Šæ¬¡çš„çŠ¶æ€æ®‹ç•™è¿·æƒ‘ */
function onDialogOpen() {
  activeFilter.value = null
  keyword.value = ''
  hasMore.value = true
  datePopoverVisible.value = false
  memberPopoverVisible.value = false
  memberSearchKeyword.value = ''
  datePickerValue.value = dayjs()
  // æœ¬åœ°æ— æ¶ˆæ¯æ—¶ç«‹å³æ‹‰ä¸€æ¬¡ï¼ˆmaxId=undefined ä»Žæœ€æ–°å¼€å§‹ï¼‰ï¼Œé¿å…æ–°è®¾å¤‡ / ç¼“存清后弹窗只显示"暂无消息"
  if (allMessages.value.length === 0 && hasMore.value && conversation.value) {
    void loadEarlier()
  }
}
/** æŠ½å±‰å…³é—­æ—¶å¤ä½ + åœè¯­éŸ³ */
watch(visible, (value) => {
  if (!value) {
    activeFilter.value = null
    keyword.value = ''
    voicePlayer.stop()
  }
})
/**
 * æŠ½å±‰å¼€ç€æ—¶å¤–部切了 active conversation:dialog çš„ title / åˆ—表 / isGroup å…¨éƒ¨è·Ÿç€æ–° conversation èµ°ï¼Œ
 * è¿™é‡ŒæŠŠåˆ†é¡µæ€ä¸€å¹¶é‡ç½®ï¼›å¦åˆ™æ—§ä¼šè¯æ®‹ç•™çš„ loadingMore=true / hasMore=false ä¼šè®©æ–°ä¼šè¯"加载更早"按钮失效
 */
watch(conversation, () => {
  loadingMore.value = false
  hasMore.value = true
})
// ==================== helper ====================
/** å–头像 url:自己用 userStore,群里查 groupStore æˆå‘˜ï¼Œç§èŠç”¨ conversation.avatar */
function getAvatar(message: Message): string {
  if (message.selfSend) {
    return userStore.userInfo?.avatar || ''
  }
  if (!conversation.value) {
    return ''
  }
  if (isGroup.value) {
    const group = groupStore.getGroup(conversation.value.targetId)
    return group?.members?.find((member) => member.userId === message.senderId)?.avatar || ''
  }
  return conversation.value.avatar || ''
}
/** å…³é”®å­—命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
  if (isFriendChatTip(message.type)) {
    return resolveFriendNotificationText(message)
  }
  switch (message.type) {
    case ImContentType.CARD: {
      const card = parseMessage<CardMessage>(message.content)
      return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}`
    }
    case ImContentType.FACE: {
      return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
    }
    case ImContentType.FILE: {
      return parseMessage<FileMessage>(message.content)?.name ?? '[文件]'
    }
    case ImContentType.IMAGE: {
      return '[图片]'
    }
    case ImContentType.MERGE: {
      const merge = parseMessage<MergeMessage>(message.content)
      return merge?.title ?? '[聊天记录]'
    }
    case ImContentType.RECALL: {
      return recallTipOf(message)
    }
    case ImContentType.TEXT: {
      return parseMessage<TextMessage>(message.content)?.content ?? ''
    }
    case ImContentType.VIDEO: {
      return '[视频]'
    }
    case ImContentType.VOICE: {
      return '[语音]'
    }
    default: {
      return ''
    }
  }
}
/**
 * å®šä½åˆ°èŠå¤©ä½ç½®ï¼šemit ç»™ MessagePanel èµ° scrollIntoView + çŸ­æš‚高亮,再关掉自己
 * messageId === 0(本地占位消息)跳过——还没拿到真实 id,DOM ä¸Šæ²¡æ³• querySelector
 */
function locateMessage(messageId: number) {
  if (!messageId) {
    return
  }
  emit('locate', messageId)
  visible.value = false
}
</script>
<template>
  <!--
    åŽ†å²æ¶ˆæ¯å¼¹çª—ï¼ˆå¯¹é½å¾®ä¿¡ PC)
    - å±…中 dialog,title "与「X」聊天记录" / "「X ç¾¤ã€èŠå¤©è®°å½•(N)"
    - é»˜è®¤æ— ç­›é€‰ï¼Œå…¨é‡å€’序展示;点击 tab è½ç­›é€‰ â†’ æœç´¢æ¡†å·¦ä¾§å‡º chip Ã— å¯æ¸…
    - æ–‡ä»¶ / å›¾ç‰‡ / é“¾æŽ¥ï¼šç›´æŽ¥è½ç­›é€‰ï¼›æ—¥æœŸ / ç¾¤æˆå‘˜ï¼šå¼¹ popover äºŒæ¬¡é€‰æ‹©å†è½
    - "加载更早消息":列表底部按钮,点击调 /im/message/{private,group}/list æ‹¿ä¸€é¡µ + prepend
  -->
  <Modal
    v-model:open="visible"
    :title="title"
    width="640px"
    :footer="null"
    class="im-message-history__dialog"
    @after-open-change="(open: boolean) => open && onDialogOpen()"
  >
    <div class="flex flex-col gap-3 h-[520px]">
      <!-- æœç´¢åŒºï¼šactiveFilter å­˜åœ¨æ—¶å·¦ä¾§å‡º chip Ã— å¯æ¸…,否则纯搜索框 -->
      <div
        class="im-message-history__searchbar flex items-center gap-2 px-3 py-1.5 rounded-md bg-[var(--ant-color-fill-secondary)]"
      >
        <Tag
          v-if="activeFilter"
          closable
          size="small"
          class="im-message-history__chip flex-shrink-0"
          @close="clearFilter"
        >
          {{ filterChipLabel }}
        </Tag>
        <Icon
          v-else
          icon="ant-design:search-outlined"
          class="text-[var(--ant-color-text-secondary)]"
        />
        <input
          v-model="keyword"
          type="text"
          placeholder="搜索"
          class="im-message-history__search-input flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-[var(--ant-color-text)]"
        />
      </div>
      <!-- Tab è¡Œï¼šæ–‡ä»¶ / å›¾ç‰‡ / è¯­éŸ³ / æ—¥æœŸ(popover) / ç¾¤æˆå‘˜(popover, ä»…群聊);底部一条分割线把 tab åŒºè·Ÿæ¶ˆæ¯åˆ—表分开,对齐微信观感 -->
      <div
        class="flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
      >
        <span
          class="im-message-history__tab cursor-pointer"
          :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'file' }"
          @click="setFilter({ kind: 'file' })"
        >
          æ–‡ä»¶
        </span>
        <span
          class="im-message-history__tab cursor-pointer"
          :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'image' }"
          @click="setFilter({ kind: 'image' })"
        >
          å›¾ç‰‡
        </span>
        <span
          class="im-message-history__tab cursor-pointer"
          :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'voice' }"
          @click="setFilter({ kind: 'voice' })"
        >
          è¯­éŸ³
        </span>
        <!-- æ—¥æœŸï¼šel-popover åŒ… el-calendar,确认后才落筛选 -->
        <Popover
          v-model:open="datePopoverVisible"
          trigger="click"
          placement="bottom"
          :overlay-style="{ width: '320px' }"
        >
          <span
            class="im-message-history__tab cursor-pointer"
            :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'date' }"
          >
            æ—¥æœŸ
          </span>
          <template #content>
            <div class="im-message-history__date-panel">
              <div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]">
                é€‰æ‹©å‘送日期
              </div>
              <Calendar
                v-model:value="datePickerValue"
                :fullscreen="false"
                class="im-message-history__calendar"
              />
              <div class="flex gap-2 justify-end px-2 pt-2">
                <Button size="small" @click="datePopoverVisible = false">取消</Button>
                <Button size="small" type="primary" @click="onDateConfirm">确定</Button>
              </div>
            </div>
          </template>
        </Popover>
        <!-- ç¾¤æˆå‘˜ï¼šä»…群聊;popover å†…自带搜索 + GroupMember åˆ—表 -->
        <Popover
          v-if="isGroup"
          v-model:open="memberPopoverVisible"
          trigger="click"
          placement="bottom"
          :overlay-style="{ width: '320px' }"
        >
          <span
            class="im-message-history__tab cursor-pointer"
            :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'member' }"
          >
            ç¾¤æˆå‘˜
          </span>
          <template #content>
            <div>
              <Input v-model:value="memberSearchKeyword" placeholder="搜索群成员" size="small">
                <template #prefix>
                  <Icon icon="ant-design:search-outlined" />
                </template>
              </Input>
              <div class="max-h-[360px] overflow-y-auto mt-2">
                <GroupMember
                  v-for="member in filteredMembersForPicker"
                  :key="member.userId"
                  :member="member"
                  :height="44"
                  :clickable="false"
                  class="cursor-pointer hover:bg-[var(--ant-color-fill)]"
                  @click="onMemberSelect(member)"
                />
                <div
                  v-if="filteredMembersForPicker.length === 0"
                  class="py-6 text-12px text-center text-[var(--ant-color-text-disabled)]"
                >
                  æ²¡æœ‰åŒ¹é…çš„æˆå‘˜
                </div>
              </div>
            </div>
          </template>
        </Popover>
      </div>
      <!-- æ¶ˆæ¯åˆ—表 -->
      <div class="flex-1 overflow-y-auto">
        <template v-for="message in currentList" :key="message.id || message.clientMessageId">
          <!-- å¥½å‹ä¼šè¯äº‹ä»¶ï¼ˆFRIEND_ADD / FRIEND_DELETE):居中灰色,不挂头像 / sender,
               è·Ÿä¸»èŠå¤©é¢æ¿é‡Œ MessageItem çš„æ¸²æŸ“语义对齐 -->
          <div
            v-if="isFriendChatTip(message.type)"
            class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <TipSegments :segments="resolveFriendNotificationSegments(message)" />
          </div>
          <!-- ç¾¤å¹¿æ’­äº‹ä»¶ï¼šè·Ÿå¥½å‹äº‹ä»¶åŒç°è‰²æ ·å¼ï¼Œmention æ®µæŒ‚点击弹 UserInfoCard -->
          <div
            v-else-if="isGroupNotification(message.type)"
            class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <TipSegments
              :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))"
            />
          </div>
          <!-- æ™®é€šæ¶ˆæ¯è¡Œ -->
          <div
            v-else
            class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <UserAvatar
              :url="getAvatar(message)"
              :name="senderRealNicknameOf(message)"
              :size="36"
              :clickable="false"
            />
            <div class="flex-1 min-w-0">
              <div
                class="flex justify-between items-start text-12px text-[var(--ant-color-text-secondary)]"
              >
                <span class="font-medium text-[var(--ant-color-text)]">
                  {{ senderDisplayNameOf(message) }}
                </span>
                <span class="im-message-history__meta relative flex-shrink-0">
                  <span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
                  <!-- å®šä½åˆ°èŠå¤©ä½ç½®ï¼šabsolute æµ®åœ¨æ—¶é—´ä¸‹æ–¹ï¼Œè¡Œ hover æ‰æ˜¾ç¤ºï¼Œ
                       ä¸å‚与右侧栏 flex æŽ’版(避免隐藏时占位让"我"和内容之间留空);
                       ä»…有真实 id çš„æ¶ˆæ¯æ‰æ”¯æŒï¼ˆæœ¬åœ°å ä½æ¶ˆæ¯ä¸è¡Œï¼‰ -->
                  <span
                    v-if="message.id && message.id > 0"
                    class="im-message-history__locate"
                    @click="locateMessage(message.id!)"
                  >
                    å®šä½åˆ°èŠå¤©ä½ç½®
                  </span>
                </span>
              </div>
              <div class="mt-1.5">
                <!-- æ’¤å›žï¼šå•独走灰色 tip,sender åæ®µå¯ç‚¹å‡» -->
                <div
                  v-if="message.type === ImContentType.RECALL"
                  class="text-sm italic text-[var(--ant-color-text-secondary)]"
                >
                  <TipSegments :segments="recallTipSegmentsOf(message)" />
                </div>
                <!-- å…¶å®ƒç±»åž‹èµ° MessageBubble å¤ç”¨ä¸»èŠå¤©æ°”泡 -->
                <MessageBubble
                  v-else
                  :type="message.type"
                  :content="message.content"
                  @open-merge="openMergeDetail?.($event)"
                />
              </div>
            </div>
          </div>
        </template>
        <div
          v-if="currentList.length === 0"
          class="py-10 text-center text-13px text-[var(--ant-color-text-disabled)]"
        >
          {{ keyword || activeFilter ? '没有匹配的消息' : '暂无消息' }}
        </div>
        <!-- åŠ è½½æ›´æ—©æ¶ˆæ¯ï¼šåˆ—è¡¨åº•éƒ¨ trigger(reverse åŽæœ€æ—©çš„在最下,按钮放底部更自然);
             filter å‘½ä¸­ 0 æ¡æ—¶ä»ä¿ç•™ â€”— åŠ è½½æ›´æ—©å¯èƒ½å¸¦å›žåŒ¹é…å†…å®¹ -->
        <div
          v-if="hasMore && allMessages.length > 0"
          class="py-3 text-center border-t border-t-solid border-[var(--ant-color-border-secondary)]"
        >
          <Button :loading="loadingMore" type="link" @click="loadEarlier">
            åŠ è½½æ›´æ—©æ¶ˆæ¯
          </Button>
        </div>
        <!-- "没有更早" åªåœ¨å·²æœ‰åŒ¹é…é¡¹æ—¶éœ²å‡ºï¼Œé¿å…å’Œä¸Šé¢"没有匹配的消息"空态文案重叠 -->
        <div
          v-else-if="!hasMore && currentList.length > 0"
          class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
        >
          æ²¡æœ‰æ›´æ—©çš„æ¶ˆæ¯äº†
        </div>
      </div>
    </div>
  </Modal>
</template>
<style scoped>
/* :deep ç©¿é€ el-tag å­ç»„ä»¶ DOM;搜索区 chip ç¦æŽ‰ hover é¢œè‰²è¿‡æ¸¡ / Ã— å›¾æ ‡åŠ¨æ•ˆï¼Œé¿å…åœ¨æœç´¢åŒºé‡Œæœ‰æŠ–åŠ¨æ„Ÿ */
.im-message-history__chip,
.im-message-history__chip :deep(.el-tag__close) {
  transition: none !important;
  animation: none !important;
}
/* ã€Œå®šä½åˆ°èŠå¤©ä½ç½®ã€æŒ‰é’®ï¼šçˆ¶è¡Œ hover æ‰æ˜¾ç¤ºï¼Œèµ° .parent:hover .child è·¨å…ƒç´ çŠ¶æ€è”åŠ¨ */
.im-message-history__locate {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 4px;
  display: none;
  white-space: nowrap;
  color: #1989fa;
  cursor: pointer;
  transition: color 0.15s;
}
.im-message-history__row:hover .im-message-history__locate {
  display: block;
}
.im-message-history__locate:hover {
  color: #146fc7;
}
/* ::after ä¼ªå…ƒç´ ç”»æ¿€æ´»ä¸‹åˆ’线;tab é»˜è®¤è“è‰²è¡Œå†…文字,激活态加下划线 */
.im-message-history__tab {
  position: relative;
  padding: 4px 2px;
  color: #1989fa;
  transition: color 0.15s;
}
.im-message-history__tab:hover {
  color: #2f81d4;
}
.im-message-history__tab--active::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: #1989fa;
  border-radius: 1px;
}
/* :deep ç©¿é€ antd Calendar(fullscreen=false å¡ç‰‡æ¨¡å¼ï¼‰å­ DOM,压一压塞进 320 popover */
.im-message-history__calendar :deep(.ant-picker-calendar-header) {
  padding: 4px 8px;
}
.im-message-history__calendar :deep(.ant-picker-content) {
  font-size: 12px;
}
.im-message-history__calendar :deep(.ant-picker-cell) {
  padding: 1px 0;
}
.im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) {
  height: 28px;
  margin: 0 2px;
  padding: 2px 4px 0;
}
</style>