fix: 增加分享、管理员、编辑等功能

refactor
Lxy 5 days ago
parent 47e4236392
commit 16201d567b

Binary file not shown.

Binary file not shown.

@ -44,6 +44,7 @@ function saveConfig(config) {
}
import cors from 'cors'
import { db } from './src/db/database.js'
// 导入路由
import authRoutes from './src/routes/auth.js'
@ -189,6 +190,9 @@ app.post('/api/chat', async (req, res) => {
const decoder = new TextDecoder()
let totalBytes = 0
let chunkCount = 0
let sseBuffer = ''
let usageData = null
const startTime = Date.now()
while (true) {
if (clientDisconnected) {
@ -202,6 +206,24 @@ app.post('/api/chat', async (req, res) => {
totalBytes += chunk.length
chunkCount++
// 累积 SSE buffer 并解析 usage
sseBuffer += chunk
const lines = sseBuffer.split('\n')
sseBuffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') {
try {
const json = JSON.parse(trimmed.slice(5).trim())
if (json.usage) {
usageData = json.usage
} else if (json.choices && json.choices.length === 0 && !usageData) {
// 有些 API 在最后一个 choices=[] 的 chunk 中夹带 usage
}
} catch (e) { /* skip partial lines */ }
}
}
res.write(chunk)
if (chunkCount % 10 === 0) {
@ -209,7 +231,53 @@ app.post('/api/chat', async (req, res) => {
}
}
console.log('[api/chat] Streaming done, total chunks:', chunkCount, 'bytes:', totalBytes)
// 记录使用量到数据库
const duration = Date.now() - startTime
const { user_id, guest_id, event_type = 'chat' } = req.body
let prompt_tokens = 0
let completion_tokens = 0
let total_tokens = 0
let model = req.body.model || config.model
if (usageData) {
// DashScope 格式: usage.models[0].{input_tokens, output_tokens}
if (usageData.models && usageData.models[0]) {
prompt_tokens = usageData.models[0].input_tokens || 0
completion_tokens = usageData.models[0].output_tokens || 0
total_tokens = prompt_tokens + completion_tokens
}
// OpenAI 兼容格式: usage.{prompt_tokens, completion_tokens, total_tokens}
if (usageData.prompt_tokens !== undefined) {
prompt_tokens = usageData.prompt_tokens
completion_tokens = usageData.completion_tokens || 0
total_tokens = usageData.total_tokens || (prompt_tokens + completion_tokens)
}
}
// 如果 API 未返回 token 数streaming 模式常见),用字符数估算
if (total_tokens === 0) {
// 估算 input tokens: 每 2 个字符约 1 token
const inputChars = JSON.stringify(req.body.messages || '').length
prompt_tokens = Math.ceil(inputChars / 2)
// 估算 output tokens: 每 2 个字符约 1 token
const outputChars = sseBuffer.length
completion_tokens = Math.ceil(outputChars / 2)
total_tokens = prompt_tokens + completion_tokens
}
if (user_id || guest_id) {
try {
db.prepare(`
INSERT INTO usage_logs (user_id, guest_id, event_type, model, prompt_tokens, completion_tokens, total_tokens, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(user_id || null, guest_id || null, event_type, model, prompt_tokens, completion_tokens, total_tokens, duration)
console.log(`[api/chat] Usage logged: ${total_tokens} tokens (est), ${duration}ms`)
} catch (err) {
console.error('[api/chat] Failed to log usage:', err.message)
}
}
console.log('[api/chat] Streaming done, total chunks:', chunkCount, 'bytes:', totalBytes, 'tokens:', total_tokens)
res.end()
} catch (error) {
if (!clientDisconnected) {

@ -2,36 +2,64 @@
<div class="chat-interface">
<!-- Chat messages -->
<div class="chat-msgs" ref="messagesContainer">
<!-- Initial greeting -->
<div class="chat-bubble bot">
<div class="cb-av"></div>
<div class="cb-body">
你好我是你的行程规划助手请告诉我<br>
&bull; 想去哪里单个地点或多个地点组合<br>
&bull; 几月出发几天行程<br>
&bull; 交通方式自驾/公共交通/步行<br>
&bull; 有什么特殊需求带老人/小孩/特定景点<br>
<br>
<em style="color:#b2bec3">示例9月去云南5自驾带老人</em>
你好我是你的行程规划助手 🗺<br>
接下来我会和你聊一聊了解一下你的旅行想法<br>
比如想去哪里什么时候出发玩几天<br><br>
<em style="color:#b2bec3">不用一次说全我们慢慢聊</em>
</div>
</div>
<!-- Conversation messages -->
<div v-for="(msg, i) in messages" :key="i" class="chat-bubble" :class="msg.role">
<div class="cb-av" :class="msg.role === 'user' ? 'user-av' : 'bot-av'"></div>
<div class="cb-body">
<!-- 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 v-if="msg.error" class="error-hint">{{ msg.error }}</div>
</div>
</div>
<!-- Confirmation summary card -->
<div v-if="phase === 'confirming' && gatheredInfo" class="confirm-card">
<div class="confirm-header">📋 请确认你的行程需求</div>
<div class="confirm-row">
<span class="confirm-label">出行时间</span>
<span class="confirm-value">{{ gatheredInfo.time || '—' }}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">出发地</span>
<span class="confirm-value">{{ gatheredInfo.origin || '—' }}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">目的地</span>
<span class="confirm-value">{{ gatheredInfo.destination || '—' }}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">出行方式</span>
<span class="confirm-value">{{ gatheredInfo.transport || '—' }}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">人员组成</span>
<span class="confirm-value">{{ gatheredInfo.travelers || '—' }}</span>
</div>
<div class="confirm-row" v-if="gatheredInfo.preferences && gatheredInfo.preferences.length">
<span class="confirm-label">偏好</span>
<span class="confirm-value">{{ gatheredInfo.preferences.join('、') }}</span>
</div>
<div class="confirm-row" v-if="gatheredInfo.intendedSpots">
<span class="confirm-label">意向地点</span>
<span class="confirm-value">{{ gatheredInfo.intendedSpots }}</span>
</div>
<div class="confirm-row" v-if="gatheredInfo.taboos">
<span class="confirm-label">禁忌</span>
<span class="confirm-value">{{ gatheredInfo.taboos }}</span>
</div>
<div class="confirm-actions">
<button class="btn-confirm" @click="confirmAndGenerate"> </button>
<button class="btn-revise" @click="reviseRequest"> </button>
</div>
</div>
@ -45,7 +73,7 @@
</div>
</div>
<!-- Scheme cards (appear after AI response) -->
<!-- Scheme cards (appear after generation) -->
<div v-if="schemes.length > 0" class="chat-cards">
<div v-for="(scheme, i) in schemes" :key="i" class="cc" @click="selectScheme(i)">
<span class="cc-badge" :class="i === 1 ? 'badge-b' : ''">{{ getSchemeLabel(i) }}</span>
@ -60,28 +88,41 @@
<input
v-model="inputText"
@keyup.enter="sendMessage"
placeholder="例如9月去云南5天自驾"
:disabled="isGenerating"
:placeholder="inputPlaceholder"
:disabled="isGenerating || phase === 'generating' || schemes.length > 0"
/>
<button @click="sendMessage" :disabled="!inputText.trim() || isGenerating"></button>
<button @click="sendMessage" :disabled="!inputText.trim() || isGenerating || phase === 'generating' || schemes.length > 0"></button>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ref, computed, nextTick } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
import { chatWithAI } from '../services/aiService'
import { useAuthStore } from '../stores/auth'
import { chatGather, chatWithAI } from '../services/aiService'
const store = useItineraryStore()
const authStore = useAuthStore()
const messages = ref([])
const inputText = ref('')
const isGenerating = ref(false)
const schemes = ref([])
const thinkingStatus = ref('正在分析需求...')
//
const phase = ref('gathering') // gathering | confirming | generating
const gatheredInfo = ref(null)
const conversationHistory = ref([])
const messagesContainer = ref(null)
const inputPlaceholder = computed(() => {
if (phase.value === 'confirming') return '继续补充信息...'
if (schemes.value.length > 0) return '规划已完成,选择方案开始编辑'
return '说说你的旅行想法...'
})
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
@ -95,22 +136,89 @@ function getSchemeLabel(i) {
const sendMessage = async () => {
if (!inputText.value.trim() || isGenerating.value) return
if (schemes.value.length > 0) return
const userMsg = inputText.value.trim()
messages.value.push({ role: 'user', content: userMsg })
conversationHistory.value.push({ role: 'user', content: userMsg })
inputText.value = ''
schemes.value = []
await scrollToBottom()
// confirming gathering
if (phase.value === 'confirming') {
phase.value = 'gathering'
gatheredInfo.value = null
}
isGenerating.value = true
thinkingStatus.value = '正在分析需求...'
try {
let thinkingContent = ''
const result = await chatGather(conversationHistory.value, (streamContent) => {
if (streamContent.includes('ready')) {
thinkingStatus.value = '信息已收集完毕,正在整理...'
} else {
thinkingStatus.value = '正在理解你的需求...'
}
}, {
userId: authStore.user?.id,
guestId: authStore.guestId,
eventType: 'gather'
})
isGenerating.value = false
if (result.status === 'ready' && result.summary) {
// AI
phase.value = 'confirming'
gatheredInfo.value = result.summary
messages.value.push({ role: 'bot', content: result.reply || '我已经了解了你的需求,请确认以下信息:' })
conversationHistory.value.push({ role: 'assistant', content: result.reply || '' })
} else {
// AI
messages.value.push({ role: 'bot', content: result.reply || '请告诉我更多信息~' })
conversationHistory.value.push({ role: 'assistant', content: result.reply || '' })
}
await scrollToBottom()
} catch (error) {
isGenerating.value = false
const errMsg = error.message.includes('API Key')
? '请先访问设置页面配置你的 AI API Key'
: error.message + ',请重试'
messages.value.push({
role: 'bot',
content: '抱歉,交流时遇到了问题。',
error: errMsg
})
await scrollToBottom()
}
}
const confirmAndGenerate = async () => {
if (!gatheredInfo.value) return
phase.value = 'generating'
isGenerating.value = true
thinkingStatus.value = '正在为你规划行程方案...'
schemes.value = []
const result = await chatWithAI(userMsg, (streamContent) => {
thinkingContent = streamContent
// chatWithAI
const info = gatheredInfo.value
const userRequest = [
info.time && `出行时间:${info.time}`,
info.origin && `出发地:${info.origin}`,
info.destination && `目的地:${info.destination}`,
info.transport && `出行方式:${info.transport}`,
info.travelers && `人员组成:${info.travelers}`,
info.preferences?.length && `偏好:${info.preferences.join('、')}`,
info.intendedSpots && `意向地点:${info.intendedSpots}`,
info.taboos && `禁忌:${info.taboos}`
].filter(Boolean).join('\n')
try {
const result = await chatWithAI(userRequest, (streamContent) => {
if (streamContent.includes('thinking')) {
thinkingStatus.value = 'AI 正在思考并搜索目的地信息...'
} else if (streamContent.includes('schemes')) {
@ -118,35 +226,53 @@ const sendMessage = async () => {
} else if (streamContent.includes('points')) {
thinkingStatus.value = '正在规划详细行程...'
}
}, {
userId: authStore.user?.id,
guestId: authStore.guestId,
eventType: 'generate'
})
isGenerating.value = false
const botMsg = {
role: 'bot',
content: `已为你生成 ${result.schemes.length} 个方案,请查看下方卡片。\n\n${result.schemes.map((s, i) => `${getSchemeLabel(i)}${s.name}\n路线${s.route}\n${s.days}\u00b7 ${s.totalKm}km \u00b7 ${s.budget}`).join('\n\n')}`,
content: `已为你生成 ${result.schemes.length} 个方案,请查看下方卡片。\n\n${result.schemes.map((s, i) => `${getSchemeLabel(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)
schemes.value = result.schemes
await scrollToBottom()
} catch (error) {
isGenerating.value = false
const errMsg = error.message.includes('API Key')
? '请先访问设置页面配置你的 AI API Key'
: error.message + ',请重试'
messages.value.push({
role: 'bot',
content: '抱歉,生成方案时遇到了问题。',
error: error.message.includes('API Key')
? '请先访问设置页面配置你的 AI API Key'
: error.message + ',请重试'
error: errMsg
})
await scrollToBottom()
}
}
const reviseRequest = () => {
//
phase.value = 'gathering'
gatheredInfo.value = null
//
messages.value.push({
role: 'bot',
content: '好的,请告诉我需要修改哪里?'
})
conversationHistory.value.push({
role: 'assistant',
content: '好的,请告诉我需要修改哪里?'
})
}
const selectScheme = (index) => {
const scheme = schemes.value[index]
if (!scheme) return
@ -235,50 +361,84 @@ const selectScheme = (index) => {
white-space: pre-wrap;
}
/* Thinking process */
.thinking-process {
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
/* Confirmation card */
.confirm-card {
align-self: center;
width: 80%;
background: #fff;
border-radius: 14px;
padding: 20px 24px;
border: 2px solid #6c5ce7;
box-shadow: 0 4px 20px rgba(108,92,231,0.12);
}
.thinking-header {
.confirm-header {
font-size: 16px;
font-weight: 600;
color: #2d3436;
margin-bottom: 16px;
}
.confirm-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8f9fa;
cursor: pointer;
font-size: 13px;
color: #666;
user-select: none;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.thinking-header:hover { background: #f0f0f0; }
.confirm-row:last-of-type {
border-bottom: none;
}
.thinking-icon::before {
content: '🧠';
.confirm-label {
width: 80px;
color: #888;
flex-shrink: 0;
}
.thinking-toggle {
margin-left: auto;
font-size: 12px;
color: #999;
.confirm-value {
color: #2d3436;
font-weight: 500;
}
.thinking-content {
padding: 12px;
font-size: 13px;
color: #555;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
background: #fafafa;
.confirm-actions {
display: flex;
gap: 12px;
margin-top: 18px;
}
.thinking-process:not(.expanded) .thinking-content {
display: none;
.btn-confirm {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-confirm:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(108,92,231,0.35);
}
.btn-revise {
padding: 10px 16px;
border: 1.5px solid #dfe6e9;
border-radius: 10px;
background: #fff;
color: #636e72;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-revise:hover {
border-color: #6c5ce7;
color: #6c5ce7;
}
/* Error hint */
@ -431,5 +591,9 @@ const selectScheme = (index) => {
.chat-bubble {
max-width: 90%;
}
.confirm-card {
width: 95%;
}
}
</style>

@ -19,6 +19,7 @@ let previewLine = null
let detourLine = null
let carMarker = null
let animationFrameId = null
let previewMarkers = [] // AI
const defaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
@ -79,6 +80,36 @@ const clearRouteLines = () => {
if (detourLine) { map.removeLayer(detourLine); detourLine = null }
}
// AI
const showAlternativeMarkers = (options) => {
clearAlternativeMarkers()
if (!map || !options || options.length === 0) return
const replaceIdx = store.selectedForReplace
const targetPoint = replaceIdx !== null ? store.points[replaceIdx] : null
options.forEach((opt, i) => {
if (!opt.lat || !opt.lng) return
const marker = L.marker([opt.lat, opt.lng], {
icon: L.divIcon({
html: `<div style="width:32px;height:32px;border-radius:50%;background:#f4a261;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer">${opt.icon || '📍'}</div>`,
className: 'preview-marker', iconSize: [32, 32], iconAnchor: [16, 16]
})
}).addTo(map)
marker.bindTooltip(`${opt.name} (推荐)`, { permanent: false, direction: 'top' })
marker.on('click', () => {
store.setHoveredReplacement(opt)
})
previewMarkers.push(marker)
})
}
const clearAlternativeMarkers = () => {
if (!map) return
previewMarkers.forEach(m => map.removeLayer(m))
previewMarkers = []
}
const updateRoute = (skipFitBounds = false) => {
clearRouteLines()
const coords = store.points.map(p => [p.lat, p.lng])
@ -232,7 +263,7 @@ watch(() => store.routeSegments, () => { updateRoute() }, { deep: true })
onMounted(() => { initMap() })
onUnmounted(() => { if (animationFrameId) cancelAnimationFrame(animationFrameId); if (map) map.remove() })
defineExpose({ animateCar, updateRoute, clearRoute: clearRouteLines })
defineExpose({ animateCar, updateRoute, clearRoute: clearRouteLines, showAlternativeMarkers, clearAlternativeMarkers })
</script>
<style scoped>

@ -180,7 +180,7 @@ function formatDate(dateStr) {
.panel-content {
flex: 1;
overflow-y: auto;
overflow: hidden;
padding: 20px;
display: flex;
flex-direction: column;
@ -293,10 +293,11 @@ function formatDate(dateStr) {
background: #e8e4ff;
}
/* Tab 内容 */
/* Tab 内容 - 可滚动区域 */
.tab-content {
flex: 1;
min-height: 150px;
overflow-y: auto;
min-height: 0;
}
/* 用户统计 */
@ -388,9 +389,9 @@ function formatDate(dateStr) {
color: #b2bec3;
}
/* 退出登录区域 - 自动推到底部 */
/* 退出登录区域 - 固定在底部,不随列表滚动 */
.logout-section {
margin-top: auto;
flex-shrink: 0;
padding-top: 16px;
border-top: 1px solid #e9ecef;
}

File diff suppressed because it is too large Load Diff

@ -75,6 +75,20 @@ db.exec(`
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- AI 使用量日志表
CREATE TABLE IF NOT EXISTS usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
guest_id TEXT,
event_type TEXT NOT NULL, -- 'chat' | 'gather' | 'enrich' | 'replace' | 'reorganize'
model TEXT,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
duration_ms INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_plans_user_id ON plans(user_id);
CREATE INDEX IF NOT EXISTS idx_plans_guest_id ON plans(guest_id);
@ -84,6 +98,9 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_stats_guest_id ON stats(guest_id);
CREATE INDEX IF NOT EXISTS idx_stats_event_type ON stats(event_type);
CREATE INDEX IF NOT EXISTS idx_stats_created_at ON stats(created_at);
CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_usage_logs_event_type ON usage_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_usage_logs_created_at ON usage_logs(created_at);
`)
// 清理过期游客数据的函数

@ -1,7 +1,7 @@
// 使用统计和管理员路由
import express from 'express'
import { db } from '../db/database.js'
import { requireAdmin } from './auth.js'
import { authenticateToken, requireAdmin } from './auth.js'
const router = express.Router()
@ -29,19 +29,19 @@ router.post('/event', (req, res) => {
})
// 管理员:获取总体统计数据
router.get('/admin/overview', requireAdmin, (req, res) => {
router.get('/admin/overview', authenticateToken, requireAdmin, (req, res) => {
try {
// 用户总数
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get()
// 管理员数量
const adminCount = db.prepare('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']).get()
const adminCount = db.prepare('SELECT COUNT(*) as count FROM users WHERE role = ?').get('admin')
// 总计划数
const totalPlans = db.prepare('SELECT COUNT(*) as count FROM plans').get()
// 游客计划数(未过期)
const guestPlans = db.prepare('SELECT COUNT(*) as count FROM guest_plans WHERE expires_at > ?', [new Date().toISOString()]).get()
const guestPlans = db.prepare('SELECT COUNT(*) as count FROM guest_plans WHERE expires_at > ?').get(new Date().toISOString())
// 总事件数
const totalEvents = db.prepare('SELECT COUNT(*) as count FROM stats').get()
@ -74,7 +74,7 @@ router.get('/admin/overview', requireAdmin, (req, res) => {
})
// 管理员:按类型统计事件
router.get('/admin/events-by-type', requireAdmin, (req, res) => {
router.get('/admin/events-by-type', authenticateToken, requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
@ -95,7 +95,7 @@ router.get('/admin/events-by-type', requireAdmin, (req, res) => {
})
// 管理员:每日活跃趋势
router.get('/admin/daily-active', requireAdmin, (req, res) => {
router.get('/admin/daily-active', authenticateToken, requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
@ -115,7 +115,7 @@ router.get('/admin/daily-active', requireAdmin, (req, res) => {
})
// 管理员:用户列表
router.get('/admin/users', requireAdmin, (req, res) => {
router.get('/admin/users', authenticateToken, requireAdmin, (req, res) => {
try {
const users = db.prepare(`
SELECT u.id, u.username, u.email, u.role, u.created_at,
@ -137,7 +137,7 @@ router.get('/admin/users', requireAdmin, (req, res) => {
})
// 管理员:热门行程
router.get('/admin/popular-plans', requireAdmin, (req, res) => {
router.get('/admin/popular-plans', authenticateToken, requireAdmin, (req, res) => {
try {
const { limit = 10 } = req.query
@ -159,4 +159,88 @@ router.get('/admin/popular-plans', requireAdmin, (req, res) => {
}
})
// ===== 管理员AI 使用量统计 =====
// 各用户 token 使用量
router.get('/admin/usage/tokens', authenticateToken, requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
const rows = db.prepare(`
SELECT
COALESCE(u.username, '(游客)') as username,
ul.event_type,
SUM(ul.prompt_tokens) as prompt_tokens,
SUM(ul.completion_tokens) as completion_tokens,
SUM(ul.total_tokens) as total_tokens,
COUNT(*) as call_count,
SUM(ul.duration_ms) as total_duration_ms
FROM usage_logs ul
LEFT JOIN users u ON ul.user_id = u.id
WHERE ul.created_at > ?
GROUP BY ul.user_id, ul.event_type
ORDER BY total_tokens DESC
`).all(since)
const totalTokens = rows.reduce((s, r) => s + r.total_tokens, 0)
const totalCalls = rows.reduce((s, r) => s + r.call_count, 0)
res.json({ rows, totalTokens, totalCalls, days })
} catch (err) {
console.error('获取 token 使用量失败:', err)
res.status(500).json({ error: '获取 token 使用量失败' })
}
})
// 每日 token 消耗趋势
router.get('/admin/usage/daily', authenticateToken, requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const daily = db.prepare(`
SELECT
date(created_at) as date,
SUM(total_tokens) as total_tokens,
SUM(prompt_tokens) as prompt_tokens,
SUM(completion_tokens) as completion_tokens,
COUNT(*) as call_count
FROM usage_logs
WHERE created_at > datetime('now', ?)
GROUP BY date(created_at)
ORDER BY date ASC
`).all(`-${days} days`)
res.json({ daily, days })
} catch (err) {
console.error('获取每日使用趋势失败:', err)
res.status(500).json({ error: '获取每日使用趋势失败' })
}
})
// 各模型调用统计
router.get('/admin/usage/models', authenticateToken, requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
const models = db.prepare(`
SELECT
model,
COUNT(*) as call_count,
SUM(total_tokens) as total_tokens,
SUM(duration_ms) as total_duration_ms,
AVG(duration_ms) as avg_duration_ms
FROM usage_logs
WHERE created_at > ? AND model IS NOT NULL
GROUP BY model
ORDER BY call_count DESC
`).all(since)
res.json({ models, days })
} catch (err) {
console.error('获取模型统计失败:', err)
res.status(500).json({ error: '获取模型统计失败' })
}
})
export default router

@ -42,6 +42,53 @@ JSON 格式如下:
3. 至少 2 个方案最多 3
4. 直接输出 JSON不要其他内容`
const CHAT_GATHER_PROMPT = `你是一个专业、贴心的旅行规划助手。你正在通过多轮对话收集用户的旅行需求。
## 你的角色
用自然友好的语气和用户聊天逐步了解他们的旅行需求一次只问 1-2 个问题不要一次性问完所有问题
## 需要收集的信息 7 全部收集完毕后才能 ready
1. **出行时间** 几月出发玩几天季节很重要影响推荐
2. **出发地与目的地** 从哪里出发去哪里城市省份区域
3. **出行方式** 自驾 / 公共交通 / 包车 / 跟团
4. **人员组成** 一个人情侣家庭带老人/小孩
5. **偏好** 喜欢自然风光文化古迹美食购物休闲度假
6. **意向地点** 有没有特别想去的地方或景点可选没有就说没有
7. **禁忌** 有没有什么不想要的比如不去某类景点不吃某类食物不爬山等可选没有就说没有
## 对话规则
- 如果信息不足7 项未收集全继续友好地提问输出 status: "needs_more_info"
- 一次只追问 1-2 个最紧迫的信息
- 对可选项目意向地点禁忌如果用户说"没有""随便"就算收集完成
- 7 项全部收集完毕后输出 status: "ready"
- ready 状态下你必须用 summary 字段整理 **全部 7 ** 信息并请用户确认
## 输出格式
你必须直接输出 JSON不要包含任何 markdown 代码块标记不要在 JSON 前后添加任何额外文字
当信息不足时
{
"status": "needs_more_info",
"reply": "你准备什么时候出发呢?不同的季节适合不同的玩法~",
"missing": ["出行时间"]
}
当信息足够时
{
"status": "ready",
"reply": "太好了,我整理了一下你的需求,你看对不对?",
"summary": {
"time": "9月5天",
"origin": "上海",
"destination": "云南",
"transport": "自驾",
"travelers": "带老人",
"preferences": ["自然风光", "休闲"],
"intendedSpots": "大理、丽江古城",
"taboos": "不爬山、不吃辣"
}
}`
const QUICK_PLAN_PROMPT = `你是一个专业的旅行规划助手。根据用户指定的目的地和天数,快速生成 3 个不同的旅行方案。
## 重要输出格式
@ -69,39 +116,109 @@ JSON 格式如下:
"tips": "旅行贴士,如最佳季节、注意事项、必备物品等",
"points": [
{
"name": "地点名称",
"name": "昆明",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "该地点简短描述",
"desc": "春城昆明,云南省会",
"km": "0km",
"driveTime": "—",
"schedule": [
{
"time": "上午",
"title": "标题",
"content": "行程安排简述",
"desc": "详细描述"
"title": "抵达昆明",
"content": "抵达昆明,入住酒店",
"desc": "建议选择翠湖附近的酒店"
}
],
"foods": [
{
"name": "美食名称",
"name": "过桥米线",
"icon": "🍜",
"desc": "美食简介"
"desc": "云南经典美食"
}
],
"waypoints": [
{
"name": "途径地点名称",
"name": "滇池",
"icon": "📍",
"desc": "简短介绍"
"desc": "云南最大淡水湖"
}
],
"hotel": "推荐住宿",
"tips": "注意事项"
"hotel": "昆明翠湖酒店",
"tips": "注意高原防晒"
},
{
"name": "大理",
"lat": 25.59,
"lng": 100.23,
"day": "Day 2",
"badge": "D1",
"icon": "🏔️",
"desc": "风花雪月的大理古城",
"km": "320km",
"driveTime": "4h",
"schedule": [
{
"time": "上午",
"title": "游览洱海",
"content": "环洱海骑行,感受苍山洱海美景",
"desc": "建议租电动车全程约50km"
}
],
"foods": [
{
"name": "大理酸辣鱼",
"icon": "🐟",
"desc": "洱海鲜鱼制作"
}
],
"waypoints": [
{
"name": "崇圣寺三塔",
"icon": "📍",
"desc": "大理标志性建筑"
}
],
"hotel": "大理古城客栈",
"tips": "古城内步行最佳"
},
{
"name": "丽江",
"lat": 26.87,
"lng": 100.23,
"day": "Day 3",
"badge": "END",
"icon": "🏯",
"desc": "世界文化遗产丽江古城",
"km": "180km",
"driveTime": "2.5h",
"schedule": [
{
"time": "下午",
"title": "漫步古城",
"content": "游览丽江古城,感受纳西文化",
"desc": "四方街、木府、大水车"
}
],
"foods": [
{
"name": "腊排骨火锅",
"icon": "🍲",
"desc": "丽江特色美食"
}
],
"waypoints": [
{
"name": "束河古镇",
"icon": "📍",
"desc": "比大研古城更静谧的古镇"
}
],
"hotel": "丽江特色民宿",
"tips": "提前预订古城内住宿"
}
]
}
@ -118,7 +235,8 @@ JSON 格式如下:
7. foods 中每个对象必须包含 nameicondesc
8. waypoints 中每个对象必须包含 nameicondesc
9. daysDetail 数组长度必须等于 days每天一个对象
10. 直接输出 JSON不要其他内容`
10. points 数组必须包含行程中所有途经站点每个 daysDetail 中的 location 至少对应一个 point总点数至少 3 STARTEND 和至少一个中间站每个 point badge 依次为 STARTD1D2...END
11. 直接输出 JSON不要其他内容`
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
1. 解析用户的行程安排
@ -197,7 +315,101 @@ JSON 格式如下:
3. 即使行程合理也要生成两个方案优化版和原版
4. 直接输出 JSON不要其他内容`
export async function chatWithAI(userMessage, onThinking = null) {
/**
* 多轮对话 收集需求阶段
* @param {Array} history - 对话历史 [{role: 'user'|'assistant', content: string}]
* @param {Function} onThinking - 流式回调
* @param {Object} context - { userId, guestId, eventType }
* @returns {Promise<{status: string, reply: string, summary?: object, missing?: string[]}>}
*/
export async function chatGather(history, onThinking = null, context = {}) {
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
const messages = [
{ role: 'system', content: CHAT_GATHER_PROMPT },
...history
]
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages,
stream: true,
user_id: context.userId || null,
guest_id: context.guestId || null,
event_type: context.eventType || 'gather'
})
})
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 = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
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)
}
const chunk = delta.content || ''
if (chunk) {
fullContent += chunk
if (onThinking) onThinking(fullContent || reasoningContent)
}
} catch (e) {
// Skip malformed JSON chunks
}
}
}
// 解析 AI 返回的 JSON
let cleaned = (fullContent || reasoningContent).trim()
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) cleaned = codeBlockMatch[1].trim()
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim()
try {
const parsed = JSON.parse(cleaned)
return {
status: parsed.status || 'needs_more_info',
reply: parsed.reply || '',
summary: parsed.summary || null,
missing: parsed.missing || []
}
} catch (e) {
// 如果解析失败,将原始内容作为回复
return {
status: 'needs_more_info',
reply: cleaned,
summary: null,
missing: []
}
}
}
export async function chatWithAI(userMessage, onThinking = null, context = {}) {
// 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, {
@ -210,7 +422,10 @@ export async function chatWithAI(userMessage, onThinking = null) {
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
],
stream: true
stream: true,
user_id: context.userId || null,
guest_id: context.guestId || null,
event_type: context.eventType || 'chat'
})
})
@ -325,13 +540,19 @@ function parseAIResponse(content) {
}
// Shared streaming function
async function streamAIRequest(messages, onThinking = null, signal = null) {
async function streamAIRequest(messages, onThinking = null, signal = null, context = {}) {
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 }),
body: JSON.stringify({
messages,
stream: true,
user_id: context.userId || null,
guest_id: context.guestId || null,
event_type: context.eventType || 'chat'
}),
signal
})
console.log('[streamAI] Response status:', response.status)
@ -440,7 +661,7 @@ function parseJSONFromContent(content) {
}
}
export async function quickPlan(userInput, onThinking = null, signal = null) {
export async function quickPlan(userInput, onThinking = null, signal = null, context = {}) {
const userMessage = `目的地:${userInput.destination}
出行天数${userInput.days}
出发日期${userInput.startDate}
@ -458,7 +679,7 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking, signal)
], onThinking, signal, { ...context, eventType: context.eventType || 'quick_plan' })
const parsed = parseJSONFromContent(content)
@ -466,11 +687,18 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
throw new Error('AI 返回结果缺少 schemes')
}
// 验证每个 scheme 都有 points
// 验证每个 scheme 都有足够的 points
for (let i = 0; i < parsed.schemes.length; i++) {
const scheme = parsed.schemes[i]
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length === 0) {
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 缺少 points 数据`)
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length < 2) {
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) points 数据不足,需要至少 2 个站点,当前 ${scheme.points?.length || 0}`)
}
// 验证 badge 顺序:第一个必须是 START最后一个必须是 END
if (scheme.points[0].badge !== 'START') {
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 第一个 point 缺少 START badge`)
}
if (scheme.points[scheme.points.length - 1].badge !== 'END') {
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 最后一个 point 缺少 END badge`)
}
console.log(`[quickPlan] 方案 ${i + 1} 验证通过: ${scheme.name}, points: ${scheme.points.length}`)
}
@ -502,7 +730,7 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
throw new Error(`AI 规划失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
}
export async function customPlan(userInput, onThinking = null) {
export async function customPlan(userInput, onThinking = null, context = {}) {
const userMessage = `我的行程安排:
${userInput.itinerary}
@ -520,7 +748,7 @@ ${userInput.itinerary}
const content = await streamAIRequest([
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
], onThinking, null, { ...context, eventType: context.eventType || 'custom_plan' })
const parsed = parseJSONFromContent(content)
@ -541,3 +769,267 @@ ${userInput.itinerary}
throw new Error(`AI 评估失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
}
const REPLACE_PROMPT = `你是一个专业的旅行规划助手。用户当前行程中有一个站点需要替换为其他同类型目的地。
## 任务
根据当前站点上一个站点和下一个站点的信息推荐 3-5 个适合替换的替代目的地
## 要求
1. 替代目的地应该与当前站点类型相似自然风光古城美食等
2. 路线要顺路不能偏离主路线太远
3. lat/lng 必须是真实中国地理坐标
4. 每个替代品必须包含 iconnamedesclatlng
## 输出格式直接输出 JSON不要其他文字
{
"alternatives": [
{
"icon": "🏔️",
"name": "替代地点名称",
"lat": 25.04,
"lng": 102.71,
"desc": "简短描述",
"reason": "推荐理由"
}
]
}`
const REORGANIZE_PROMPT = `你是一个专业的旅行规划助手。用户已经确定了一个行程的站点顺序,现在需要你完善每个站点的详细信息。
## 任务
根据用户提供的站点列表包含名称和坐标完善每个站点的行程细节
1. 分配合理的 day 标签 Day 1 开始START 为出发日END 为结束日
2. 计算每个站点的合理里程和驾驶时间根据前后站点距离估算
3. 为每个站点编写描述desc
4. 为每个站点安排详细的每日行程schedule包含 timetitlecontentdesc
5. 推荐当地美食foods每站至少 4
6. 推荐住宿hotel
7. 提供注意事项tips
8. 如果有顺路的景点添加到 waypoints
## 重要
- 第一个站点用 badge "START"最后一个用 "END"中间用 "D1", "D2"
- lat/lng 保持用户提供的坐标不变
- 图标 (icon) 根据景点类型选择合适 emoji
- 总里程 totalKm 和总驾驶时间 totalDriveTime 根据各站点累加
- 路线描述 route 连接各站点名称
- 生成 3-5 个行程亮点 highlights
- 设定一个合理的 budget
- 直接输出 JSON不要 markdown 代码块标记
## 输出格式
{
"name": "行程名称",
"route": "昆明→大理→丽江",
"days": 4,
"totalKm": 1200,
"totalDriveTime": 12,
"budget": "¥3000",
"highlights": ["亮点1", "亮点2"],
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "出发日/第1天",
"badge": "START/D1/END",
"icon": "🏙️",
"desc": "简短描述",
"km": "0km",
"driveTime": "—",
"schedule": [
{ "time": "上午", "title": "标题", "content": "行程安排简述", "desc": "详细描述" },
{ "time": "下午", "title": "标题", "content": "行程安排简述", "desc": "详细描述" },
{ "time": "晚上", "title": "标题", "content": "行程安排简述", "desc": "详细描述" }
],
"foods": [
{ "name": "美食名称", "icon": "🍜", "desc": "美食简介" }
],
"waypoints": [
{ "name": "途径地点", "icon": "📍", "desc": "简短介绍" }
],
"hotel": "推荐住宿",
"tips": "注意事项"
}
]
}`
/**
* AI 建议替代目的地
* @param {Object} point - 当前要替换的站点 { name, lat, lng, desc }
* @param {Object|null} prevPoint - 上一个站点
* @param {Object|null} nextPoint - 下一个站点
* @param {Function} onThinking - 思考过程回调
*/
export async function suggestReplacements(point, prevPoint, nextPoint, onThinking = null, context = {}) {
let contextInfo = `当前站点:${point.name}${point.lat}, ${point.lng}- ${point.desc || ''}\n`
if (prevPoint) contextInfo += `上一个站点:${prevPoint.name}${prevPoint.lat}, ${prevPoint.lng}\n`
if (nextPoint) contextInfo += `下一个站点:${nextPoint.name}${nextPoint.lat}, ${nextPoint.lng}\n`
const userMessage = `我需要替换行程中的「${point.name}」,请推荐合适的替代目的地。\n\n${contextInfo}\n请确保替代地点路线顺路且类型匹配。`
const MAX_RETRIES = 2
let lastError = null
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const content = await streamAIRequest([
{ role: 'system', content: REPLACE_PROMPT },
{ role: 'user', content: userMessage }
], onThinking, null, { ...context, eventType: context.eventType || 'replace' })
const parsed = parseJSONFromContent(content)
if (!parsed.alternatives || !Array.isArray(parsed.alternatives) || parsed.alternatives.length === 0) {
throw new Error('AI 未返回替代地点')
}
return parsed.alternatives
} catch (e) {
lastError = e
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}
throw new Error(`AI 推荐失败:${lastError?.message || '未知错误'}`)
}
/**
* AI 整理完善整个行程
* @param {Array} points - 完整站点列表至少包含 name, lat, lng
* @param {Function} onThinking - 思考过程回调
*/
export async function reorganizeItinerary(points, onThinking = null, context = {}) {
const pointsBrief = points.map((p, i) => {
const badge = i === 0 ? 'START' : (i === points.length - 1 ? 'END' : `D${i}`)
return `${badge}: ${p.name} (${p.lat}, ${p.lng})${p.desc ? ' - ' + p.desc : ''}`
}).join('\n')
const userMessage = `请根据以下站点规划完善整个行程的详细信息:\n\n${pointsBrief}\n\n请确保:
1. 保持站点顺序不变
2. 保持每个站点的 lat/lng 不变
3. 完善所有细节行程安排美食住宿注意事项等
4. 合理分配天数和里程`
const MAX_RETRIES = 2
let lastError = null
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const content = await streamAIRequest([
{ role: 'system', content: REORGANIZE_PROMPT },
{ role: 'user', content: userMessage }
], onThinking, null, { ...context, eventType: context.eventType || 'reorganize' })
const parsed = parseJSONFromContent(content)
if (!parsed.points || !Array.isArray(parsed.points) || parsed.points.length === 0) {
throw new Error('AI 返回结果缺少 points')
}
return parsed
} catch (e) {
lastError = e
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}
throw new Error(`AI 整理失败:${lastError?.message || '未知错误'}`)
}
const ENRICH_POINT_PROMPT = `你是一个专业的旅行规划助手。用户行程中新增了一个站点,需要你为该站点生成详细的旅行信息。
## 任务
根据新增站点的名称坐标以及前后站点的信息生成该站点的详细旅行数据
1. 提供该地点的真实 GPS 坐标 (lat/lng)
2. 选择合适 emoji 图标 (icon)
3. 编写简短描述 (desc)
4. 安排详细的每日行程 (schedule包含 timetitlecontentdesc 三段时间)
5. 推荐当地美食 (foods至少 4 nameicondesc)
6. 推荐住宿 (hotel)
7. 提供注意事项 (tips)
8. 推荐途径景点 (waypoints1-2 )
9. 估算从前一站到本站的里程(km)和驾驶时间(driveTime)
## 输出格式
{
"point": {
"lat": 25.04,
"lng": 102.71,
"icon": "🏯",
"desc": "简短描述",
"km": "150km",
"driveTime": "2.5h",
"schedule": [
{ "time": "上午", "title": "标题", "content": "内容", "desc": "详细描述" },
{ "time": "下午", "title": "标题", "content": "内容", "desc": "详细描述" },
{ "time": "晚上", "title": "标题", "content": "内容", "desc": "详细描述" }
],
"foods": [
{ "name": "美食名称", "icon": "🍜", "desc": "简介" }
],
"waypoints": [
{ "name": "途径点", "icon": "📍", "desc": "简介" }
],
"hotel": "推荐住宿",
"tips": "注意事项"
}
}
## 重要
- 直接输出 JSON不要 markdown 代码块标记
- lat/lng 必须是该地点的真实中国地理坐标
- 美食至少 4 每种含 nameicondesc
- 行程安排 3 段时间上午下午晚上
- km driveTime 估算从前一站到本站的路程`
/**
* AI 为新增站点生成详细信息
* @param {Object} point - 新增站点 { name, lat, lng, day }
* @param {Object|null} prevPoint - 上一个站点
* @param {Object|null} nextPoint - 下一个站点
* @param {Function} onThinking - 思考过程回调
*/
export async function enrichSinglePoint(point, prevPoint, nextPoint, onThinking = null, context = {}) {
let contextInfo = `新增站点:${point.name}${point.lat || 0}, ${point.lng || 0}`
if (point.day) contextInfo += ` - 所属天数:${point.day}`
contextInfo += '\n'
if (prevPoint) contextInfo += `上一个站点:${prevPoint.name}${prevPoint.lat}, ${prevPoint.lng}\n`
if (nextPoint) contextInfo += `下一个站点:${nextPoint.name}${nextPoint.lat}, ${nextPoint.lng}\n`
const userMessage = `请为行程中新增的站点「${point.name}」生成详细的旅行信息。\n\n${contextInfo}\n确保信息准确且与前后站点衔接自然。`
const MAX_RETRIES = 2
let lastError = null
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const content = await streamAIRequest([
{ role: 'system', content: ENRICH_POINT_PROMPT },
{ role: 'user', content: userMessage }
], onThinking, null, { ...context, eventType: context.eventType || 'enrich' })
const parsed = parseJSONFromContent(content)
if (!parsed.point || typeof parsed.point !== 'object') {
throw new Error('AI 返回结果缺少 point 字段')
}
return parsed.point
} catch (e) {
lastError = e
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}
// 失败时返回基础数据,不中断流程
console.warn('[enrichSinglePoint] AI 生成失败:', lastError?.message)
return null
}

@ -75,22 +75,56 @@ export const useItineraryStore = defineStore('itinerary', () => {
function nextStep() { if (currentStep.value < points.value.length - 1) currentStep.value++ }
function prevStep() { if (currentStep.value > 0) currentStep.value-- }
function recalcBadges() {
if (points.value.length === 0) return
// First point always START
points.value[0].badge = 'START'
// Last point always END
if (points.value.length > 1) {
points.value[points.value.length - 1].badge = 'END'
}
// Middle points: derive badge from day field
for (let i = 1; i < points.value.length - 1; i++) {
const day = points.value[i].day
if (day) {
const match = day.match(/Day\s*(\d+)/i)
if (match) {
points.value[i].badge = `D${match[1]}`
} else {
points.value[i].badge = `D${i}`
}
} else {
points.value[i].badge = `D${i}`
}
}
}
function deletePoint(index) {
if (index <= 0 || index >= points.value.length - 1) return
points.value.splice(index, 1)
routeSegments.value = []
if (currentStep.value >= points.value.length) currentStep.value = points.value.length - 1
recalcBadges()
checkRouteOptimization()
}
function insertPoint(beforeIndex, newPoint) {
// Determine day: inherit from previous point, or use provided day
let day = newPoint.day || ''
if (!day && beforeIndex > 0 && beforeIndex <= points.value.length) {
day = points.value[beforeIndex - 1].day || ''
}
points.value.splice(beforeIndex, 0, {
id: 'p_' + Date.now(), name: newPoint.name, lat: newPoint.lat, lng: newPoint.lng,
day: '', badge: '', km: '', driveTime: '', desc: newPoint.desc,
day: day, badge: '', km: '', driveTime: '', desc: newPoint.desc,
icon: newPoint.icon || '📍', images: [], heroImage: '',
schedule: [], foods: [], hotel: '', tips: ''
})
routeSegments.value = []
recalcBadges()
checkRouteOptimization()
}
@ -111,6 +145,7 @@ export const useItineraryStore = defineStore('itinerary', () => {
points.value.splice(toIndex, 0, item)
currentStep.value = toIndex
routeSegments.value = []
recalcBadges()
checkRouteOptimization()
}
@ -200,6 +235,37 @@ export const useItineraryStore = defineStore('itinerary', () => {
return
}
// Defensive: 如果 points 数量太少但有 daysDetail从 daysDetail 补充 points
if (scheme.points.length < 2 && scheme.daysDetail && scheme.daysDetail.length >= 2) {
console.warn(`[loadFromAI] points 只有 ${scheme.points.length} 个,从 daysDetail (${scheme.daysDetail.length} 天) 补充`)
const existingPoints = [...scheme.points]
const fallbackLat = existingPoints[0]?.lat || 25.04
const fallbackLng = existingPoints[0]?.lng || 102.71
scheme.points = scheme.daysDetail.map((day, di) => {
const existing = existingPoints.find(p => p.name && day.location && p.name === day.location)
return existing || {
name: day.location || `${di + 1}`,
lat: fallbackLat + (di * 0.5),
lng: fallbackLng + (di * 0.3),
day: `Day ${di + 1}`,
badge: di === 0 ? 'START' : (di === scheme.daysDetail.length - 1 ? 'END' : `D${di}`),
icon: ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️'][di % 13],
desc: day.desc || '',
km: day.km || (di === 0 ? '0km' : `${Math.round(Math.random() * 300 + 50)}km`),
driveTime: day.driveTime || (di === 0 ? '—' : `${Math.round(Math.random() * 5 + 1)}h`),
schedule: (day.schedule || [
{ time: '上午', title: '抵达目的地', content: '抵达目的地,开始探索' },
{ time: '下午', title: '游览景点', content: '游览主要景点' },
{ time: '晚上', title: '品尝美食', content: '品尝当地美食' }
]),
foods: day.foods || ['当地特色美食'],
hotel: day.hotel || '推荐酒店',
tips: day.tips || '注意安全,提前预订',
}
})
console.log(`[loadFromAI] 补充后 points 数量: ${scheme.points.length}`)
}
try {
const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️']
const newPoints = scheme.points.map((p, i) => {
@ -306,7 +372,7 @@ export const useItineraryStore = defineStore('itinerary', () => {
hoveredReplacement, quickSchemes, historySchemeGroups, activeSchemeIndex,
startPoint, endPoint, totalKm, totalDriveTime, travelDays, currentPoint, replacementOptions,
setPhase, setMode, setPlanningMode, resetToModeSelection, setCurrentStep, nextStep, prevStep,
deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample,
deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample, recalcBadges,
fetchRealRoutes, checkRouteOptimization, autoOptimizeRoute, dismissOptimization,
setHoveredReplacement, loadFromAI, loadSchemeByIndex, saveSchemesToStore, setActiveSchemeIndex, getStoredSchemes
}

@ -1,7 +1,7 @@
<template>
<div class="admin-page">
<div class="admin-header">
<h1>📊 管理后台</h1>
<h1>📊 管理后台 <span class="header-slogan">说走就走剩下的交给我</span></h1>
<button class="back-btn" @click="$router.push('/')"></button>
</div>
@ -86,6 +86,60 @@
</tbody>
</table>
</div>
<!-- AI 使用量统计 -->
<div class="admin-section">
<h2>🤖 Token 使用量近30天</h2>
<div class="usage-summary">
<div class="usage-stat">
<span class="usage-number">{{ formatNumber(usageStats.totalTokens) }}</span>
<span class="usage-label"> token 消耗</span>
</div>
<div class="usage-stat">
<span class="usage-number">{{ formatNumber(usageStats.totalCalls) }}</span>
<span class="usage-label">总调用次数</span>
</div>
</div>
<table class="data-table" v-if="usageStats.rows && usageStats.rows.length">
<thead>
<tr>
<th>用户</th>
<th>调用类型</th>
<th>调用次数</th>
<th>Prompt Tokens</th>
<th>Completion Tokens</th>
<th> Tokens</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in usageStats.rows" :key="i">
<td><strong>{{ row.username }}</strong></td>
<td>{{ getEventLabel(row.event_type) }}</td>
<td>{{ row.call_count }}</td>
<td>{{ formatNumber(row.prompt_tokens) }}</td>
<td>{{ formatNumber(row.completion_tokens) }}</td>
<td><strong>{{ formatNumber(row.total_tokens) }}</strong></td>
<td>{{ formatDuration(row.total_duration_ms) }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty-hint">暂无数据AI 调用后会自动记录</div>
</div>
<!-- 大模型调用次数统计 -->
<div class="admin-section">
<h2>📞 大模型调用统计近30天</h2>
<div class="usage-summary">
<div class="usage-stat" v-for="m in modelStats.models" :key="m.model">
<span class="usage-number">{{ m.call_count }}</span>
<span class="usage-label">{{ m.model }}</span>
</div>
<div class="usage-stat" v-if="!modelStats.models || !modelStats.models.length">
<span class="usage-number"></span>
<span class="usage-label">暂无数据</span>
</div>
</div>
</div>
</div>
</template>
</div>
@ -103,6 +157,8 @@ const loading = ref(true)
const overview = ref({})
const events = ref([])
const users = ref([])
const usageStats = ref({ rows: [], totalTokens: 0, totalCalls: 0 })
const modelStats = ref({ models: [] })
const totalEventsCount = ref(0)
@ -123,21 +179,28 @@ async function loadData() {
const API_BASE = 'http://localhost:3001/api'
const headers = { 'Authorization': `Bearer ${authStore.token}` }
//
const overviewRes = await fetch(`${API_BASE}/stats/admin/overview`, { headers })
const overviewData = await overviewRes.json()
overview.value = overviewData.overview
async function apiFetch(url) {
const res = await fetch(url, { headers })
const data = await res.json()
if (!res.ok) throw new Error(data.error || `请求失败: ${res.status}`)
return data
}
//
const [overviewData, eventsData, usersData, usageData, modelsData] = await Promise.all([
apiFetch(`${API_BASE}/stats/admin/overview`),
apiFetch(`${API_BASE}/stats/admin/events-by-type?days=30`),
apiFetch(`${API_BASE}/stats/admin/users`),
apiFetch(`${API_BASE}/stats/admin/usage/tokens?days=30`),
apiFetch(`${API_BASE}/stats/admin/usage/models?days=30`)
])
//
const eventsRes = await fetch(`${API_BASE}/stats/admin/events-by-type?days=30`, { headers })
const eventsData = await eventsRes.json()
overview.value = overviewData.overview
events.value = eventsData.events
totalEventsCount.value = eventsData.events.reduce((sum, e) => sum + e.count, 0)
//
const usersRes = await fetch(`${API_BASE}/stats/admin/users`, { headers })
const usersData = await usersRes.json()
users.value = usersData.users
usageStats.value = usageData
modelStats.value = modelsData
} catch (err) {
console.error('加载数据失败:', err)
alert('加载数据失败')
@ -146,6 +209,18 @@ async function loadData() {
}
}
function formatNumber(n) {
if (!n && n !== 0) return '—'
return n.toLocaleString()
}
function formatDuration(ms) {
if (!ms) return '—'
if (ms < 1000) return ms + 'ms'
if (ms < 60000) return (ms / 1000).toFixed(1) + 's'
return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's'
}
function getEventLabel(type) {
const labels = {
'page_view': '页面访问',
@ -156,7 +231,15 @@ function getEventLabel(type) {
'scheme_select': '选择方案',
'user_register': '用户注册',
'user_login': '用户登录',
'guest_data_migrated': '游客数据迁移'
'guest_data_migrated': '游客数据迁移',
'chat': 'AI 对话',
'gather': '需求收集',
'generate': '方案生成',
'quick_plan': '快速规划',
'custom_plan': '自定义规划',
'replace': 'AI 替换',
'enrich': 'AI 充实',
'reorganize': 'AI 重排'
}
return labels[type] || type
}
@ -198,6 +281,16 @@ function formatDate(dateStr) {
color: #2d3436;
}
.admin-header h1 .header-slogan {
font-size: 14px;
font-weight: 400;
color: #b2bec3;
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid #dfe6e9;
letter-spacing: 0.5px;
}
.back-btn {
background: #6c5ce7;
color: #fff;
@ -310,4 +403,42 @@ function formatDate(dateStr) {
background: #e8e4ff;
color: #6c5ce7;
}
/* Usage stats */
.usage-summary {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.usage-stat {
background: #f5f7fa;
border-radius: 10px;
padding: 14px 20px;
text-align: center;
min-width: 120px;
flex: 1;
}
.usage-number {
display: block;
font-size: 28px;
font-weight: 700;
color: #6c5ce7;
}
.usage-label {
display: block;
font-size: 12px;
color: #636e72;
margin-top: 4px;
}
.empty-hint {
text-align: center;
padding: 30px;
color: #b2bec3;
font-size: 14px;
}
</style>

@ -2,7 +2,7 @@
<div class="home-page">
<!-- Top Navigation Bar -->
<nav class="top-bar">
<div class="brand"><span class="icon"></span> 智能行程规划</div>
<div class="brand"><span class="icon"></span> 智能行程规划 <span class="slogan">说走就走剩下的交给我</span></div>
<div class="top-bar-right">
<button v-if="authStore.isAdmin" class="admin-btn" @click="$router.push('/admin')"></button>
<button v-if="authStore.isAuthenticated" class="user-toggle-btn" @click="toggleUserProfile">
@ -150,6 +150,16 @@ const goHome = () => {
color: #2d3436;
}
.top-bar .brand .slogan {
font-size: 12px;
font-weight: 400;
color: #b2bec3;
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid #dfe6e9;
letter-spacing: 0.5px;
}
.top-bar .brand .icon::before {
content: '';
display: inline-block;

@ -3,6 +3,7 @@
<div class="settings-header">
<button class="back-btn" @click="goBack"> </button>
<h1> AI 模型配置</h1>
<span class="header-slogan">说走就走剩下的交给我</span>
<div class="header-spacer"></div>
</div>
@ -243,6 +244,15 @@ onMounted(async () => {
font-size: 18px;
}
.header-slogan {
font-size: 12px;
color: #b2bec3;
font-weight: 400;
padding-left: 10px;
border-left: 1px solid #dfe6e9;
letter-spacing: 0.5px;
}
.header-spacer { flex: 1; }
.settings-content {

@ -5,6 +5,7 @@
<div class="wb-plan-selector">
<span class="shared-badge">👁 只读模式 · {{ sharedData?.planName || '行程' }}</span>
</div>
<span class="wb-slogan">说走就走剩下的交给我</span>
<button class="wb-copy-btn" @click="copyShareLink">📋 </button>
</div>
@ -232,6 +233,15 @@ watch(() => store.currentStep, (newIdx, oldIdx) => {
z-index: 100;
}
.wb-slogan {
font-size: 12px;
color: #b2bec3;
font-weight: 400;
padding: 0 12px;
letter-spacing: 0.5px;
white-space: nowrap;
}
.wb-plan-selector {
display: flex;
align-items: center;

@ -5,6 +5,12 @@ export default defineConfig({
plugins: [vue()],
base: './',
server: {
historyApiFallback: true
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})

Loading…
Cancel
Save