feat: 完善对话式规划、快速规划等功能

master
Lxy 1 week ago
parent aaa7679eb1
commit 3f13a42fb1

27
package-lock.json generated

@ -8,6 +8,7 @@
"name": "trip-planner", "name": "trip-planner",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"cors": "^2.8.6",
"express": "^4.22.2", "express": "^4.22.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@ -1108,6 +1109,23 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -1640,6 +1658,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.6",
"express": "^4.22.2", "express": "^4.22.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",

@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import { createServer as createViteServer } from 'vite' import { spawn } from 'child_process'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@ -43,7 +43,15 @@ function saveConfig(config) {
} }
} }
import cors from 'cors'
const app = express() const app = express()
app.use(cors({
origin: 'http://localhost:5173',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type'],
exposedHeaders: ['Content-Type']
}))
app.use(express.json()) app.use(express.json())
// API: Get config // API: Get config
@ -122,6 +130,7 @@ app.post('/api/config/test', async (req, res) => {
// API: Proxy AI chat request (keeps API key on server) // API: Proxy AI chat request (keeps API key on server)
app.post('/api/chat', async (req, res) => { app.post('/api/chat', async (req, res) => {
console.log('[api/chat] POST received, body:', JSON.stringify(req.body).substring(0, 100))
const config = loadConfig() const config = loadConfig()
if (!config.apiKey) { if (!config.apiKey) {
@ -130,7 +139,10 @@ app.post('/api/chat', async (req, res) => {
// Handle client disconnect // Handle client disconnect
let clientDisconnected = false let clientDisconnected = false
req.on('close', () => { clientDisconnected = true }) req.socket.on('close', () => {
clientDisconnected = true
console.log('[api/chat] Client socket closed')
})
try { try {
const response = await fetch(`${config.baseUrl}/chat/completions`, { const response = await fetch(`${config.baseUrl}/chat/completions`, {
@ -143,8 +155,7 @@ app.post('/api/chat', async (req, res) => {
...req.body, ...req.body,
model: req.body.model || config.model, model: req.body.model || config.model,
temperature: req.body.temperature ?? config.temperature, temperature: req.body.temperature ?? config.temperature,
max_tokens: req.body.max_tokens || config.maxTokens, max_tokens: req.body.max_tokens || config.maxTokens
response_format: { type: 'json_object' }
}) })
}) })
@ -153,14 +164,19 @@ app.post('/api/chat', async (req, res) => {
return res.status(response.status).json(error) return res.status(response.status).json(error)
} }
console.log('[api/chat] AI response status:', response.status)
// Stream the response to client // Stream the response to client
res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Content-Type', 'application/x-ndjson')
res.setHeader('Cache-Control', 'no-cache') res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive') res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') res.setHeader('X-Accel-Buffering', 'no')
console.log('[api/chat] Streaming started')
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let totalBytes = 0
let chunkCount = 0
while (true) { while (true) {
if (clientDisconnected) { if (clientDisconnected) {
@ -171,15 +187,17 @@ app.post('/api/chat', async (req, res) => {
if (done) break if (done) break
const chunk = decoder.decode(value, { stream: true }) const chunk = decoder.decode(value, { stream: true })
try { totalBytes += chunk.length
chunkCount++
res.write(chunk) res.write(chunk)
} catch (e) {
// Client disconnected, stop writing if (chunkCount % 10 === 0) {
reader.cancel() console.log('[api/chat] Chunk count:', chunkCount, 'bytes:', totalBytes)
return
} }
} }
console.log('[api/chat] Streaming done, total chunks:', chunkCount, 'bytes:', totalBytes)
res.end() res.end()
} catch (error) { } catch (error) {
if (!clientDisconnected) { if (!clientDisconnected) {
@ -188,22 +206,35 @@ app.post('/api/chat', async (req, res) => {
} }
}) })
// Catch-all for debugging
app.all('/api/*', (req, res) => {
console.log('[DEBUG CATCH-ALL]', req.method, req.path)
res.status(404).json({ error: 'Route not found' })
})
async function start() { async function start() {
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
if (isDev) { if (isDev) {
// Development: use Vite middleware console.log('[Server] Starting Vite dev server...')
const vite = await createViteServer({ // Run Vite dev server on port 5173
server: { middlewareMode: true }, // API routes are handled by Express on port 3001
appType: 'spa' // We use a simple approach: start both servers
const viteProcess = spawn('npx', ['vite'], {
stdio: 'inherit',
shell: true,
env: { ...process.env, NODE_ENV: 'development' }
}) })
app.use(vite.middlewares) viteProcess.on('error', (err) => {
console.error('[Server] Failed to start Vite:', err)
})
const port = 5173 // Start Express API server on port 3001
app.listen(port, () => { const apiPort = 3001
console.log(`Server running at http://localhost:${port}`) app.listen(apiPort, () => {
console.log(`API Key stored in: ${CONFIG_FILE}`) console.log(`[Server] API server running at http://localhost:${apiPort}`)
console.log(`[Server] API Key stored in: ${CONFIG_FILE}`)
}) })
} else { } else {
// Production: serve static files // Production: serve static files

@ -0,0 +1,473 @@
<template>
<div class="custom-plan">
<div class="custom-header">
<h1> 自定义行程</h1>
<p>输入你的行程安排AI 帮你评估合理性并给出优化建议</p>
</div>
<!-- Input form -->
<div class="custom-form">
<div class="form-group">
<label>行程安排</label>
<textarea
v-model="itineraryText"
placeholder="请按格式输入你的行程安排,例如:&#10;Day 1昆明 -> 大理,上午出发,下午逛大理古城&#10;Day 2大理 -> 丽江,上午游览洱海,下午前往丽江&#10;Day 3丽江一日游游览玉龙雪山、束河古镇&#10;Day 4丽江返程&#10;&#10;也可以自由描述AI 会自动解析..."
:disabled="isGenerating"
rows="10"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>出行方式</label>
<select v-model="transport" :disabled="isGenerating">
<option value="自驾">自驾</option>
<option value="高铁">高铁</option>
<option value="飞机">飞机</option>
<option value="公共交通">公共交通</option>
</select>
</div>
<div class="form-group">
<label>同行人员</label>
<select v-model="companions" :disabled="isGenerating">
<option value="独自">独自</option>
<option value="情侣">情侣</option>
<option value="朋友">朋友</option>
<option value="带老人">带老人</option>
<option value="带小孩">带小孩</option>
<option value="家庭出游">家庭出游</option>
</select>
</div>
</div>
<button
class="generate-btn"
@click="evaluatePlan"
:disabled="!itineraryText.trim() || isGenerating"
>
{{ isGenerating ? '评估中...' : 'AI 评估行程' }}
</button>
</div>
<!-- AI Thinking process -->
<div v-if="isGenerating" class="thinking-section">
<div class="thinking-header" @click="thinkingExpanded = !thinkingExpanded">
<div class="thinking-spinner"></div>
<span class="thinking-title">AI 正在评估中...</span>
<span class="thinking-toggle">{{ thinkingExpanded ? '收起' : '展开详情' }}</span>
</div>
<transition name="expand">
<div v-if="thinkingExpanded" class="thinking-content">
<div class="thinking-text">{{ thinkingText || '正在初始化...' }}</div>
</div>
</transition>
</div>
<!-- Loading (fallback) -->
<div v-if="isGenerating && !thinkingText" class="loading-section">
<div class="loading-spinner"></div>
<p>{{ loadingText }}</p>
</div>
<!-- Evaluation result -->
<transition name="fade-up">
<div v-if="evaluationResult" class="result-section">
<!-- Summary -->
<div class="result-card summary-card">
<h3>{{ evaluationResult.isReasonable ? '✅ 行程合理' : '⚠️ 行程有待优化' }}</h3>
<p>{{ evaluationResult.summary }}</p>
</div>
<!-- Suggestions -->
<div v-if="evaluationResult.suggestions && evaluationResult.suggestions.length > 0" class="result-card suggestion-card">
<h3>优化建议</h3>
<ul>
<li v-for="(s, i) in evaluationResult.suggestions" :key="i">{{ s }}</li>
</ul>
</div>
<!-- Generated itinerary -->
<div v-if="generatedScheme" class="result-card itinerary-card">
<h3>优化后的行程方案</h3>
<div class="route-preview">{{ generatedScheme.route }}</div>
<div class="stats-row">
<span>{{ generatedScheme.days }}</span>
<span>~{{ generatedScheme.totalKm }}km</span>
<span>{{ generatedScheme.budget }}</span>
</div>
<button class="use-btn" @click="useOptimizedPlan">使</button>
</div>
<!-- Original plan option -->
<div v-if="originalScheme" class="result-card original-card">
<h3>按你的原始输入生成</h3>
<div class="route-preview">{{ originalScheme.route }}</div>
<button class="use-btn original-btn" @click="useOriginalPlan">使</button>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
import { customPlan } from '../services/aiService'
const store = useItineraryStore()
const itineraryText = ref('')
const transport = ref('自驾')
const companions = ref('独自')
const isGenerating = ref(false)
const loadingText = ref('正在分析行程安排...')
const thinkingText = ref('')
const thinkingExpanded = ref(true)
const evaluationResult = ref(null)
const generatedScheme = ref(null)
const originalScheme = ref(null)
const evaluatePlan = async () => {
if (!itineraryText.value.trim() || isGenerating.value) return
isGenerating.value = true
evaluationResult.value = null
generatedScheme.value = null
originalScheme.value = null
thinkingText.value = ''
thinkingExpanded.value = true
loadingText.value = '正在解析你的行程安排...'
try {
const userInput = {
itinerary: itineraryText.value.trim(),
transport: transport.value,
companions: companions.value
}
const result = await customPlan(userInput, (text) => {
thinkingText.value = text
})
evaluationResult.value = result.evaluation
if (result.optimizedScheme) {
generatedScheme.value = result.optimizedScheme
}
if (result.originalScheme) {
originalScheme.value = result.originalScheme
}
} catch (error) {
alert('评估失败:' + (error.message || '请重试'))
} finally {
isGenerating.value = false
}
}
const useOptimizedPlan = () => {
if (generatedScheme.value) {
store.loadFromAI(generatedScheme.value)
store.setPhase('workbench')
}
}
const useOriginalPlan = () => {
if (originalScheme.value) {
store.loadFromAI(originalScheme.value)
store.setPhase('workbench')
}
}
</script>
<style scoped>
.custom-plan {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
overflow-y: auto;
}
.custom-header {
text-align: center;
padding: 30px 20px;
background: linear-gradient(135deg, #264653, #2a9d8f);
color: #fff;
}
.custom-header h1 { margin: 0 0 8px; font-size: 24px; }
.custom-header p { margin: 0; opacity: 0.9; font-size: 14px; }
.custom-form {
max-width: 700px;
margin: 30px auto;
padding: 24px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: #555;
}
.form-group textarea {
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
outline: none;
resize: vertical;
transition: border-color 0.2s;
line-height: 1.6;
}
.form-group textarea:focus {
border-color: #2a9d8f;
}
.form-group textarea:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-group select {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.form-group select:focus {
border-color: #2a9d8f;
}
.form-group select:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.generate-btn {
width: 100%;
padding: 14px;
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.generate-btn:hover:not(:disabled) { background: #21867a; }
.generate-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.thinking-section {
max-width: 700px;
margin: 20px auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}
.thinking-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
cursor: pointer;
user-select: none;
}
.thinking-spinner {
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #2a9d8f;
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
.thinking-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #333;
}
.thinking-toggle {
font-size: 12px;
color: #999;
}
.thinking-content {
border-top: 1px solid #f0f0f0;
}
.thinking-text {
padding: 16px 20px;
font-size: 13px;
color: #555;
line-height: 1.8;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
background: #fafafa;
}
.expand-enter-active, .expand-leave-active {
transition: all 0.3s ease;
max-height: 300px;
overflow: hidden;
}
.expand-enter-from, .expand-leave-to {
max-height: 0;
opacity: 0;
}
.loading-section {
text-align: center;
padding: 40px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2a9d8f;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-section p {
color: #666;
font-size: 14px;
}
.result-section {
max-width: 700px;
margin: 0 auto 40px;
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.result-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.result-card h3 {
margin: 0 0 12px;
font-size: 16px;
color: #333;
}
.summary-card {
border-left: 4px solid #2a9d8f;
}
.summary-card p {
margin: 0;
color: #555;
line-height: 1.6;
}
.suggestion-card {
border-left: 4px solid #f4a261;
}
.suggestion-card ul {
margin: 0;
padding-left: 20px;
color: #555;
line-height: 1.8;
}
.itinerary-card {
border-left: 4px solid #264653;
}
.original-card {
border-left: 4px solid #e76f51;
}
.route-preview {
font-size: 13px;
color: #666;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
margin-bottom: 10px;
}
.stats-row {
display: flex;
gap: 16px;
font-size: 13px;
color: #888;
margin-bottom: 14px;
}
.use-btn {
width: 100%;
padding: 12px;
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.use-btn:hover { background: #21867a; }
.original-btn {
background: #e76f51;
}
.original-btn:hover { background: #d35f44; }
.fade-up-enter-active, .fade-up-leave-active {
transition: all 0.3s ease;
}
.fade-up-enter-from, .fade-up-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>

@ -0,0 +1,936 @@
<template>
<div class="quick-plan">
<!-- ========== List View ========== -->
<template v-if="viewMode === 'list'">
<div class="quick-header">
<button class="back-btn" @click="goBack" v-if="allSchemes.length > 0"></button>
<h1>快速规划</h1>
<p>输入目的地和天数立即生成多个旅行方案</p>
</div>
<!-- Input form -->
<div v-if="allSchemes.length === 0" class="quick-form">
<div class="form-row">
<div class="form-group">
<label>目的地</label>
<input v-model="destination" placeholder="如:云南、贵州、四川" :disabled="isGenerating" />
</div>
<div class="form-group">
<label>出行天数</label>
<select v-model="days" :disabled="isGenerating">
<option v-for="d in 15" :key="d" :value="d">{{ d }}</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>出发日期</label>
<input type="date" v-model="startDate" :disabled="isGenerating" />
</div>
<div class="form-group">
<label>出行方式</label>
<select v-model="transport" :disabled="isGenerating">
<option value="自驾">自驾</option>
<option value="高铁">高铁</option>
<option value="飞机">飞机</option>
<option value="公共交通">公共交通</option>
</select>
</div>
</div>
<div class="form-group full-width">
<label>其他需求选填</label>
<input v-model="extraNeeds" placeholder="如:带老人小孩、预算范围、必去景点等" :disabled="isGenerating" />
</div>
<button class="generate-btn" @click="generatePlan" :disabled="!destination.trim() || isGenerating">
{{ isGenerating ? '生成中...' : '立即生成方案' }}
</button>
</div>
<!-- AI Thinking process -->
<div v-if="isGenerating" class="thinking-section">
<div class="thinking-header" @click="thinkingExpanded = !thinkingExpanded">
<div class="thinking-spinner"></div>
<span class="thinking-title">AI 正在规划中...</span>
<span class="thinking-toggle">{{ thinkingExpanded ? '收起' : '展开详情' }}</span>
</div>
<transition name="expand">
<div v-if="thinkingExpanded" class="thinking-content">
<div class="thinking-text">{{ thinkingText || '正在初始化...' }}</div>
</div>
</transition>
</div>
<!-- Loading (fallback) -->
<div v-if="isGenerating && !thinkingText" class="loading-section">
<div class="loading-spinner"></div>
<p>{{ loadingText }}</p>
</div>
<!-- Regenerate button -->
<div v-if="allSchemes.length > 0 && !isGenerating" class="regenerate-row">
<button class="regenerate-btn" @click="generatePlan" :disabled="isGenerating">
重新生成方案
</button>
<span class="scheme-count">已生成 {{ allSchemes.length }}/9 个方案</span>
</div>
<!-- Current schemes -->
<transition name="fade-up">
<div v-if="allSchemes.length > 0" class="schemes-section">
<h3>当前方案</h3>
<div class="cards-grid">
<div
v-for="(scheme, i) in allSchemes"
:key="'current-' + i"
class="scheme-card"
@click="selectScheme(i, 'current')"
>
<div class="card-badge">{{ getSchemeLabel(i) }}</div>
<h4>{{ scheme.name }}</h4>
<div class="card-stats">
<span>{{ scheme.days }}</span>
<span>~{{ scheme.totalKm }}km</span>
<span>{{ scheme.budget }}</span>
</div>
<div class="card-route">{{ scheme.route }}</div>
<div class="card-highlights">
<span v-for="(h, j) in scheme.highlights" :key="j">{{ h }}</span>
</div>
<div class="card-action">查看此方案 </div>
</div>
</div>
</div>
</transition>
<!-- History section -->
<transition name="fade-up">
<div v-if="historySchemes.length > 0" class="history-section">
<h3 @click="historyExpanded = !historyExpanded" style="cursor:pointer;">
历史方案 ({{ historySchemes.length }})
<span class="toggle-icon">{{ historyExpanded ? '▲' : '▼' }}</span>
</h3>
<transition name="expand">
<div v-if="historyExpanded" class="history-grid">
<div
v-for="(group, gi) in historySchemes"
:key="'history-' + gi"
class="history-group"
>
<div class="group-label"> {{ gi + 1 }} </div>
<div
v-for="(scheme, si) in group.schemes"
:key="si"
class="history-card"
@click="selectScheme(si, 'history', gi)"
>
<div class="card-badge">{{ getSchemeLabel(si) }}</div>
<h5>{{ scheme.name }}</h5>
<div class="card-stats">
<span>{{ scheme.days }}</span>
<span>~{{ scheme.totalKm }}km</span>
</div>
<div class="card-action">查看详情 </div>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<!-- ========== Detail View ========== -->
<template v-else-if="viewMode === 'detail'">
<div class="detail-view">
<div class="detail-header">
<button class="back-btn" @click="goBackToList"></button>
<h2>{{ currentDetailScheme.name }}</h2>
<div class="detail-meta">
<span>{{ currentDetailScheme.days }}</span>
<span>{{ currentDetailScheme.route }}</span>
<span>预算 {{ currentDetailScheme.budget }}</span>
</div>
<button class="use-btn" @click="useThisScheme">使</button>
</div>
<!-- Scheme switcher -->
<div class="scheme-switcher">
<div class="switcher-label">切换方案:</div>
<div class="switcher-btns">
<template v-if="currentDetailSource === 'current'">
<button
v-for="(s, i) in allSchemes"
:key="'switch-' + i"
:class="['switch-btn', { active: currentDetailIndex === i }]"
@click="selectScheme(i, 'current')"
>
{{ getSchemeLabel(i) }}
</button>
</template>
<template v-else>
<button
v-for="(s, i) in (historySchemes[currentDetailHistoryGroup]?.schemes || [])"
:key="'switch-h-' + i"
:class="['switch-btn', { active: currentDetailIndex === i }]"
@click="selectScheme(i, 'history', currentDetailHistoryGroup)"
>
{{ getSchemeLabel(i) }}
</button>
</template>
</div>
</div>
<!-- Detail content -->
<div class="detail-content">
<div class="detail-highlights">
<h3>亮点</h3>
<ul>
<li v-for="(h, i) in currentDetailScheme.highlights" :key="i">{{ h }}</li>
</ul>
</div>
<div v-if="currentDetailScheme.daysDetail" class="detail-days">
<h3>每日行程</h3>
<div
v-for="(day, di) in currentDetailScheme.daysDetail"
:key="di"
class="day-card"
>
<div class="day-header">
<span class="day-badge">Day {{ di + 1 }}</span>
<span class="day-title">{{ day.location }}</span>
</div>
<div class="day-content">
<p class="day-desc">{{ day.desc }}</p>
<div v-if="day.spots && day.spots.length > 0" class="day-spots">
<span v-for="(spot, si) in day.spots" :key="si" class="spot-tag">{{ spot }}</span>
</div>
</div>
</div>
</div>
<div v-if="currentDetailScheme.tips" class="detail-tips">
<h3>旅行贴士</h3>
<p>{{ currentDetailScheme.tips }}</p>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
import { quickPlan } from '../services/aiService'
const store = useItineraryStore()
// View modes: 'list' | 'detail'
const viewMode = ref('list')
// Use store's shared scheme state
const allSchemes = computed(() => store.quickSchemes)
const historySchemes = computed(() => store.historySchemeGroups)
const historyExpanded = ref(false)
const MAX_SCHEMES = 9
// Form fields
const destination = ref('')
const days = ref(3)
const startDate = ref('')
const transport = ref('自驾')
const extraNeeds = ref('')
const isGenerating = ref(false)
const loadingText = ref('正在分析目的地并规划路线...')
const thinkingText = ref('')
const thinkingExpanded = ref(true)
// Detail view state
const currentDetailIndex = ref(-1)
const currentDetailSource = ref('current') // 'current' | 'history'
const currentDetailHistoryGroup = ref(-1)
const currentDetailScheme = computed(() => {
if (currentDetailSource.value === 'current' && currentDetailIndex.value >= 0) {
return allSchemes.value[currentDetailIndex.value] || {}
}
if (currentDetailSource.value === 'history' && currentDetailHistoryGroup.value >= 0) {
return historySchemes.value[currentDetailHistoryGroup.value]?.schemes?.[currentDetailIndex.value] || {}
}
return {}
})
function getSchemeLabel(i) {
return '方案' + String.fromCharCode(65 + (i % 26))
}
async function generatePlan() {
if (!destination.value.trim() || isGenerating.value) return
isGenerating.value = true
thinkingText.value = '发送请求中...'
thinkingExpanded.value = true
loadingText.value = '正在分析目的地并规划路线...'
try {
const userInput = {
destination: destination.value.trim(),
days: days.value,
startDate: startDate.value || '近期',
transport: transport.value,
extraNeeds: extraNeeds.value.trim() || '无特殊需求'
}
const result = await quickPlan(userInput, (text) => {
thinkingText.value = text
})
const newSchemes = result.schemes || []
const currentSchemes = [...allSchemes.value]
const currentHistory = [...historySchemes.value]
// Archive old schemes to history if we exceed MAX_SCHEMES
if (currentSchemes.length + newSchemes.length > MAX_SCHEMES) {
const toArchive = currentSchemes.slice(0, 3)
currentHistory.push({
schemes: toArchive,
timestamp: Date.now()
})
currentSchemes.splice(0, 3)
}
// Add new schemes
currentSchemes.push(...newSchemes)
// Cap at MAX_SCHEMES
if (currentSchemes.length > MAX_SCHEMES) {
const excess = currentSchemes.splice(MAX_SCHEMES)
currentHistory.push({ schemes: excess, timestamp: Date.now() })
}
// Save to store
store.saveSchemesToStore(currentSchemes, currentHistory)
} catch (error) {
thinkingText.value = '生成失败:' + (error.message || '请重试')
} finally {
isGenerating.value = false
}
}
function selectScheme(index, source, historyGroup) {
currentDetailIndex.value = index
currentDetailSource.value = source
currentDetailHistoryGroup.value = historyGroup ?? -1
viewMode.value = 'detail'
}
function useThisScheme() {
const scheme = currentDetailScheme.value
if (!scheme) return
store.saveSchemesToStore(allSchemes.value, historySchemes.value)
const idx = currentDetailSource.value === 'current' ? currentDetailIndex.value : -1
store.setActiveSchemeIndex(idx)
store.loadFromAI(scheme)
store.setPhase('workbench')
}
function goBack() {
store.resetToModeSelection()
}
function goBackToList() {
viewMode.value = 'list'
}
// Restore state from store on mount
onMounted(() => {
if (store.quickSchemes.length > 0) {
viewMode.value = 'list'
}
})
</script>
<style scoped>
.quick-plan {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
overflow-y: auto;
}
.quick-header {
text-align: center;
padding: 30px 20px 20px;
background: linear-gradient(135deg, #e76f51, #f4a261);
color: #fff;
position: relative;
}
.quick-header h1 { margin: 0 0 8px; font-size: 24px; }
.quick-header p { margin: 0; opacity: 0.9; font-size: 14px; }
.back-btn {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
padding: 8px 14px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.back-btn:hover { background: rgba(255,255,255,0.3); }
.quick-form {
max-width: 600px;
margin: 20px auto;
padding: 24px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.full-width { margin-bottom: 16px; }
.form-group label {
font-size: 13px;
font-weight: 600;
color: #555;
}
.form-group input,
.form-group select {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
border-color: #e76f51;
}
.form-group input:disabled,
.form-group select:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.generate-btn {
width: 100%;
padding: 14px;
background: #e76f51;
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.generate-btn:hover:not(:disabled) { background: #d35f44; }
.generate-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.regenerate-row {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 900px;
margin: 16px auto 0;
padding: 0 20px;
}
.regenerate-btn {
padding: 10px 20px;
background: #e76f51;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.regenerate-btn:hover:not(:disabled) { background: #d35f44; }
.regenerate-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.scheme-count {
font-size: 13px;
color: #666;
}
.thinking-section {
max-width: 600px;
margin: 16px auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}
.thinking-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
cursor: pointer;
user-select: none;
}
.thinking-spinner {
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #e76f51;
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
.thinking-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #333;
}
.thinking-toggle {
font-size: 12px;
color: #999;
}
.thinking-content {
border-top: 1px solid #f0f0f0;
}
.thinking-text {
padding: 16px 20px;
font-size: 13px;
color: #555;
line-height: 1.8;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
background: #fafafa;
}
.expand-enter-active, .expand-leave-active {
transition: all 0.3s ease;
max-height: 300px;
overflow: hidden;
}
.expand-enter-from, .expand-leave-to {
max-height: 0;
opacity: 0;
}
.loading-section {
text-align: center;
padding: 40px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #e76f51;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-section p { color: #666; font-size: 14px; }
.schemes-section, .history-section {
max-width: 900px;
margin: 16px auto 40px;
padding: 0 20px;
}
.schemes-section h3, .history-section h3 {
margin: 0 0 16px;
font-size: 18px;
color: #333;
}
.toggle-icon {
font-size: 12px;
margin-left: 8px;
color: #999;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.scheme-card {
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.2s;
}
.scheme-card:hover {
border-color: #e76f51;
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(231,111,81,0.15);
}
.card-badge {
display: inline-block;
background: #e76f51;
color: #fff;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-bottom: 10px;
}
.scheme-card h4 {
margin: 0 0 12px;
font-size: 16px;
color: #333;
}
.card-stats {
display: flex;
gap: 12px;
font-size: 13px;
color: #666;
margin-bottom: 10px;
}
.card-route {
font-size: 12px;
color: #888;
margin-bottom: 10px;
padding: 6px 8px;
background: #f8f9fa;
border-radius: 6px;
}
.card-highlights {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.card-highlights span {
background: #fff3e0;
color: #e76f51;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.card-action {
text-align: center;
color: #e76f51;
font-weight: 600;
font-size: 14px;
}
/* History */
.history-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-group {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.group-label {
font-size: 12px;
font-weight: 600;
color: #999;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid #f0f0f0;
}
.history-card {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.history-card:last-child { margin-bottom: 0; }
.history-card:hover {
border-color: #e76f51;
background: #fff;
}
.history-card h5 {
margin: 8px 0 6px;
font-size: 14px;
color: #333;
}
.fade-up-enter-active, .fade-up-leave-active {
transition: all 0.3s ease;
}
.fade-up-enter-from, .fade-up-leave-to {
transform: translateY(20px);
opacity: 0;
}
/* Detail View */
.detail-view {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
overflow-y: auto;
}
.detail-header {
background: linear-gradient(135deg, #e76f51, #f4a261);
color: #fff;
padding: 20px 20px 24px;
position: relative;
}
.detail-header h2 {
margin: 12px 0 8px;
font-size: 22px;
}
.detail-meta {
display: flex;
gap: 16px;
font-size: 14px;
opacity: 0.9;
margin-bottom: 12px;
flex-wrap: wrap;
}
.use-btn {
padding: 12px 24px;
background: #fff;
color: #e76f51;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.use-btn:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* Scheme switcher */
.scheme-switcher {
background: #fff;
padding: 12px 20px;
border-bottom: 1px solid #e0e0e0;
}
.switcher-label {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
}
.switcher-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.switch-btn {
padding: 6px 14px;
border: 1px solid #ddd;
background: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
color: #555;
}
.switch-btn:hover {
border-color: #e76f51;
color: #e76f51;
}
.switch-btn.active {
background: #e76f51;
color: #fff;
border-color: #e76f51;
}
/* Detail content */
.detail-content {
max-width: 800px;
margin: 0 auto;
padding: 20px;
width: 100%;
}
.detail-highlights {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.detail-highlights h3 {
margin: 0 0 12px;
font-size: 16px;
color: #333;
}
.detail-highlights ul {
margin: 0;
padding-left: 20px;
}
.detail-highlights li {
margin-bottom: 6px;
color: #555;
line-height: 1.6;
}
.detail-days {
margin-bottom: 16px;
}
.detail-days h3 {
margin: 0 0 12px;
font-size: 16px;
color: #333;
}
.day-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.day-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.day-badge {
background: #e76f51;
color: #fff;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.day-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.day-content {
padding-left: 10px;
}
.day-desc {
margin: 0 0 10px;
font-size: 14px;
color: #555;
line-height: 1.6;
}
.day-spots {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.spot-tag {
background: #f0f7ff;
color: #2196f3;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
}
.detail-tips {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.detail-tips h3 {
margin: 0 0 10px;
font-size: 16px;
color: #333;
}
.detail-tips p {
margin: 0;
font-size: 14px;
color: #555;
line-height: 1.6;
}
@media (max-width: 768px) {
.form-row { flex-direction: column; }
.cards-grid { grid-template-columns: 1fr; }
}
</style>

@ -15,6 +15,7 @@
<!-- Top bar --> <!-- Top bar -->
<div class="workbench-header"> <div class="workbench-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack" title="返回方案预览"> 返回</button>
<h1>🚗 {{ itineraryTitle }}</h1> <h1>🚗 {{ itineraryTitle }}</h1>
<span class="subtitle">{{ itinerarySubtitle }}</span> <span class="subtitle">{{ itinerarySubtitle }}</span>
</div> </div>
@ -26,6 +27,26 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Scheme switcher (only when quick schemes exist) -->
<div v-if="store.quickSchemes.length > 0" class="scheme-switcher-wrap">
<button class="switcher-btn" @click="schemeDropdownOpen = !schemeDropdownOpen">
方案 {{ getSchemeLabel(store.activeSchemeIndex) }}
</button>
<transition name="dropdown">
<div v-if="schemeDropdownOpen" class="scheme-dropdown">
<div class="dropdown-label">切换方案</div>
<div
v-for="(s, i) in store.quickSchemes"
:key="'ws-' + i"
:class="['dropdown-item', { active: store.activeSchemeIndex === i }]"
@click="switchScheme(i)"
>
<span class="item-badge">{{ getSchemeLabel(i) }}</span>
<span class="item-name">{{ s.name }}</span>
</div>
</div>
</transition>
</div>
<ModeToggle /> <ModeToggle />
<button class="route-btn" @click="handleFetchRoutes" :disabled="store.isFetchingRoutes" title="获取真实路线"> <button class="route-btn" @click="handleFetchRoutes" :disabled="store.isFetchingRoutes" title="获取真实路线">
{{ store.isFetchingRoutes ? '加载中...' : '🗺️ 真实路线' }} {{ store.isFetchingRoutes ? '加载中...' : '🗺️ 真实路线' }}
@ -52,7 +73,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useItineraryStore } from '../stores/itinerary' import { useItineraryStore } from '../stores/itinerary'
import ModeToggle from './ModeToggle.vue' import ModeToggle from './ModeToggle.vue'
import TimelinePanel from './TimelinePanel.vue' import TimelinePanel from './TimelinePanel.vue'
@ -64,8 +85,34 @@ import { generateStaticHTML } from '../utils/exporter'
const store = useItineraryStore() const store = useItineraryStore()
const mapView = ref(null) const mapView = ref(null)
const itineraryTitle = sampleItinerary.title const schemeDropdownOpen = ref(false)
const itinerarySubtitle = sampleItinerary.subtitle
// Use actual scheme data when available, fallback to mock
const itineraryTitle = computed(() => {
if (store.quickSchemes.length > 0 && store.activeSchemeIndex >= 0) {
const scheme = store.quickSchemes[store.activeSchemeIndex]
if (scheme) return scheme.name
}
return sampleItinerary.title
})
const itinerarySubtitle = computed(() => {
if (store.quickSchemes.length > 0 && store.activeSchemeIndex >= 0) {
const scheme = store.quickSchemes[store.activeSchemeIndex]
if (scheme) return scheme.route || `${scheme.days}天旅行规划`
}
return sampleItinerary.subtitle
})
function getSchemeLabel(i) {
if (i < 0) return '?'
return String.fromCharCode(65 + (i % 26))
}
function switchScheme(index) {
schemeDropdownOpen.value = false
store.loadSchemeByIndex(index)
}
const handleAnimate = (idx, fromStep) => { if (mapView.value) mapView.value.animateCar(idx, fromStep) } const handleAnimate = (idx, fromStep) => { if (mapView.value) mapView.value.animateCar(idx, fromStep) }
const handlePrevStep = () => { const handlePrevStep = () => {
@ -99,6 +146,11 @@ const handleExport = () => {
} }
const handleOptimize = () => { store.autoOptimizeRoute() } const handleOptimize = () => { store.autoOptimizeRoute() }
const goBack = () => {
store.setPlanningMode('quick')
store.setPhase('chat')
}
</script> </script>
<style scoped> <style scoped>
@ -117,14 +169,55 @@ const handleOptimize = () => { store.autoOptimizeRoute() }
.alert-btn.no { background: #e0e0e0; color: #666; } .alert-btn.no { background: #e0e0e0; color: #666; }
.workbench-header { .workbench-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 20px; display: flex; align-items: center; justify-content: space-between; padding: 10px 20px;
background: #1b4332; color: #fff; z-index: 10; flex-shrink: 0; background: #1b4332; color: #fff; z-index: 10000; flex-shrink: 0;
position: relative;
} }
.header-left h1 { margin: 0; font-size: 18px; } .header-left h1 { margin: 0; font-size: 18px; }
.subtitle { font-size: 12px; color: rgba(255,255,255,0.6); } .subtitle { font-size: 12px; color: rgba(255,255,255,0.6); }
.back-btn {
padding: 6px 12px; background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.3); color: #fff;
border-radius: 6px; cursor: pointer; font-size: 13px;
margin-right: 12px; transition: all 0.2s;
}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.header-center { flex: 1; display: flex; justify-content: center; } .header-center { flex: 1; display: flex; justify-content: center; }
.stats-bar { display: flex; gap: 20px; } .stats-bar { display: flex; gap: 20px; }
.stat-item { font-size: 13px; color: rgba(255,255,255,0.7); } .stat-item { font-size: 13px; color: rgba(255,255,255,0.7); }
.stat-item strong { color: #f4a261; margin-left: 4px; } .stat-item strong { color: #f4a261; margin-left: 4px; }
.scheme-switcher-wrap { position: relative; }
.switcher-btn {
padding: 6px 14px; background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.3); color: #fff;
border-radius: 6px; cursor: pointer; font-size: 13px;
margin-right: 8px; transition: all 0.2s;
}
.switcher-btn:hover { background: rgba(255,255,255,0.25); }
.scheme-dropdown {
position: fixed; top: 52px; right: 240px;
background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.2);
min-width: 240px; padding: 10px; z-index: 100000;
border: 1px solid #e0e0e0;
}
.dropdown-label {
font-size: 11px; font-weight: 600; color: #999;
padding-bottom: 6px; border-bottom: 1px solid #f0f0f0;
margin-bottom: 6px;
}
.dropdown-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 6px; cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover { background: #f5f5f5; }
.dropdown-item.active { background: #fff3e0; }
.item-badge {
background: #e76f51; color: #fff; padding: 2px 8px;
border-radius: 4px; font-size: 11px; font-weight: 600;
}
.item-name { font-size: 13px; color: #333; }
.dropdown-enter-active, .dropdown-leave-active { transition: all 0.2s ease; }
.dropdown-enter-from, .dropdown-leave-to { opacity: 0; transform: translateY(-8px); }
.header-right { display: flex; align-items: center; gap: 8px; } .header-right { display: flex; align-items: center; gap: 8px; }
.reset-btn, .export-btn, .route-btn { .reset-btn, .export-btn, .route-btn {
padding: 8px 12px; border: 1px solid rgba(255,255,255,0.2); color: #fff; padding: 8px 12px; border: 1px solid rgba(255,255,255,0.2); color: #fff;

@ -42,8 +42,140 @@ JSON 格式如下:
3. 至少 2 个方案最多 3 3. 至少 2 个方案最多 3
4. 直接输出 JSON不要其他内容` 4. 直接输出 JSON不要其他内容`
const QUICK_PLAN_PROMPT = `你是一个专业的旅行规划助手。根据用户指定的目的地和天数,快速生成 3 个不同的旅行方案。
## 重要输出格式
你必须直接输出 JSON不要包含任何 markdown 代码块标记不要在 JSON 前后添加任何额外文字
JSON 格式如下
{
"thinking": "你的思考过程",
"schemes": [
{
"name": "方案名称",
"route": "路线描述,如:昆明→大理→丽江→香格里拉",
"days": 5,
"totalKm": 800,
"totalDriveTime": 10,
"budget": "¥3000",
"highlights": ["亮点1", "亮点2", "亮点3"],
"daysDetail": [
{
"location": "当日所在地",
"desc": "当日行程描述",
"spots": ["景点1", "景点2", "景点3"]
}
],
"tips": "旅行贴士,如最佳季节、注意事项、必备物品等",
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "该地点简短描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程安排"}],
"foods": ["当地美食"],
"hotel": "推荐住宿",
"tips": "注意事项"
}
]
}
]
}
要求
1. 3 个方案风格不同经典路线深度游小众路线
2. lat/lng 必须是真实中国地理坐标
3. 行程安排要合理每天 1-2 个地点
4. daysDetail 数组长度必须等于 days每天一个对象
5. 直接输出 JSON不要其他内容`
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
1. 解析用户的行程安排
2. 评估行程是否合理时间是否充裕路线是否顺畅景点搭配是否合理
3. 如果不合理给出优化建议和优化后的方案
4. 同时也生成一个按用户原始输入直接生成的方案
## 重要输出格式
你必须直接输出 JSON不要包含任何 markdown 代码块标记不要在 JSON 前后添加任何额外文字
JSON 格式如下
{
"thinking": "你的分析过程",
"evaluation": {
"isReasonable": true,
"summary": "整体评价:行程基本合理/存在以下问题...",
"suggestions": ["建议1某天行程太紧建议...", "建议2A到B距离较远建议..."]
},
"optimizedScheme": {
"name": "优化方案",
"route": "优化后的路线",
"days": 4,
"totalKm": 800,
"totalDriveTime": 10,
"budget": "¥3000",
"highlights": ["亮点1", "亮点2"],
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程"}],
"foods": ["美食"],
"hotel": "住宿",
"tips": "注意事项"
}
]
},
"originalScheme": {
"name": "原始方案(按你的输入生成)",
"route": "原始路线",
"days": 4,
"totalKm": 900,
"totalDriveTime": 12,
"budget": "¥3500",
"highlights": ["亮点1", "亮点2"],
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程"}],
"foods": ["美食"],
"hotel": "住宿",
"tips": "注意事项"
}
]
}
}
注意
1. lat/lng 必须是真实中国地理坐标
2. optimizedScheme originalScheme 都要包含完整的 points 数组
3. 即使行程合理也要生成两个方案优化版和原版
4. 直接输出 JSON不要其他内容`
export async function chatWithAI(userMessage, onThinking = null) { export async function chatWithAI(userMessage, onThinking = null) {
const response = await fetch('/api/chat', { // Use Express API server directly (port 3001) instead of Vite proxy
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
const response = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -65,6 +197,7 @@ export async function chatWithAI(userMessage, onThinking = null) {
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let fullContent = '' let fullContent = ''
let reasoningContent = ''
let buffer = '' let buffer = ''
while (true) { while (true) {
@ -84,10 +217,16 @@ export async function chatWithAI(userMessage, onThinking = null) {
try { try {
const parsed = JSON.parse(data) const parsed = JSON.parse(data)
const chunk = parsed.choices?.[0]?.delta?.content || '' const delta = parsed.choices?.[0]?.delta || {}
// Support both reasoning_content (thinking models) and content
if (delta.reasoning_content) {
reasoningContent += delta.reasoning_content
if (onThinking) onThinking(reasoningContent)
}
const chunk = delta.content || ''
if (chunk) { if (chunk) {
fullContent += chunk fullContent += chunk
if (onThinking) onThinking(fullContent) if (onThinking) onThinking(fullContent || reasoningContent)
} }
} catch (e) { } catch (e) {
// Skip malformed JSON chunks // Skip malformed JSON chunks
@ -95,7 +234,7 @@ export async function chatWithAI(userMessage, onThinking = null) {
} }
} }
return parseAIResponse(fullContent) return parseAIResponse(fullContent || reasoningContent)
} }
function parseAIResponse(content) { function parseAIResponse(content) {
@ -138,3 +277,139 @@ function parseAIResponse(content) {
throw new Error('AI 返回的 JSON 格式有误,请重试') throw new Error('AI 返回的 JSON 格式有误,请重试')
} }
} }
// Shared streaming function
async function streamAIRequest(messages, onThinking = null) {
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
console.log('[streamAI] Fetching:', apiUrl)
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true })
})
console.log('[streamAI] Response status:', response.status)
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error?.message || `API 请求失败: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let fullContent = ''
let reasoningContent = ''
let buffer = ''
let chunkCount = 0
let lastUpdate = Date.now()
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('[streamAI] Done, total chunks:', chunkCount)
break
}
chunkCount++
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data:')) continue
const data = trimmed.slice(5).trim()
if (data === '[DONE]') break
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta || {}
if (delta.reasoning_content) {
reasoningContent += delta.reasoning_content
if (onThinking) {
onThinking(reasoningContent)
if (Date.now() - lastUpdate > 2000) {
console.log('[streamAI] reasoning_length:', reasoningContent.length)
lastUpdate = Date.now()
}
}
}
const chunk = delta.content || ''
if (chunk) fullContent += chunk
} catch (e) {
// Skip malformed JSON
}
}
}
console.log('[streamAI] fullContent:', fullContent.length, 'reasoningContent:', reasoningContent.length)
return fullContent || reasoningContent
}
function parseJSONFromContent(content) {
let cleaned = content.trim()
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) cleaned = codeBlockMatch[1].trim()
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim()
const firstBrace = cleaned.indexOf('{')
const lastBrace = cleaned.lastIndexOf('}')
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
throw new Error('AI 未返回有效的 JSON 数据')
}
const jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
try {
return JSON.parse(jsonStr)
} catch (e) {
console.error('JSON parse error:', e)
throw new Error('AI 返回的 JSON 格式有误,请重试')
}
}
export async function quickPlan(userInput, onThinking = null) {
const userMessage = `目的地:${userInput.destination}
出行天数${userInput.days}
出发日期${userInput.startDate}
出行方式${userInput.transport}
其他需求${userInput.extraNeeds}
请为我生成 3 个不同风格的旅行方案`
const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
const parsed = parseJSONFromContent(content)
if (!parsed.schemes || !Array.isArray(parsed.schemes) || parsed.schemes.length === 0) {
throw new Error('AI 返回结果缺少 schemes')
}
return parsed
}
export async function customPlan(userInput, onThinking = null) {
const userMessage = `我的行程安排:
${userInput.itinerary}
出行方式${userInput.transport}
同行人员${userInput.companions}
请评估我的行程是否合理并给出优化建议和两个方案优化版和原版`
const content = await streamAIRequest([
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
const parsed = parseJSONFromContent(content)
if (!parsed.evaluation) {
throw new Error('AI 返回结果缺少 evaluation')
}
return parsed
}

@ -6,6 +6,7 @@ import { getMultiRoute, detectDetour } from '../services/routeService'
export const useItineraryStore = defineStore('itinerary', () => { export const useItineraryStore = defineStore('itinerary', () => {
const phase = ref('chat') const phase = ref('chat')
const mode = ref('preview') const mode = ref('preview')
const planningMode = ref('') // '' | 'chat' | 'quick' | 'custom'
const points = ref(JSON.parse(JSON.stringify(sampleItinerary.points))) const points = ref(JSON.parse(JSON.stringify(sampleItinerary.points)))
const currentStep = ref(0) const currentStep = ref(0)
const selectedForReplace = ref(null) const selectedForReplace = ref(null)
@ -17,6 +18,11 @@ export const useItineraryStore = defineStore('itinerary', () => {
const detourWarnings = ref([]) const detourWarnings = ref([])
const hoveredReplacement = ref(null) const hoveredReplacement = ref(null)
// Shared scheme state (for QuickPlan & Workbench switching)
const quickSchemes = ref([]) // current schemes array
const historySchemeGroups = ref([]) // { schemes: [], timestamp }
const activeSchemeIndex = ref(-1) // index in quickSchemes of currently loaded scheme
const startPoint = computed(() => points.value.find(p => p.badge === 'START')) const startPoint = computed(() => points.value.find(p => p.badge === 'START'))
const endPoint = computed(() => points.value.find(p => p.badge === 'END')) const endPoint = computed(() => points.value.find(p => p.badge === 'END'))
@ -60,6 +66,11 @@ export const useItineraryStore = defineStore('itinerary', () => {
function setPhase(p) { phase.value = p } function setPhase(p) { phase.value = p }
function setMode(m) { mode.value = m } function setMode(m) { mode.value = m }
function setPlanningMode(m) { planningMode.value = m }
function resetToModeSelection() {
planningMode.value = ''
phase.value = 'chat'
}
function setCurrentStep(idx) { if (idx >= 0 && idx < points.value.length) currentStep.value = idx } function setCurrentStep(idx) { if (idx >= 0 && idx < points.value.length) currentStep.value = idx }
function nextStep() { if (currentStep.value < points.value.length - 1) currentStep.value++ } function nextStep() { if (currentStep.value < points.value.length - 1) currentStep.value++ }
function prevStep() { if (currentStep.value > 0) currentStep.value-- } function prevStep() { if (currentStep.value > 0) currentStep.value-- }
@ -178,7 +189,7 @@ export const useItineraryStore = defineStore('itinerary', () => {
function loadFromAI(scheme) { function loadFromAI(scheme) {
const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️'] const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️']
const points = scheme.points.map((p, i) => ({ const newPoints = scheme.points.map((p, i) => ({
id: 'p_' + Date.now() + '_' + i, id: 'p_' + Date.now() + '_' + i,
name: p.name, name: p.name,
lat: p.lat, lat: p.lat,
@ -204,7 +215,7 @@ export const useItineraryStore = defineStore('itinerary', () => {
tips: p.tips || '注意安全,提前预订' tips: p.tips || '注意安全,提前预订'
})) }))
points.value = points points.value = newPoints
currentStep.value = 0 currentStep.value = 0
routeSegments.value = [] routeSegments.value = []
showOptimizationAlert.value = false showOptimizationAlert.value = false
@ -213,14 +224,34 @@ export const useItineraryStore = defineStore('itinerary', () => {
hoveredReplacement.value = null hoveredReplacement.value = null
} }
function loadSchemeByIndex(index) {
if (index < 0 || index >= quickSchemes.value.length) return
activeSchemeIndex.value = index
loadFromAI(quickSchemes.value[index])
}
function saveSchemesToStore(schemes, historyGroups) {
quickSchemes.value = JSON.parse(JSON.stringify(schemes))
historySchemeGroups.value = JSON.parse(JSON.stringify(historyGroups))
}
function setActiveSchemeIndex(index) {
activeSchemeIndex.value = index
}
function getStoredSchemes() {
return { schemes: quickSchemes.value, history: historySchemeGroups.value }
}
return { return {
phase, mode, points, currentStep, selectedForReplace, showReplacementPanel, phase, mode, planningMode, points, currentStep, selectedForReplace, showReplacementPanel,
showOptimizationAlert, optimizationMessage, isFetchingRoutes, routeSegments, detourWarnings, showOptimizationAlert, optimizationMessage, isFetchingRoutes, routeSegments, detourWarnings,
hoveredReplacement, startPoint, endPoint, totalKm, totalDriveTime, travelDays, currentPoint, replacementOptions, hoveredReplacement, quickSchemes, historySchemeGroups, activeSchemeIndex,
setPhase, setMode, setCurrentStep, nextStep, prevStep, startPoint, endPoint, totalKm, totalDriveTime, travelDays, currentPoint, replacementOptions,
setPhase, setMode, setPlanningMode, resetToModeSelection, setCurrentStep, nextStep, prevStep,
deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample, deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample,
fetchRealRoutes, checkRouteOptimization, autoOptimizeRoute, dismissOptimization, fetchRealRoutes, checkRouteOptimization, autoOptimizeRoute, dismissOptimization,
setHoveredReplacement, loadFromAI setHoveredReplacement, loadFromAI, loadSchemeByIndex, saveSchemesToStore, setActiveSchemeIndex, getStoredSchemes
} }
}) })

@ -1,14 +1,176 @@
<template> <template>
<ChatInterface v-if="store.phase === 'chat'" /> <!-- Mode selection page -->
<div v-if="showModeSelection" class="mode-selection">
<div class="selection-header">
<h1>智能行程规划</h1>
<p>选择一种方式开始你的旅行规划</p>
</div>
<div class="mode-cards">
<div class="mode-card chat-mode" @click="selectMode('chat')">
<div class="mode-icon">💬</div>
<h3>聊天问答式</h3>
<p>通过对话交流我逐步了解你的需求为你量身定制完美行程</p>
<div class="mode-features">
<span>多轮对话</span>
<span>逐步收集信息</span>
<span>个性化规划</span>
</div>
</div>
<div class="mode-card quick-mode" @click="selectMode('quick')">
<div class="mode-icon"></div>
<h3>快速规划</h3>
<p>输入目的地和天数一键生成多个旅行方案供你选择</p>
<div class="mode-features">
<span>快速生成</span>
<span>多方案对比</span>
<span>即输即得</span>
</div>
</div>
<div class="mode-card custom-mode" @click="selectMode('custom')">
<div class="mode-icon"></div>
<h3>自定义行程</h3>
<p>输入你已规划的行程AI 评估合理性并给出优化建议</p>
<div class="mode-features">
<span>行程评估</span>
<span>智能建议</span>
<span>优化方案</span>
</div>
</div>
</div>
</div>
<!-- Planning mode components -->
<ChatInterface v-else-if="store.phase === 'chat' && store.planningMode === 'chat'" />
<QuickPlanPanel v-else-if="store.phase === 'chat' && store.planningMode === 'quick'" />
<CustomPlanPanel v-else-if="store.phase === 'chat' && store.planningMode === 'custom'" />
<ComparisonView v-else-if="store.phase === 'comparison'" /> <ComparisonView v-else-if="store.phase === 'comparison'" />
<Workbench v-else /> <Workbench v-else />
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { useItineraryStore } from '../stores/itinerary' import { useItineraryStore } from '../stores/itinerary'
import ChatInterface from '../components/ChatInterface.vue' import ChatInterface from '../components/ChatInterface.vue'
import ComparisonView from '../components/ComparisonView.vue' import ComparisonView from '../components/ComparisonView.vue'
import Workbench from '../components/Workbench.vue' import Workbench from '../components/Workbench.vue'
import QuickPlanPanel from '../components/QuickPlanPanel.vue'
import CustomPlanPanel from '../components/CustomPlanPanel.vue'
const store = useItineraryStore() const store = useItineraryStore()
// Show mode selection only when no planning has been started
const showModeSelection = computed(() => {
return store.phase === 'chat' && !store.planningMode
})
const selectMode = (mode) => {
store.setPlanningMode(mode)
}
</script> </script>
<style scoped>
.mode-selection {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f5f5f5;
}
.selection-header {
text-align: center;
padding: 60px 20px 40px;
background: linear-gradient(135deg, #1b4332, #2d6a4f);
color: #fff;
}
.selection-header h1 {
margin: 0 0 10px;
font-size: 32px;
}
.selection-header p {
margin: 0;
opacity: 0.85;
font-size: 16px;
}
.mode-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 1000px;
margin: -40px auto 40px;
padding: 0 20px;
}
.mode-card {
background: #fff;
border-radius: 16px;
padding: 30px 24px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.mode-card:hover {
transform: translateY(-6px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.chat-mode:hover { border-color: #2a9d8f; }
.quick-mode:hover { border-color: #e76f51; }
.custom-mode:hover { border-color: #264653; }
.mode-icon {
font-size: 48px;
margin-bottom: 16px;
}
.mode-card h3 {
margin: 0 0 10px;
font-size: 20px;
color: #333;
}
.mode-card > p {
margin: 0 0 16px;
font-size: 14px;
color: #666;
line-height: 1.6;
}
.mode-features {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mode-features span {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.chat-mode .mode-features span {
background: #e8f5e9;
color: #2a9d8f;
}
.quick-mode .mode-features span {
background: #fff3e0;
color: #e76f51;
}
.custom-mode .mode-features span {
background: #e0f2f1;
color: #264653;
}
@media (max-width: 768px) {
.mode-cards {
grid-template-columns: 1fr;
}
}
</style>

Loading…
Cancel
Save