| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |