From 27cd042df9aca0383a49f3514bc21958dd890912 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期一, 29 六月 2026 15:42:23 +0800
Subject: [PATCH] 银川 1.联调产品维护页面 2.添加IM即时通讯模块

---
 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