413 lines
14 KiB
Vue
413 lines
14 KiB
Vue
<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>
|