From 16201d567b127b40c9841adf60d3f0b3927978ed Mon Sep 17 00:00:00 2001 From: Lxy Date: Mon, 8 Jun 2026 23:27:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E3=80=81=E7=AE=A1=E7=90=86=E5=91=98=E3=80=81=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E7=AD=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .data/trip-planner.db-shm | Bin 32768 -> 32768 bytes .data/trip-planner.db-wal | Bin 4128272 -> 4128272 bytes server.js | 70 +- src/components/ChatInterface.vue | 314 +++++-- src/components/MapView.vue | 33 +- src/components/UserProfilePanel.vue | 11 +- src/components/Workbench.vue | 1184 ++++++++++++++++++++++++++- src/db/database.js | 17 + src/routes/stats.js | 100 ++- src/services/aiService.js | 538 +++++++++++- src/stores/itinerary.js | 70 +- src/views/AdminPage.vue | 157 +++- src/views/HomePage.vue | 12 +- src/views/SettingsPage.vue | 10 + src/views/SharedPlanView.vue | 10 + vite.config.js | 8 +- 16 files changed, 2365 insertions(+), 169 deletions(-) diff --git a/.data/trip-planner.db-shm b/.data/trip-planner.db-shm index 70ea03eb397c56896fe247ba80b2ae57af974ce5..15aa23cc649a32bc0a6f6dbb018a058520cc253e 100644 GIT binary patch delta 528 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 delta 2498 zcmaLYeN>cH9>8&*XPlX5@Xo#S!pty;FuaK@Gs%l0f)a!tP(bD(U1=RUXn9mDn_|o9 zWa8?ipt)hBPpoV}m>6RDByv^O70r{NTacp0*{!A$itH%vsZQtUiSC`}8JDKr<(%(7 z_so6xedgYosk?WVsUvOiGG5lJ@J{wBy(+J|lZyp&43o%T3cI3i&C!}N6Zig-qv^u= z5VD4leiFKl9!2v|6w;$!bP~0r{pe-%BHDl)xH%PM!95k7!a#>zD?~0(j0#UZ1T-kO z#C!VL@wP+$N5BRlYM~-Z7Uzu5Qs{)N7@+0ejz6-m z%*{{5WoR~8yYv#&+B-YiH>XXm=IJ~-#Qq-=VgsmF!FNsz+dR2|K|OE&V^zZIW#9Ps z!#`0^G&W4j&S%65gVwLYyAHq?8INuE z4j&2cxt*1eD7*A@EYf|~sqX5p8du(W9QTUp0oUydMZU{%!;RQ?mab9P1Hjz%argEX z9i2-Lzn|cO-v=Moe`NLk+ErAzQT+`6Vo#i`jcx4*BS-+xfoc}~+{q`$8 z*tQ@eKs!AAS>>w-cUI$!rF4zblDF*}i(T5Pi==ZI;K;9NaO8jEwn?}ZxnLRtD|}Zs zzwzUmW70!}$c2jOA|EeZL;S*O^bP|ZYrkBgYJZxdjNDF&N)UM$#}*g`-lEPCeC0re zD_4MQHRr~AAG6x`4$CxSFzw$b&gbso65hql3+D!CO)Ybbe_Yyj8=qK4*C;Krq0zVT zSluQ+%?Pgt(dx%+p@BR2jQ&;& zgJ!%QGs*e>Sr;XRaym*%#LsNZ^r_qGiXq2^Ut6|kD8HmpW`qVauB_R=ZaCt{CTz>k zq$6(yP(Ap~>YFW^l{jNLUCK%^!>H-0{qxp7c!f?A3j2Z(ux}z`m2@T_wSY_pEfQ7b zH&Qg6(#B}YNEG=y@~0^20Q{6e!?86D%f2pug%V8aeUy+S@{g-okI8@4b`?V<$3j z{TmAGo`@-xFui$-2N~(x?)T2)#tXW|L(+Z?6(!-ADGAHfV#OAf^by+c1V1pSt+UVT ze&|CJb(z&g)a4NQFg06Xo^adje*l{}?&QVKrh(E!wX)6}taH`3T`c0x#NwK)`2n5F zy}WkH&+WT$b0J;x>uT85#XR?F7@f`xJN~=Q9Jq10(v3Tnrzn(`I_~#d}4Q7p_WDeJa%)kyQk|3Dzm6AQ`tVX%%0EE5IMlHjj-ib`3q>D*}g7s;c8{vF!0RFER_ zwG#yctuTW{GrHW*|1`JnIu%$|kP6a7eo)C4SpAm_4-~>44R_Q3sj|dStHzzjdTVp@(Q=(6lwU}4Ym&n-F72{S3;VeUFq__$yV|+ zDWk#ymrEyIu2=FD9vP*3eqZ&?vB_Hh1GR5cR2sfK95(a*ua605J3%Rna%vBmKM1+# z|5giEzD8BEMZQeQdcw&Cdk%L1qzqA?oQ2}1T(e6iCVYp@07zOABq5THq$e3jMp7ur zL^6{sq%e||ME6c5MUbYEB1uss8!4I;Ly9HEk>W|yNeQGGq(sv1NJ%6+$w7L6G?SD} jN+G3^(nzyNvq|ZsIiw8ITv8?}i!_fkAKMJsV>f>R)1e{! 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天,自驾,带老人 + 你好!我是你的行程规划助手 🗺️
+ 接下来我会和你聊一聊,了解一下你的旅行想法。
+ 比如想去哪里、什么时候出发、玩几天……

+ 不用一次说全,我们慢慢聊~
+
- -
-
- - AI 思考过程 - {{ msg.thinkingExpanded ? '收起' : '展开' }} -
-
{{ msg.thinking }}
-
{{ 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 @@
- +
{{ getSchemeLabel(i) }} @@ -60,28 +88,41 @@ - +
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 @@ @@ -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 @@
@@ -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; diff --git a/vite.config.js b/vite.config.js index 0532775..0bcb220 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,12 @@ export default defineConfig({ plugins: [vue()], base: './', server: { - historyApiFallback: true + historyApiFallback: true, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + } + } } })