gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/message/message-bubble.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,414 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { formatFileSize, openSafeUrl } from '#/packages/utils/src'
import { Image } from 'ant-design-vue'
import { CardBubble } from '#/views/im/home/components/card'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'
import { ImContentType } from '#/views/im/utils/constants'
import { summarizeMessageContent } from '#/views/im/utils/conversation'
import {
  type AudioMessage,
  type CardMessage,
  type FaceMessage,
  type FileMessage,
  getFileIconInfo,
  type ImageMessage,
  type MentionCandidate,
  type MergeMessage,
  parseMessage,
  parseTextSegments,
  type TextMessage,
  type VideoMessage
} from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time'
import MaterialBubble from './material-bubble.vue'
import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageBubble' })
const props = defineProps<{
  /** æ¶ˆæ¯ content(JSON å­—符串) */
  content: string
  /** TEXT æ°”泡的 @ mention å€™é€‰åå­—;不传则文本里的 @xxx é€€åŒ–为普通文本 */
  mentions?: MentionCandidate[]
  /** æ˜¯å¦è‡ªå·±å‘送,影响气泡配色(绿底 vs ç°åº•) */
  selfSend?: boolean
  /** å†…容类型,对齐 ImContentType */
  type: number
  /** åª’体上传进度(0-100);非 null å³è§†ä¸ºä¸Šä¼ ä¸­ï¼Œæ¸²æŸ“遮罩 / è¿›åº¦æ¡ */
  uploadProgress?: null | number
}>()
const emit = defineEmits<{
  /** åç‰‡ç‚¹å‡»ï¼šè°ƒç”¨æ–¹å†³å®šå¼¹å¡ç‰‡ / è·³ç¾¤ç­‰è¡Œä¸º */
  clickCard: [card: CardMessage, e: MouseEvent]
  /** åˆå¹¶æ¶ˆæ¯æ°”泡点击:调用方决定开 dialog æˆ–栈内 push */
  openMerge: [content: string]
}>()
/** å„ type åˆ¤å®š */
const isText = computed(() => props.type === ImContentType.TEXT)
const isImage = computed(() => props.type === ImContentType.IMAGE)
const isFile = computed(() => props.type === ImContentType.FILE)
const isVoice = computed(() => props.type === ImContentType.VOICE)
const isVideo = computed(() => props.type === ImContentType.VIDEO)
const isFace = computed(() => props.type === ImContentType.FACE)
const isCard = computed(() => props.type === ImContentType.CARD)
const isMerge = computed(() => props.type === ImContentType.MERGE)
const isMaterial = computed(() => props.type === ImContentType.MATERIAL)
/** åª’体上传中:uploadProgress éž null å³è§†ä¸ºä¸Šä¼ ä¸­ */
const isUploading = computed(() => props.uploadProgress != null)
const uploadProgress = computed(() => props.uploadProgress ?? 0)
const uploadProgressText = computed(() => `${uploadProgress.value}%`)
/**
 * å•一 parse å…¥å£ï¼šcontent ä¸€å˜åª parse ä¸€æ¬¡ï¼ŒæŒ‰ type åˆ†å‘到下面 7 ä¸ª payload
 *
 * å„类型 payload å…±ç”¨åŒä¸€æ£µ JSON æ ‘,避免 7 ä¸ª computed å„自重 parse åŒä¸€ä»½ content
 */
const parsedContent = computed<unknown>(() => parseMessage(props.content))
const textPayload = computed(() =>
  isText.value ? (parsedContent.value as null | TextMessage) : null
)
/** æ–‡æœ¬æ°”泡 segment æ•°ç»„:mention é«˜äº® + URL è‡ªåŠ¨è¯†åˆ« + æ™®é€šæ–‡æœ¬ä¸‰æ®µæ‹¼æŽ¥ */
const textSegments = computed(() => {
  const content = textPayload.value?.content
  if (!content) {
    return []
  }
  return parseTextSegments(content, props.mentions || [])
})
const imagePayload = computed(() =>
  isImage.value ? (parsedContent.value as ImageMessage | null) : null
)
const filePayload = computed(() =>
  isFile.value ? (parsedContent.value as FileMessage | null) : null
)
const voicePayload = computed(() =>
  isVoice.value ? (parsedContent.value as AudioMessage | null) : null
)
const videoPayload = computed(() =>
  isVideo.value ? (parsedContent.value as null | VideoMessage) : null
)
const cardPayload = computed(() =>
  isCard.value ? (parsedContent.value as CardMessage | null) : null
)
const mergePayload = computed(() =>
  isMerge.value ? (parsedContent.value as MergeMessage | null) : null
)
/** åˆå¹¶æ¶ˆæ¯å†…嵌前 N æ¡æ´¾ç”Ÿã€Œ{昵称}:{摘要}」 */
const mergePreviewLines = computed(() => {
  if (!mergePayload.value) {
    return []
  }
  return mergePayload.value.messages
    .slice(0, MESSAGE_MERGE_PREVIEW_LINES)
    .map((item) => `${item.senderNickname}:${summarizeMessageContent(item)}`)
})
const FACE_DIMENSION_MAX = 2048 // è¡¨æƒ… payload:非法宽高派生成 undefined,让 <img> èµ° CSS max-w / max-h å…œåº•
const facePayload = computed(() => {
  if (!isFace.value) {
    return null
  }
  const raw = parsedContent.value as FaceMessage | null
  if (!raw) {
    return null
  }
  const sanitize = (v: number | undefined) =>
    v && v > 0 && v <= FACE_DIMENSION_MAX ? v : undefined
  return { ...raw, width: sanitize(raw.width), height: sanitize(raw.height) }
})
/** æ–‡ä»¶å›¾æ ‡ + é…è‰²ï¼šæŒ‰æ‰©å±•名分发 */
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
/** æ–‡æœ¬ / æ–‡ä»¶ / è¯­éŸ³æ°”泡的整体 class(含 selfSend é…è‰² + ::before ä¸‰è§’çš„ side class) */
function bubbleClass(variant: 'file' | 'text' | 'voice'): string[] {
  const isSelf = props.selfSend
  const side = isSelf ? 'message-bubble--self' : 'message-bubble--other'
  switch (variant) {
    case 'file': {
      return [
        side,
        'message-bubble--file',
        isSelf
          ? 'bg-[#95ec69] border-[var(--ant-color-border-secondary)]'
          : 'bg-[var(--ant-color-bg-container)] border-[var(--ant-color-border-secondary)] hover:border-[#409eff]'
      ]
    }
    case 'text': {
      return [
        side,
        'message-bubble--text',
        isSelf
          ? 'text-black bg-[#95ec69]'
          : 'text-[var(--ant-color-text)]'
      ]
    }
    case 'voice': {
      return [side, 'message-bubble--voice', isSelf ? 'bg-[#95ec69]' : '']
    }
  }
}
/** æ–‡ä»¶ç‚¹å‡» â†’ æ–°çª—口下载;上传中跳过 */
function handleFileClick() {
  if (isUploading.value || !filePayload.value?.url) {
    return
  }
  openSafeUrl(filePayload.value.url)
}
const voicePlayer = useVoicePlayer() // è¯­éŸ³ç‚¹å‡»ï¼šæ‰˜ç®¡ç»™ useVoicePlayer å…¨å±€äº’斥播放,新点的语音会停掉旧的
/**
 * å®žä¾‹çº§å”¯ä¸€æ’­æ”¾ key:每个 MessageBubble å®žä¾‹ç‹¬ç«‹ä¸€ä»½
 *
 * ä¸ç”¨ url å½“ key æ˜¯ä¸ºäº†é¿å…ã€Œä¸»é¢æ¿ / åŽ†å²æŠ½å±‰ / åˆå¹¶è¯¦æƒ…同一条语音」共享身份:那样三处气泡会
 * åŒæ—¶æ˜¾ç¤ºæ’­æ”¾æ€ï¼Œä¸”任何一处卸载都会 stop æŽ‰åˆ«å¤„仍可见的播放
 */
