From 5367b3b4d92588c4e76728e6bd4ad6aae0cbb967 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 23 七月 2026 13:05:25 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0
---
src/views/im/home/pages/conversation/components/message/message-bubble.vue | 414 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 414 insertions(+), 0 deletions(-)
diff --git a/src/views/im/home/pages/conversation/components/message/message-bubble.vue b/src/views/im/home/pages/conversation/components/message/message-bubble.vue
new file mode 100644
index 0000000..8a0cc97
--- /dev/null
+++ b/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锛圝SON 瀛楃涓诧級 */
+ 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 鍏ュ彛锛歝ontent 涓�鍙樺彧 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 鏁扮粍锛歮ention 楂樹寒 + 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]' : '']
+ }
+ }
+}
+
+/** 鏂囦欢鐐瑰嚮 鈫� 鏂扮獥鍙d笅杞斤紱涓婁紶涓烦杩� */
+function handleFileClick() {
+ if (isUploading.value || !filePayload.value?.url) {
+ return
+ }
+ openSafeUrl(filePayload.value.url)
+}
+
+const voicePlayer = useVoicePlayer() // 璇煶鐐瑰嚮锛氭墭绠$粰 useVoicePlayer 鍏ㄥ眬浜掓枼鎾斁锛屾柊鐐圭殑璇煶浼氬仠鎺夋棫鐨�
+/**
+ * 瀹炰緥绾у敮涓�鎾斁 key锛氭瘡涓� MessageBubble 瀹炰緥鐙珛涓�浠�
+ *
+ * 涓嶇敤 url 褰� key 鏄负浜嗛伩鍏嶃�屼富闈㈡澘 / 鍘嗗彶鎶藉眽 / 鍚堝苟璇︽儏鍚屼竴鏉¤闊炽�嶅叡浜韩浠斤細閭f牱涓夊姘旀场浼�
+ * 鍚屾椂鏄剧ず鎾斁鎬侊紝涓斾换浣曚竴澶勫嵏杞介兘浼� 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 娓叉煋锛宮ention 楂樹寒鍙偣鍑汇�乁RL 鑷姩璇嗗埆鎴愬彲鐐瑰嚮閾炬帴 -->
+ <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>
+
+ <!-- 鍥剧墖锛歟l-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 鍐呭祵鎾斁锛宲oster 璧板悗绔皝闈紱涓婁紶涓崐閫忔槑閬僵 -->
+ <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 瑙f瀽澶辫触 / 娌� 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>
+
+ <!-- 琛ㄦ儏璐村浘锛氳8 <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)"
+ />
+
+ <!-- 鍚堝苟杞彂姘旀场锛歵itle + 鎽樿棰勮 + 搴曢儴銆岃亰澶╄褰曘�嶆爣绛� -->
+ <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>
+ <!-- 鍚堝苟娑堟伅瑙f瀽澶辫触鍏滃簳 -->
+ <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` 濉岀缉鎴愯8 `.dark` 鑰屾妸鍙橀噺鍒峰埌 <html> */
+:global(.dark .message-bubble--other) {
+ --im-message-bubble-other-bg: rgb(255 255 255 / 12%);
+}
+
+/* :deep 绌块�� scoped 瀛愮粍浠� DOM锛沞l-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>
--
Gitblit v1.9.3