Files
nl-im-pc/src/components/MessageBubble.vue

401 lines
8.9 KiB
Vue

<template>
<div class="flex items-start gap-3 mb-4" :class="isSent ? 'flex-row-reverse' : 'flex-row'">
<!-- 头像 -->
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0"
:style="avatarStyle"
>
{{ senderInfo.avatar }}
</div>
<!-- 消息内容区域 -->
<div class="flex flex-col max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl" :class="isSent ? 'items-end' : 'items-start'">
<!-- 发送者名称 -->
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 px-2">
{{ senderInfo.name }}
</div>
<!-- 消息气泡 -->
<div
class="rounded-2xl p-4 shadow-sm relative"
:class="bubbleClasses"
>
<!-- 图片消息 -->
<div
v-if="message.type === 'image'"
class="cursor-pointer relative"
@click="previewMedia(message.content, 'image')"
>
<img
:src="message.content"
:alt="'图片消息'"
class="rounded-lg max-w-full"
@error="handleImageError"
/>
<div class="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50 rounded-lg">
<i class="fas fa-search-plus text-white text-xl"></i>
</div>
</div>
<!-- 视频消息 -->
<div
v-else-if="message.type === 'video'"
class="cursor-pointer relative video-message"
@click="previewMedia(message.content, 'video')"
>
<video
:src="message.content"
class="rounded-lg max-w-full video-thumbnail"
preload="metadata"
@error="handleVideoError"
/>
<div class="video-overlay">
<div class="play-button">
<i class="fas fa-play"></i>
</div>
<div class="video-duration" v-if="message.duration">
{{ formatDuration(message.duration) }}
</div>
</div>
</div>
<!-- URL链接消息 -->
<div v-else-if="message.type === 'url'" class="url-message">
<div class="url-preview" @click="openUrl(message.url)">
<div class="url-favicon">
<img :src="message.favicon" :alt="message.domain" v-if="message.favicon" />
<i class="fas fa-link" v-else></i>
</div>
<div class="url-content">
<div class="url-title">{{ message.title || message.url }}</div>
<div class="url-description" v-if="message.description">
{{ message.description }}
</div>
<div class="url-domain">{{ message.domain }}</div>
</div>
<div class="url-thumbnail" v-if="message.thumbnail">
<img :src="message.thumbnail" :alt="message.title" />
</div>
</div>
<div class="url-text" v-if="message.text">
{{ message.text }}
</div>
</div>
<!-- 音频消息 -->
<AudioMessage v-else-if="message.type === 'audio'" :message="message" :is-sent="isSent" />
<!-- 文本消息 -->
<div v-else class="text-base leading-relaxed whitespace-pre-wrap">
<span v-html="formatTextWithLinks(message.content)"></span>
</div>
</div>
<!-- 消息时间和状态 -->
<div class="flex items-center gap-1 mt-1 text-xs text-gray-400 dark:text-gray-500 px-2">
<span>{{ message.time }}</span>
<i v-if="isSent && message.read" class="fas fa-check-double text-green-500"></i>
<i v-else-if="isSent && !message.read" class="fas fa-clock text-gray-400"></i>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useMediaPreview } from '@/composables/useMediaPreview';
import { useThemeStore } from '@/stores/theme';
import AudioMessage from './AudioMessage.vue';
const previewMedia = useMediaPreview();
const themeStore = useThemeStore();
const props = defineProps({
message: {
type: Object,
required: true
},
isSent: {
type: Boolean,
default: false
},
senderInfo: {
type: Object,
required: true
}
});
const avatarStyle = computed(() => ({
background: props.senderInfo.color
}));
const bubbleClasses = computed(() => {
const baseClasses = ['message-bubble'];
if (props.isSent) {
baseClasses.push('sent');
} else {
baseClasses.push('received');
}
if (themeStore.isDarkMode) {
baseClasses.push('dark');
}
return baseClasses;
});
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatTextWithLinks = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-link">$1</a>');
};
const openUrl = (url) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
</script>
<style scoped>
.message-bubble {
position: relative;
max-width: 100%;
word-wrap: break-word;
transition: all 0.2s ease;
}
.message-bubble.sent {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
border-bottom-right-radius: 4px;
}
.message-bubble.received {
background: white;
color: #1a202c;
border: 1px solid #e2e8f0;
border-bottom-left-radius: 4px;
}
.message-bubble.received.dark {
background: #374151;
color: #f7fafc;
border-color: #4b5563;
}
.video-message {
position: relative;
border-radius: 12px;
overflow: hidden;
max-width: 300px;
}
.video-thumbnail {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.video-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-message:hover .video-overlay {
opacity: 1;
}
.play-button {
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.url-message {
max-width: 400px;
}
.url-preview {
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
}
.url-preview:hover {
border-color: #4361ee;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.15);
}
.message-bubble.sent .url-preview {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.message-bubble.received.dark .url-preview {
border-color: #4b5563;
background: #4b5563;
}
.url-preview {
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
.url-favicon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.url-favicon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 2px;
}
.url-content {
flex: 1;
min-width: 0;
}
.url-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #1a202c;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-title {
color: white;
}
.message-bubble.received.dark .url-title {
color: #f7fafc;
}
.url-description {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-description {
color: rgba(255, 255, 255, 0.8);
}
.message-bubble.received.dark .url-description {
color: #9ca3af;
}
.url-domain {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.message-bubble.sent .url-domain {
color: rgba(255, 255, 255, 0.7);
}
.message-bubble.received.dark .url-domain {
color: #6b7280;
}
.url-thumbnail {
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.url-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.url-text {
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
:deep(.text-link) {
color: #4361ee;
text-decoration: underline;
transition: color 0.2s ease;
}
:deep(.text-link:hover) {
color: #3f37c9;
}
.message-bubble.sent :deep(.text-link) {
color: rgba(255, 255, 255, 0.9);
}
.message-bubble.sent :deep(.text-link:hover) {
color: white;
}
</style>