You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

498 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="chat-interface">
<div class="chat-header">
<h1>🧭 智能行程规划</h1>
<p>告诉我你的旅行想法我来为你规划完美行程</p>
</div>
<!-- Chat messages -->
<div class="chat-messages" ref="messagesContainer">
<div class="message bot">
<div class="avatar">🤖</div>
<div class="bubble">
你好我是你的行程规划助手请告诉我<br/>
想去哪里单个地点或多个地点组合<br/>
几月出发几天行程<br/>
交通方式自驾/公共交通/步行<br/>
有什么特殊需求带老人/小孩/特定景点<br/>
<br/>
<span class="example">示例9月去云南5自驾带老人</span><br/>
<span class="example">示例成都-九寨沟-重庆7天自驾游</span>
</div>
</div>
<div v-for="(msg, i) in messages" :key="i" class="message" :class="msg.role">
<div class="avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="bubble">
<!-- Show thinking process for bot messages -->
<div v-if="msg.thinking" class="thinking-process" :class="{ expanded: msg.thinkingExpanded }">
<div class="thinking-header" @click="msg.thinkingExpanded = !msg.thinkingExpanded">
<span class="thinking-icon">🧠</span>
<span>AI 思考过程</span>
<span class="thinking-toggle">{{ msg.thinkingExpanded ? '收起' : '展开' }}</span>
</div>
<div class="thinking-content">{{ msg.thinking }}</div>
</div>
<div class="message-content">{{ msg.content }}</div>
<!-- Error message -->
<div v-if="msg.error" class="error-hint">
💡 {{ msg.error }}
</div>
</div>
</div>
<!-- Loading indicator -->
<div v-if="isGenerating" class="message bot">
<div class="avatar">🤖</div>
<div class="bubble thinking">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
{{ thinkingStatus }}
</div>
</div>
</div>
<!-- Input area -->
<div class="chat-input-area">
<input
v-model="inputText"
@keyup.enter="sendMessage"
placeholder="例如9月去云南5天自驾"
:disabled="isGenerating"
/>
<button @click="sendMessage" :disabled="!inputText.trim() || isGenerating">
{{ isGenerating ? '生成中...' : '发送' }}
</button>
</div>
<!-- Scheme cards (appear after AI response) -->
<transition name="slide-up">
<div v-if="schemes.length > 0" class="scheme-cards">
<h3>📋 为你生成了 {{ schemes.length }} 个方案</h3>
<div class="cards-grid">
<div
v-for="(scheme, i) in schemes"
:key="i"
class="scheme-card"
@click="selectScheme(i)"
>
<div class="card-header">
<span class="card-badge">{{ '方案' + String.fromCharCode(65 + i) }}</span>
<h4>{{ scheme.name }}</h4>
</div>
<div class="card-stats">
<span>🚗 ~{{ scheme.totalKm }}km</span>
<span>⏱️ {{ scheme.totalDriveTime }}h</span>
<span>📅 {{ scheme.days }}天</span>
<span>💰 {{ scheme.budget }}</span>
</div>
<div class="card-highlights">
<span v-for="(h, j) in scheme.highlights" :key="j" class="highlight-tag">{{ h }}</span>
</div>
<div class="card-route">
<span>🗺️ {{ scheme.route }}</span>
</div>
<div class="card-action"> </div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
import { chatWithAI } from '../services/aiService'
const store = useItineraryStore()
const messages = ref([])
const inputText = ref('')
const isGenerating = ref(false)
const schemes = ref([])
const thinkingStatus = ref('正在分析需求...')
const messagesContainer = ref(null)
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
const sendMessage = async () => {
if (!inputText.value.trim() || isGenerating.value) return
const userMsg = inputText.value.trim()
messages.value.push({ role: 'user', content: userMsg })
inputText.value = ''
schemes.value = []
await scrollToBottom()
isGenerating.value = true
thinkingStatus.value = '正在分析需求...'
try {
let thinkingContent = ''
const result = await chatWithAI(userMsg, (streamContent) => {
// Parse streaming content for thinking
thinkingContent = streamContent
// Update thinking status based on progress
if (streamContent.includes('thinking')) {
thinkingStatus.value = '🧠 AI 正在思考并搜索目的地信息...'
} else if (streamContent.includes('schemes')) {
thinkingStatus.value = '📋 正在生成旅行方案...'
} else if (streamContent.includes('points')) {
thinkingStatus.value = '📍 正在规划详细行程...'
}
})
isGenerating.value = false
// Add bot response message with thinking process
const botMsg = {
role: 'bot',
content: `已为你生成 ${result.schemes.length} 个方案,请查看下方卡片。\n\n${result.schemes.map((s, i) => `【方案${String.fromCharCode(65 + i)}${s.name}\n路线${s.route}\n${s.days}天 · ${s.totalKm}km · ${s.budget}`).join('\n\n')}`,
thinking: result.thinking,
thinkingExpanded: false
}
messages.value.push(botMsg)
// Set schemes for card display
schemes.value = result.schemes
await scrollToBottom()
} catch (error) {
isGenerating.value = false
messages.value.push({
role: 'bot',
content: '抱歉,生成方案时遇到了问题。',
error: error.message.includes('API Key')
? '请先访问 /settings 配置你的 AI API Key'
: error.message + ',请重试'
})
await scrollToBottom()
}
}
const selectScheme = (index) => {
const scheme = schemes.value[index]
if (!scheme) return
// Load the selected scheme into the itinerary store
store.loadFromAI(scheme)
store.setPhase('workbench')
}
</script>
<style scoped>
.chat-interface {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
position: relative;
}
.chat-header {
text-align: center;
padding: 30px 20px;
background: linear-gradient(135deg, #1b4332, #2d6a4f);
color: #fff;
}
.chat-header h1 { margin: 0 0 8px; font-size: 24px; }
.chat-header p { margin: 0; opacity: 0.8; font-size: 14px; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
max-width: 85%;
}
.message.bot { align-self: flex-start; }
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: #e8f5e9;
flex-shrink: 0;
}
.message.user .avatar { background: #e3f2fd; }
.bubble {
padding: 12px 16px;
border-radius: 16px;
font-size: 14px;
line-height: 1.6;
}
.message.bot .bubble {
background: #fff;
color: #333;
border-bottom-left-radius: 4px;
}
.message.user .bubble {
background: #2a9d8f;
color: #fff;
border-bottom-right-radius: 4px;
}
.example {
opacity: 0.8;
font-size: 13px;
}
/* Thinking process */
.thinking-process {
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.thinking-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8f9fa;
cursor: pointer;
font-size: 13px;
color: #666;
user-select: none;
}
.thinking-header:hover { background: #f0f0f0; }
.thinking-toggle {
margin-left: auto;
font-size: 12px;
color: #999;
}
.thinking-content {
padding: 12px;
font-size: 13px;
color: #555;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
background: #fafafa;
}
.thinking-process:not(.expanded) .thinking-content {
display: none;
}
/* Error hint */
.error-hint {
margin-top: 8px;
padding: 8px 12px;
background: #fff3cd;
border-radius: 6px;
font-size: 12px;
color: #856404;
}
.message-content {
white-space: pre-wrap;
}
/* Loading */
.thinking {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2a9d8f;
animation: pulse 1.4s infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* Input */
.chat-input-area {
display: flex;
gap: 8px;
padding: 16px 20px;
background: #fff;
border-top: 1px solid #eee;
}
.chat-input-area input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 24px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area input:focus { border-color: #2a9d8f; }
.chat-input-area input:disabled { background: #f5f5f5; cursor: not-allowed; }
.chat-input-area button {
padding: 12px 24px;
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 24px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.chat-input-area button:hover:not(:disabled) { background: #21867a; }
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Scheme cards */
.scheme-cards {
position: absolute;
bottom: 80px;
left: 20px;
right: 20px;
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.1);
max-height: 60vh;
overflow-y: auto;
}
.scheme-cards h3 {
margin: 0 0 16px;
font-size: 16px;
color: #1b4332;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.scheme-card {
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.scheme-card:hover {
border-color: #2a9d8f;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(42,157,143,0.2);
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.card-badge {
background: #f4a261;
color: #1b4332;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.card-header h4 {
margin: 0;
font-size: 14px;
color: #333;
}
.card-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.card-highlights {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.highlight-tag {
background: #e8f5e9;
color: #2a9d8f;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.card-route {
font-size: 11px;
color: #888;
margin-bottom: 12px;
padding: 6px;
background: #f8f9fa;
border-radius: 6px;
}
.card-action {
text-align: center;
color: #2a9d8f;
font-weight: 600;
font-size: 13px;
}
.slide-up-enter-active, .slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from, .slide-up-leave-to {
transform: translateY(20px);
opacity: 0;
}
@media (max-width: 900px) {
.cards-grid {
grid-template-columns: 1fr;
}
}
</style>