const voiceKey = Symbol('im-message-bubble-voice')
const voicePlaying = computed(() => voicePlayer.isPlaying(voiceKey))
function handleVoiceClick() {
  const url = voicePayload.value?.url
  if (!url) {
    return
  }
  voicePlayer.play(voiceKey, url)
}
/** æ°”泡卸载兜底:传 key è®© stop è‡ªå·±åˆ¤åˆ«ã€Œæ˜¯ä¸æ˜¯æˆ‘」,不会误伤别人的播放 */
onBeforeUnmount(() => {
  voicePlayer.stop(voiceKey)
})
</script>
<template>
  <!-- æ–‡æœ¬ï¼šæŒ‰ segment æ¸²æŸ“,mention é«˜äº®å¯ç‚¹å‡»ã€URL è‡ªåŠ¨è¯†åˆ«æˆå¯ç‚¹å‡»é“¾æŽ¥ -->
  <div
    v-if="isText && textPayload"
    class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
    :class="bubbleClass('text')"
  >
    <TipSegments :segments="textSegments" />
  </div>
  <!-- å›¾ç‰‡ï¼šel-image å†…置预览;上传中半透明遮罩 -->
  <div v-else-if="isImage && imagePayload" class="relative inline-block">
    <Image
      class="max-w-[220px] rounded cursor-zoom-in"
      :src="imagePayload.thumbnailUrl || imagePayload.url"
      :preview="isUploading ? false : { src: imagePayload.url }"
    />
    <div
      v-if="isUploading"
      class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
    >
      {{ uploadProgressText }}
    </div>
  </div>
  <!-- æ–‡ä»¶ï¼šæ–‡ä»¶å + å¤§å°é å·¦ã€å½©è‰²å¤§å›¾æ ‡è´´å³ï¼›ä¸Šä¼ ä¸­æ’一条进度条 -->
  <div
    v-else-if="isFile && filePayload"
    class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border border-solid rounded transition-colors"
    :class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
    @click="handleFileClick"
  >
    <div class="flex-1 min-w-0">
      <div class="overflow-hidden text-sm font-medium truncate text-[var(--ant-color-text)]">
        {{ filePayload.name }}
      </div>
      <div class="mt-1 text-12px text-[var(--ant-color-text-secondary)]">
        {{ formatFileSize(filePayload.size) }}
      </div>
      <div v-if="isUploading" class="flex gap-2 items-center mt-1.5">
        <div class="overflow-hidden flex-1 h-1 rounded bg-[var(--ant-color-fill-dark)]">
          <div
            class="h-full bg-[var(--ant-color-primary)] transition-[width] duration-150"
            :style="{ width: `${uploadProgress }%` }"
          ></div>
        </div>
        <span class="text-11px text-[var(--ant-color-text-secondary)] tabular-nums">
          {{ uploadProgressText }}
        </span>
      </div>
    </div>
    <Icon :icon="fileIconInfo.icon" :color="fileIconInfo.color" :size="40" class="flex-shrink-0" />
  </div>
  <!-- è¯­éŸ³ -->
  <div
    v-else-if="isVoice && voicePayload"
    class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
    :class="bubbleClass('voice')"
    @click="handleVoiceClick"
  >
    <Icon
      icon="ant-design:audio-outlined"
      :size="18"
      class="message-bubble__voice-icon"
      :class="{ 'im-voice-playing': voicePlaying }"
    />
    <span class="text-13px text-[var(--ant-color-text)]">
      {{ formatSeconds(voicePayload.duration) }}
    </span>
  </div>
  <!-- è§†é¢‘:原生 controls å†…嵌播放,poster èµ°åŽç«¯å°é¢ï¼›ä¸Šä¼ ä¸­åŠé€æ˜Žé®ç½© -->
  <div v-else-if="isVideo && videoPayload?.url" class="relative inline-block">
    <video
      class="max-w-[280px] max-h-[320px] rounded bg-black"
      :src="videoPayload.url"
      :poster="videoPayload.coverUrl"
      :controls="!isUploading"
      preload="metadata"
    ></video>
    <div
      v-if="isUploading"
      class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
    >
      {{ uploadProgressText }}
    </div>
  </div>
  <!-- è§†é¢‘但 payload è§£æžå¤±è´¥ / æ²¡ url:降级展示 -->
  <div
    v-else-if="isVideo"
    class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]"
  >
    [视频消息]
  </div>
  <!-- è¡¨æƒ…贴图:裸 <img> ä¸å¥—气泡 -->
  <div v-else-if="isFace && facePayload" class="inline-block">
    <img
      :src="facePayload.url"
      :alt="facePayload.name || '表情'"
      :title="facePayload.name || ''"
      :width="facePayload.width"
      :height="facePayload.height"
      class="block max-w-[160px] max-h-[160px] object-contain"
      draggable="false"
    />
  </div>
  <!-- åç‰‡ -->
  <CardBubble
    v-else-if="isCard && cardPayload"
    :card="cardPayload"
    clickable
    @click="(e: MouseEvent) => emit('clickCard', cardPayload!, e)"
  />
  <!-- åˆå¹¶è½¬å‘气泡:title + æ‘˜è¦é¢„览 + åº•部「聊天记录」标签 -->
  <div
    v-else-if="isMerge && mergePayload"
    class="flex flex-col w-[260px] rounded-md overflow-hidden cursor-pointer bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border)] hover:border-[#409eff]"
    @click="emit('openMerge', content)"
  >
    <div class="px-3 py-2 text-sm font-medium text-[var(--ant-color-text)] truncate">
      {{ mergePayload.title }}
    </div>
    <div class="flex flex-col gap-0.5 px-3 pb-2">
      <div
        v-for="(line, idx) in mergePreviewLines"
        :key="idx"
        class="text-12px text-[var(--ant-color-text-secondary)] truncate"
      >
        {{ line }}
      </div>
    </div>
    <div
      class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]"
    >
      èŠå¤©è®°å½•
    </div>
  </div>
  <!-- åˆå¹¶æ¶ˆæ¯è§£æžå¤±è´¥å…œåº• -->
  <div
    v-else-if="isMerge"
    class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]"
  >
    [聊天记录]
  </div>
  <!-- é¢‘道素材:图文卡片,点击拉富文本 / è·³å¤–链 -->
  <MaterialBubble v-else-if="isMaterial" :content="props.content" />
  <!-- æœªçŸ¥ç±»åž‹é™çº§ -->
  <div
    v-else
    class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]"
  >
    [不支持的内容类型]
  </div>
</template>
<style scoped>
/* æ°”泡尾巴小三角指向对应头像(对方在左、自己在右);走 ::before + 4 è¾¹ border é…è‰²ç”»ï¼šé€æ˜Ž 3 è¾¹ + å®žè‰² 1 è¾¹ï¼Œ
   é¢œè‰²ä¸Žæ°”泡背景对应,留 1px è§†è§‰åƒè¿›åŽ»ï¼Œçœä¸€å¼ å›¾ç‰‡ */
.message-bubble--other::before,
.message-bubble--self::before {
  content: '';
  position: absolute;
  top: 12px;
  width: 0;
  height: 0;
  border-style: solid;
}
.message-bubble--other {
  --im-message-bubble-other-bg: #f5f5f5;
}
.message-bubble--other.message-bubble--text,
.message-bubble--other.message-bubble--voice {
  background-color: var(--im-message-bubble-other-bg);
}
.message-bubble--other::before {
  left: -5px;
  border-width: 5px 6px 5px 0;
  border-color: transparent var(--im-message-bubble-other-bg) transparent transparent;
}
.message-bubble--self::before {
  right: -5px;
  border-width: 5px 0 5px 6px;
  border-color: transparent transparent transparent #95ec69;
}
/* æ•´ä½“放进 :global(),避免 Vue scoped æŠŠ `:global(.dark) .xxx` å¡Œç¼©æˆè£¸ `.dark` è€ŒæŠŠå˜é‡åˆ·åˆ° <html> */
:global(.dark .message-bubble--other) {
  --im-message-bubble-other-bg: rgb(255 255 255 / 12%);
}
/* :deep ç©¿é€ scoped å­ç»„ä»¶ DOM;el-icon åœ¨æš—色模式下全局 color è¢« .el-icon{color:var(--color)} å¹²æ‰°ï¼ŒæŠŠ voice å›¾æ ‡ fill é”æ­» */
.message-bubble__voice-icon :deep(svg) {
  fill: #606266 !important;
}
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
  fill: #409eff !important;
}
/* @keyframes éœ€è¦ SCSS å£°æ˜Žï¼›æ’­æ”¾ä¸­çš„脉冲动画 */
.im-voice-playing {
  animation: im-voice-icon-pulse 0.8s infinite;
}
@keyframes im-voice-icon-pulse {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.15);
  }
}
</style>