ai-courseware/vue3-frontend/src/components/MessageItem.vue

298 lines
7.0 KiB
Vue

<template>
<div class="message-item" :class="messageClass">
<div class="message-content">
<!-- 用户消息 -->
<div v-if="message.type === 'user'" class="user-message">
<div class="message-bubble user-bubble">
{{ message.content }}
</div>
<div class="message-time">
{{ formatTime(message.timestamp) }}
</div>
</div>
<!-- AI消息 -->
<div v-else-if="message.type === 'ai'" class="ai-message">
<div class="ai-avatar">
<div class="avatar-icon">🤖</div>
</div>
<div class="ai-content">
<div class="message-bubble ai-bubble">
<div v-if="isTyping" class="typing-text">
<div class="markdown" v-html="rendered"></div>
<span class="cursor">|</span>
</div>
<div v-else class="markdown" v-html="rendered"></div>
</div>
<div class="message-time">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
<!-- 思考过程 -->
<div v-else-if="message.type === 'thinking'" class="thinking-message">
<div class="ai-avatar">
<div class="avatar-icon thinking">💭</div>
</div>
<div class="thinking-content">
<div class="thinking-bubble">
<div class="thinking-dots">
<span></span>
<span></span>
<span></span>
</div>
<div class="thinking-text">{{ message.content }}</div>
</div>
</div>
</div>
<!-- 组件消息(订单卡片等) -->
<div v-else-if="message.type === 'component'" class="component-message">
<div class="ai-avatar">
<div class="avatar-icon">🤖</div>
</div>
<div class="component-content">
<div class="component-wrapper">
<OrderCard
v-if="message.component?.type === 'order_card'"
:order-data="message.component.data"
/>
<SimpleOrderCard
v-else-if="(message as any).component?.order"
:order="(message as any).component.order"
/>
</div>
<div class="message-time">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { marked } from 'marked';
import type { Message } from '@/types';
import OrderCard from './OrderCard.vue';
import SimpleOrderCard from './SimpleOrderCard.vue';
interface Props {
message: Message;
isTyping?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isTyping: false
});
// 显示内容(用于打字机效果)
const displayContent = ref('');
const rendered = ref('');
const sanitizeMd = (s: string) => (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 消息样式类
const messageClass = computed(() => ({
'message-user': props.message.type === 'user',
'message-ai': props.message.type === 'ai',
'message-thinking': props.message.type === 'thinking',
'message-component': props.message.type === 'component'
}));
// 格式化时间
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
// 如果是今天
if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
// 如果是昨天
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.getDate() === yesterday.getDate()) {
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}
// 其他日期
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
};
// 打字机效果
watch(() => props.message.content, (newContent) => {
displayContent.value = newContent;
rendered.value = marked.parse(sanitizeMd(newContent || ''));
}, { immediate: true });
onMounted(() => {
displayContent.value = props.message.content;
rendered.value = marked.parse(sanitizeMd(displayContent.value || ''));
});
</script>
<style scoped>
.message-item {
@apply mb-4 px-4;
}
.message-content {
@apply max-w-full;
}
/* 用户消息样式 */
.user-message {
@apply flex flex-col items-end;
}
.user-bubble {
@apply bg-blue-500 text-white px-4 py-2 rounded-2xl rounded-br-md max-w-xs md:max-w-md break-words;
}
/* AI消息样式 */
.ai-message {
@apply flex items-start space-x-3;
}
.ai-avatar {
@apply flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center;
}
.avatar-icon {
@apply text-lg;
}
.avatar-icon.thinking {
@apply animate-pulse;
}
.ai-content {
@apply flex-1 min-w-0;
}
.ai-bubble {
@apply bg-gray-100 text-gray-800 px-4 py-2 rounded-2xl rounded-bl-md max-w-xs md:max-w-md break-words;
}
.markdown :is(h1,h2,h3,h4,h5,h6) { @apply font-semibold text-gray-900 }
.markdown p { @apply text-gray-800 leading-6 }
.markdown code { @apply bg-gray-200 rounded px-1 py-0.5 text-sm }
.markdown pre { @apply bg-gray-200 rounded p-3 overflow-auto text-sm }
.markdown ul { @apply list-disc pl-5 }
.markdown ol { @apply list-decimal pl-5 }
.dark .markdown :is(h1,h2,h3,h4,h5,h6) { @apply text-gray-100 }
.dark .markdown p { @apply text-gray-100 }
.dark .markdown code, .dark .markdown pre { @apply bg-gray-700 text-gray-100 }
/* 思考过程样式 */
.thinking-message {
@apply flex items-start space-x-3 opacity-70;
}
.thinking-content {
@apply flex-1 min-w-0;
}
.thinking-bubble {
@apply bg-gray-50 border border-gray-200 px-4 py-2 rounded-2xl rounded-bl-md max-w-xs md:max-w-md;
}
.thinking-dots {
@apply flex space-x-1 mb-2;
}
.thinking-dots span {
@apply w-2 h-2 bg-gray-400 rounded-full animate-bounce;
}
.thinking-dots span:nth-child(2) {
animation-delay: 0.1s;
}
.thinking-dots span:nth-child(3) {
animation-delay: 0.2s;
}
.thinking-text {
@apply text-sm text-gray-600 italic;
}
/* 组件消息样式 */
.component-message {
@apply flex items-start space-x-3;
}
.component-content {
@apply flex-1 min-w-0;
}
.component-wrapper {
@apply max-w-sm;
}
/* 消息气泡通用样式 */
.message-bubble {
@apply shadow-sm;
}
/* 消息时间样式 */
.message-time {
@apply text-xs text-gray-500 mt-1;
}
.user-message .message-time {
@apply text-right;
}
/* 打字机效果 */
.typing-text {
@apply inline;
}
.cursor {
@apply animate-pulse;
}
/* 响应式设计 */
@media (max-width: 640px) {
.message-bubble {
@apply max-w-xs;
}
}
/* 暗色主题支持 */
.dark .ai-bubble {
@apply bg-gray-700 text-gray-100;
}
.dark .thinking-bubble {
@apply bg-gray-800 border-gray-600;
}
.dark .thinking-text {
@apply text-gray-400;
}
.dark .ai-avatar {
@apply bg-gray-700;
}
.dark .message-time {
@apply text-gray-400;
}
</style>