Files
happy-life-star/web/src/views/Chat/index.vue
T
2025-10-26 17:43:24 +08:00

413 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased chat-container">
<!-- Chat Header -->
<header class="bg-white shadow-md z-20 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-3">
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="home" class="w-6 h-6"></i>
</router-link>
<img
src="https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png"
alt="开开头像"
class="w-10 h-10 rounded-full object-cover border-2 border-white shadow"
>
<div>
<h1 class="text-lg font-bold text-text-dark">开开</h1>
<p class="text-xs text-text-medium flex items-center">
<span
class="w-2 h-2 rounded-full mr-1.5"
:class="{
'bg-green-400': chatStore.wsConnected,
'bg-yellow-400': chatStore.connectionStatus === 'CONNECTING',
'bg-red-400': !chatStore.wsConnected
}"
></span>
{{ connectionStatusText }}
</p>
</div>
</div>
<div class="flex items-center space-x-4">
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="bell" class="w-6 h-6"></i>
</router-link>
</div>
</div>
</header>
<!-- 连接状态提示 -->
<div
v-if="!chatStore.wsConnected"
class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mx-4"
>
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="wifi-off" class="h-5 w-5 text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
<span v-if="chatStore.connectionStatus === 'CONNECTING'">正在连接...</span>
<span v-else-if="chatStore.connectionStatus === 'ERROR'">连接失败正在重试...</span>
<span v-else>连接已断开正在重连...</span>
</p>
</div>
</div>
</div>
<!-- Chat Messages -->
<main class="flex-1 overflow-hidden">
<div
id="chat-messages"
ref="messagesContainer"
class="h-full overflow-y-auto px-4 py-6"
>
<!-- 加载状态 -->
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-gray-600">加载对话记录中...</p>
</div>
<!-- 欢迎消息使用 v-if避免与列表互斥切换引发的 DOM 竞态 -->
<div v-if="messages.length === 0" class="text-center py-12">
<img
:src="kaikaiAvatar"
alt="开开"
class="w-20 h-20 rounded-full mx-auto mb-4 shadow-lg"
>
<h2 class="text-xl font-bold text-text-dark mb-2">你好我是开开 👋</h2>
<p class="text-text-medium max-w-md mx-auto leading-relaxed">
我是你的情绪陪伴使者随时准备倾听你的心声
<br>有什么想聊的吗
</p>
</div>
<!-- 消息列表使用 v-show 保持节点稳定移除key避免频繁重新挂载 -->
<div v-show="messages.length > 0" class="space-y-4">
<div
v-for="message in messages"
:key="message.id"
class="flex w-full items-end mb-4"
:class="message.type === 'user' ? 'justify-end' : 'justify-start'"
>
<!-- AI消息 -->
<template v-if="message.type === 'ai'">
<img
:src="kaikaiAvatar"
alt="开开"
class="w-10 h-10 rounded-full mr-3 self-start flex-shrink-0"
>
<div class="max-w-xs md:max-w-md lg:max-w-lg">
<div class="bg-white text-text-dark rounded-r-2xl rounded-tl-2xl p-3 px-4 shadow-md border border-gray-100">
<p class="leading-relaxed whitespace-pre-wrap">{{ message.content }}</p>
<div class="text-xs text-gray-500 mt-1">{{ formatMessageTime(message.timestamp) }}</div>
</div>
</div>
</template>
<!-- 用户消息 -->
<template v-else-if="message.type === 'user'">
<div class="max-w-xs md:max-w-md lg:max-w-lg">
<div class="bg-tech-blue text-white rounded-l-2xl rounded-tr-2xl p-3 px-4 shadow-md">
<p class="leading-relaxed whitespace-pre-wrap">{{ message.content }}</p>
<div class="text-xs text-blue-200 mt-1 flex items-center justify-end">
<span>{{ formatMessageTime(message.timestamp) }}</span>
<!-- 消息状态指示器 -->
<span v-if="message.status" class="ml-2 flex items-center">
<i v-if="message.status === 'sending'" data-lucide="clock" class="w-3 h-3"></i>
<i v-else-if="message.status === 'sent'" data-lucide="check" class="w-3 h-3"></i>
<i v-else-if="message.status === 'failed'" data-lucide="x" class="w-3 h-3 text-red-300"></i>
</span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</main>
<!-- Chat Input -->
<footer class="bg-white border-t border-gray-200 p-4 fixed left-0 right-0 z-10 chat-input-footer">
<div class="container mx-auto max-w-4xl">
<form @submit.prevent="sendMessage" class="flex items-end space-x-3">
<div class="flex-1 relative">
<textarea
v-model="inputMessage"
ref="messageInput"
placeholder="和开开说说你的心情..."
class="w-full px-4 py-3 border border-gray-300 rounded-2xl resize-none focus:outline-none focus:ring-2 focus:ring-tech-blue focus:border-transparent"
rows="1"
:disabled="!chatStore.wsConnected || sending"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.shift.exact="addNewLine"
@input="adjustTextareaHeight"
></textarea>
</div>
<button
type="submit"
:disabled="!inputMessage.trim() || !chatStore.wsConnected || sending"
class="bg-tech-blue text-white p-3 rounded-2xl hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-tech-blue focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
>
<i v-if="sending" data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
<i v-else data-lucide="send" class="w-5 h-5"></i>
</button>
</form>
</div>
</footer>
<!-- 底部导航栏 -->
<BottomNavigation />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { messageApi } from '@/services/message'
import MessageService from '@/services/message'
import type { ChatMessage } from '@/types'
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
// Store
const chatStore = useChatStore()
// 响应式数据
// 直接使用 chatStore.messages,避免计算属性导致的重新计算
const messages = computed(() => chatStore.messages)
const inputMessage = ref('')
const sending = ref(false)
const loading = ref(false)
const messagesContainer = ref<HTMLElement>()
const messageInput = ref<HTMLTextAreaElement>()
// 定时同步句柄(避免在 onMounted 内部注册 onUnmounted
let syncInterval: any = null
// 头像
const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
// 计算属性
const connectionStatusText = computed(() => {
if (chatStore.wsConnected) return '在线'
if (chatStore.connectionStatus === 'CONNECTING') return '连接中'
if (chatStore.connectionStatus === 'ERROR') return '连接失败'
return '离线'
})
// 格式化消息时间
const formatMessageTime = (timestamp: string | Date) => {
try {
let date: Date
if (typeof timestamp === 'string') {
// 处理 "2025-07-26 22:09:10" 格式
if (timestamp.includes(' ') && !timestamp.includes('T')) {
date = new Date(timestamp.replace(' ', 'T'))
} else {
date = new Date(timestamp)
}
} else {
date = timestamp
}
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
if (messageDate.getTime() === today.getTime()) {
// 今天的消息只显示时间
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
} else {
// 其他日期显示日期和时间
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
} catch (error) {
console.error('时间格式化失败:', error)
return String(timestamp)
}
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 强制滚动到底部(多次尝试确保成功)
const forceScrollToBottom = () => {
scrollToBottom()
setTimeout(() => scrollToBottom(), 100)
setTimeout(() => scrollToBottom(), 300)
setTimeout(() => scrollToBottom(), 500)
}
// 加载消息
const loadMessages = async () => {
loading.value = true
try {
// 调用最近消息API
const response = await messageApi.getRecentMessages(50)
// 提取消息数据
const messageList = response.data || response || []
if (Array.isArray(messageList)) {
// 转换消息格式
const chatMessages = MessageService.convertToChatMessages(messageList)
// 按时间排序(最早的在前面)
chatMessages.sort((a, b) => {
const parseTime = (timestamp: string | Date) => {
if (timestamp instanceof Date) return timestamp.getTime()
if (typeof timestamp === 'string') {
if (timestamp.includes(' ') && !timestamp.includes('T')) {
return new Date(timestamp.replace(' ', 'T')).getTime()
}
return new Date(timestamp).getTime()
}
return new Date().getTime()
}
return parseTime(a.timestamp) - parseTime(b.timestamp)
})
// 将消息添加到 chatStore
chatStore.messages.splice(0, chatStore.messages.length, ...chatMessages)
// 强制滚动到底部
await nextTick()
forceScrollToBottom()
}
} catch (error) {
console.error('❌ 加载消息失败:', error)
} finally {
loading.value = false
}
}
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim() || !chatStore.wsConnected || sending.value) {
return
}
const content = inputMessage.value.trim()
inputMessage.value = ''
sending.value = true
try {
// 直接通过WebSocket发送消息,让chatStore处理消息添加
// 计算属性会自动响应 chatStore 的变化
await chatStore.sendMessage(content)
} catch (error) {
console.error('❌ 发送消息失败:', error)
} finally {
sending.value = false
}
}
// 调整文本框高度
const adjustTextareaHeight = () => {
if (messageInput.value) {
messageInput.value.style.height = 'auto'
messageInput.value.style.height = messageInput.value.scrollHeight + 'px'
}
}
// 添加换行
const addNewLine = () => {
inputMessage.value += '\n'
nextTick(() => adjustTextareaHeight())
}
// 组件挂载
onMounted(async () => {
// 初始化聊天store
await chatStore.initChat()
// 加载历史消息
await loadMessages()
// 监听WebSocket消息
try {
chatStore.onMessage(async (_message: any) => {
// 消息已经被添加到 chatStore,计算属性会自动更新
console.log('📨 Chat页面收到WebSocket消息回调')
await nextTick()
forceScrollToBottom()
})
} catch (error) {
console.warn('⚠️ 设置WebSocket监听器失败:', error)
}
// 确保初始化完成后滚动到底部
await nextTick()
setTimeout(() => forceScrollToBottom(), 100)
setTimeout(() => forceScrollToBottom(), 500)
setTimeout(() => forceScrollToBottom(), 1000)
})
// 组件卸载
onUnmounted(() => {
chatStore.disconnectWebSocket()
})
// 监听chatStore消息变化(移除,避免与 onMessage/定时同步重复触发导致渲染竞态)
// 保留通过 onMessage 事件与定时器同步的方式,减少同一 tick 内的多次 DOM 更新
</script>
<style scoped>
/* 样式定义 */
.bg-tech-blue { background-color: #4A90E2; }
.bg-warm-orange { background-color: #F5A623; }
.bg-light-gray { background-color: #F7F8FA; }
.text-tech-blue { color: #4A90E2; }
.text-text-dark { color: #333333; }
.text-text-medium { color: #888888; }
.border-tech-blue { border-color: #4A90E2; }
#chat-messages {
scrollbar-width: thin;
scrollbar-color: #4A90E2 #F7F8FA;
}
#chat-messages::-webkit-scrollbar {
width: 6px;
}
#chat-messages::-webkit-scrollbar-track {
background: #F7F8FA;
}
#chat-messages::-webkit-scrollbar-thumb {
background: #4A90E2;
border-radius: 3px;
}
#chat-messages::-webkit-scrollbar-thumb:hover {
background: #357ABD;
}
textarea {
max-height: 120px;
min-height: 44px;
}
/* 聊天页面布局调整 */
.chat-container {
padding-bottom: 150px; /* 为输入框和底部导航栏留出空间 */
}
.chat-input-footer {
bottom: 70px; /* 在底部导航栏上方 */
}
</style>