diff --git a/.data/trip-planner.db-shm b/.data/trip-planner.db-shm
index 70ea03e..15aa23c 100644
Binary files a/.data/trip-planner.db-shm and b/.data/trip-planner.db-shm differ
diff --git a/.data/trip-planner.db-wal b/.data/trip-planner.db-wal
index 7ac90c2..ba478ec 100644
Binary files a/.data/trip-planner.db-wal and b/.data/trip-planner.db-wal differ
diff --git a/server.js b/server.js
index e3c2c1e..b1f5606 100644
--- a/server.js
+++ b/server.js
@@ -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) {
diff --git a/src/components/ChatInterface.vue b/src/components/ChatInterface.vue
index f5aef32..7b824f1 100644
--- a/src/components/ChatInterface.vue
+++ b/src/components/ChatInterface.vue
@@ -2,36 +2,64 @@
+
- 你好!我是你的行程规划助手。请告诉我:
- • 想去哪里?(单个地点或多个地点组合)
- • 几月出发?几天行程?
- • 交通方式?(自驾/公共交通/步行)
- • 有什么特殊需求?(带老人/小孩/特定景点)
-
- 示例:9月去云南,5天,自驾,带老人
+ 你好!我是你的行程规划助手 🗺️
+ 接下来我会和你聊一聊,了解一下你的旅行想法。
+ 比如想去哪里、什么时候出发、玩几天……
+ 不用一次说全,我们慢慢聊~
+
-
-
{{ msg.content }}
-
-
- {{ msg.error }}
-
+
{{ msg.error }}
+
+
+
+
+
+
+
+ 出行时间
+ {{ gatheredInfo.time || '—' }}
+
+
+ 出发地
+ {{ gatheredInfo.origin || '—' }}
+
+
+ 目的地
+ {{ gatheredInfo.destination || '—' }}
+
+
+ 出行方式
+ {{ gatheredInfo.transport || '—' }}
+
+
+ 人员组成
+ {{ gatheredInfo.travelers || '—' }}
+
+
+ 偏好
+ {{ gatheredInfo.preferences.join('、') }}
+
+
+ 意向地点
+ {{ gatheredInfo.intendedSpots }}
+
+
+ 禁忌
+ {{ gatheredInfo.taboos }}
+
+
+
+
@@ -45,7 +73,7 @@
-
+
diff --git a/src/db/database.js b/src/db/database.js
index 7c368ca..770c936 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -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);
`)
// 清理过期游客数据的函数
diff --git a/src/routes/stats.js b/src/routes/stats.js
index 38d44f4..5365010 100644
--- a/src/routes/stats.js
+++ b/src/routes/stats.js
@@ -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
diff --git a/src/services/aiService.js b/src/services/aiService.js
index faa37a3..bd86c82 100644
--- a/src/services/aiService.js
+++ b/src/services/aiService.js
@@ -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 中每个对象必须包含 name、icon、desc
8. waypoints 中每个对象必须包含 name、icon、desc
9. daysDetail 数组长度必须等于 days,每天一个对象
-10. 直接输出 JSON,不要其他内容`
+10. points 数组必须包含行程中所有途经站点(每个 daysDetail 中的 location 至少对应一个 point),总点数至少 3 个(START、END 和至少一个中间站),每个 point 的 badge 依次为 START、D1、D2...、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(/[\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. 每个替代品必须包含 icon、name、desc、lat、lng
+
+## 输出格式(直接输出 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,包含 time、title、content、desc)
+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,包含 time、title、content、desc 三段时间)
+5. 推荐当地美食 (foods,至少 4 种,含 name、icon、desc)
+6. 推荐住宿 (hotel)
+7. 提供注意事项 (tips)
+8. 推荐途径景点 (waypoints,1-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 种,每种含 name、icon、desc
+- 行程安排 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
+}
diff --git a/src/stores/itinerary.js b/src/stores/itinerary.js
index 8694377..ff0e071 100644
--- a/src/stores/itinerary.js
+++ b/src/stores/itinerary.js
@@ -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
}
diff --git a/src/views/AdminPage.vue b/src/views/AdminPage.vue
index 0250f71..373d4e3 100644
--- a/src/views/AdminPage.vue
+++ b/src/views/AdminPage.vue
@@ -1,7 +1,7 @@
@@ -86,6 +86,60 @@
+
+
+
🤖 Token 使用量(近30天)
+
+
+ {{ formatNumber(usageStats.totalTokens) }}
+ 总 token 消耗
+
+
+ {{ formatNumber(usageStats.totalCalls) }}
+ 总调用次数
+
+
+
+
+
+ | 用户 |
+ 调用类型 |
+ 调用次数 |
+ Prompt Tokens |
+ Completion Tokens |
+ 总 Tokens |
+ 耗时 |
+
+
+
+
+ | {{ row.username }} |
+ {{ getEventLabel(row.event_type) }} |
+ {{ row.call_count }} |
+ {{ formatNumber(row.prompt_tokens) }} |
+ {{ formatNumber(row.completion_tokens) }} |
+ {{ formatNumber(row.total_tokens) }} |
+ {{ formatDuration(row.total_duration_ms) }} |
+
+
+
+
暂无数据,AI 调用后会自动记录
+
+
+
+
+
📞 大模型调用统计(近30天)
+
+
+ {{ m.call_count }}
+ {{ m.model }}
+
+
+ —
+ 暂无数据
+
+
+
@@ -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;
+}
diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue
index 6c86620..80701c0 100644
--- a/src/views/HomePage.vue
+++ b/src/views/HomePage.vue
@@ -2,7 +2,7 @@